1. 为什么需要分页查询与资源优化处理百万级数据导出时最直接的思路可能是一次性查询所有数据然后写入文件。但实际开发中你会发现这种简单粗暴的方式会导致两个致命问题内存溢出和响应超时。我曾在项目中遇到过导出50万条数据时直接让服务崩溃的情况后来分析发现是JVM堆内存被ResultSet占满导致的。分页查询的核心思想就像我们吃饭时的少量多次原则。想象一下如果让你一次性吃完100个包子不仅难以下咽还可能撑坏胃。但分成10次、每次10个就轻松多了。数据库查询也是同样的道理内存控制每次只加载部分数据到内存比如每页1000条网络传输避免单次传输超大结果集导致网络阻塞响应速度用户可以更快看到首批数据体验更好资源优化则像是给这个吃饭过程加上健康管理用连接池避免反复创建/销毁连接相当于不用每次都重新拿筷子及时关闭ResultSet防止内存泄漏吃完的盘子要及时收走合理设置事务隔离级别避免吃饭时被其他人打扰2. 分页查询的四种实现方案2.1 LIMIT OFFSET方案这是最基础的分页方式适合数据量不大的场景String sql SELECT * FROM orders LIMIT 1000 OFFSET 2000;优点实现简单直观所有数据库都支持缺点大数据量时OFFSET效率低下需要先扫描跳过记录深度分页性能差翻到第1000页时2.2 游标分页方案针对深度分页的优化方案适合持续导出的场景// 第一页 String sql SELECT * FROM orders WHERE id 0 ORDER BY id LIMIT 1000; // 后续页用上一页最后ID String nextPageSql SELECT * FROM orders WHERE id ? ORDER BY id LIMIT 1000;优化点用WHERE替代OFFSET需要有序且唯一的排序列实测导出100万数据速度提升3倍2.3 分区并行方案结合线程池的进阶玩法我去年在电商订单导出中实际采用ExecutorService executor Executors.newFixedThreadPool(4); ListFutureVoid futures new ArrayList(); // 按ID范围分区查询 for (int i 0; i 4; i) { long startId i * 250000; long endId (i 1) * 250000; futures.add(executor.submit(() - { exportRange(startId, endId); return null; })); }注意事项需要确保数据分区均匀数据库连接池大小要适配最终文件需要合并2.4 存储过程方案适合数据库性能优于应用服务器的场景CREATE PROCEDURE export_orders(IN page_size INT) BEGIN DECLARE done INT DEFAULT FALSE; DECLARE cur CURSOR FOR SELECT * FROM orders; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done TRUE; OPEN cur; read_loop: LOOP -- 批量获取数据 -- 直接输出到文件 IF done THEN LEAVE read_loop; END IF; END LOOP; CLOSE cur; END3. 资源优化的五个关键点3.1 连接池配置实战推荐使用HikariCP的配置模板HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/db); config.setUsername(user); config.setPassword(pass); config.setMaximumPoolSize(10); // 根据数据库负载调整 config.setConnectionTimeout(30000); config.setIdleTimeout(600000); config.setMaxLifetime(1800000); config.setAutoCommit(false); // 批处理建议关闭自动提交 // 关键优化参数 config.addDataSourceProperty(cachePrepStmts, true); config.addDataSourceProperty(prepStmtCacheSize, 250); config.addDataSourceProperty(prepStmtCacheSqlLimit, 2048);避坑指南连接数不是越多越好建议CPU核心数*2 磁盘数遇到连接泄漏时添加leakDetectionThresholdMySQL记得设置useServerPrepStmtstrue3.2 内存管理技巧通过JVM参数和代码层面的双重优化# 启动参数建议 java -Xms512m -Xmx2g -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:InitiatingHeapOccupancyPercent35代码层面try (ResultSet rs stmt.executeQuery()) { while (rs.next()) { // 使用流式处理 writer.writeRow(rs.getString(1), ...); // 每1000条手动触发GC if (count % 1000 0) { System.gc(); } } }3.3 文件写入优化对比三种写入方式的性能差异方式100万条耗时内存占用全量String拼接失败(OOM)2GB单行追加写入45s50MB缓冲批量写入28s80MB推荐使用Apache Commons CSV的写法CSVFormat format CSVFormat.DEFAULT .withHeader(ID, Name, Amount); try (CSVPrinter printer new CSVPrinter( new BufferedWriter(new FileWriter(data.csv), 81920), format)) { for (Order order : orders) { printer.printRecord( order.getId(), order.getName(), order.getAmount()); } }3.4 异常处理机制设计健壮的重试机制int retry 3; while (retry 0) { try { exportBatch(currentPage); break; } catch (SQLException e) { if (--retry 0) throw e; Thread.sleep(1000 * (4 - retry)); } }记录关键日志每页导出的起止ID单页耗时内存快照网络IO情况3.5 数据库侧优化给DBA的检查清单确保查询用到合适的索引临时调大sort_buffer_size导出期间避免ANALYZE TABLE操作考虑从备库读取调整max_allowed_packet大小4. 性能监控与调优4.1 监控指标看板搭建简单的监控体系// 在分页循环中添加统计 long startTime System.currentTimeMillis(); exportPage(pageNo); long cost System.currentTimeMillis() - startTime; metrics.put(pageNo, new PageMetric( cost, Runtime.getRuntime().freeMemory(), getDBConnectionActiveCount() ));关键指标阈值参考指标警告阈值危险阈值单页耗时2s5s内存使用率70%90%连接等待数5104.2 JVM调优实战G1GC的典型配置-XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:InitiatingHeapOccupancyPercent35 -XX:G1ReservePercent15 -XX:ConcGCThreads4遇到内存问题时用jmap诊断jmap -histo:live pid | head -20 jmap -dump:formatb,fileheap.hprof pid4.3 分布式导出方案当单机无法处理时可以考虑按时间范围切分任务按ID哈希分配集群节点最终合并部分文件// 使用Spring Batch的分布式处理 Bean public Partitioner partitioner() { return new ColumnRangePartitioner(id, minId, maxId); } Bean public Step slaveStep() { return stepBuilderFactory.get(slaveStep) .Order, Orderchunk(1000) .reader(partitionReader()) .writer(csvWriter()) .build(); }5. 完整实现案例结合Spring Boot的完整解决方案RestController public class ExportController { Autowired private DataSource dataSource; GetMapping(/export) public ResponseEntityStreamingResponseBody export( RequestParam String token, HttpServletResponse response) { response.setContentType(text/csv); response.setHeader(Content-Disposition, attachment; filenameexport.csv); return ResponseEntity.ok() .body(outputStream - { try (Connection conn dataSource.getConnection(); PreparedStatement stmt conn.prepareStatement( SELECT * FROM orders WHERE id ? ORDER BY id LIMIT 1000, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { stmt.setFetchSize(1000); long lastId 0; CSVPrinter printer new CSVPrinter( new OutputStreamWriter(outputStream), CSVFormat.DEFAULT.withHeader(HEADERS)); while (true) { stmt.setLong(1, lastId); ResultSet rs stmt.executeQuery(); int count 0; while (rs.next()) { printer.printRecord( rs.getLong(id), rs.getString(order_no), rs.getBigDecimal(amount)); lastId rs.getLong(id); count; } if (count 0) break; } } }); } }生产环境增强功能通过token验证导出权限支持断点续传后台进度查询接口自动清理过期导出文件