Java千万级CSV导出内存溢出解决方案:流式处理实践

发布时间:2026/9/8 12:12:45
Java千万级CSV导出内存溢出解决方案:流式处理实践 简介面向 Java 开发者的千万级 CSV 数据导出方案解决一次性加载全部数据导致内存溢出的痛点适用于报表导出、电商订单下载、运营数据归档等大数据量场景。资源共 10 个文件含 9 个 Java 源文件与 1 个说明文档压缩包仅 10KB轻量易用。源码覆盖批量切分、多线程调度、线程池复用、CSV 写入、压缩打包与下载响应等完整链路包含多个核心类分别负责分块导出主流程、并发任务调度、工具封装与下载集成便于对照学习。已有 15559 人学习下载。读者可从中掌握分批拉取数据、借助线程池并行写入、通过缓存流逐行输出以避免内存溢出的关键手法并结合说明文档中的调优参数、错误重试、日志监控经验快速迁移到实际项目中解决高并发导出难题。整个工程结构清晰适合在真实项目中直接参考或裁剪复用。1. 从一次线上OOM事故说起千万级CSV导出的痛点前两年我做过一个对账系统每个月末要把业务流水导出成 CSV 给财务。平时几百万行数据用最原始的“查全量 List → 逐行拼字符串 → 一次性写文件”方案跑得还算稳。结果上线后的第一个月末导出量直接冲到一千多万行应用在导出开始后不到两分钟就报了java.lang.OutOfMemoryError: Java heap space紧接着整台机器 GC 频繁连正常请求都受了影响。那个月底我的手机被运维和财务同时打爆。这次事故让我把 Java 做 CSV 大数据量导出的问题彻底研究了一遍。先说结论千万级数据导出只要还在用“一次查全部到内存、一次拼全部字符串、一次写全部文件”的思路堆内存再大也扛不住。真正的解法是四个字流式处理。数据库那边用游标逐行读文件那边用 Writer 逐行写数据像流水一样从数据库流到磁盘内存里只保留很小一块缓冲区。这套方案我后来在多个项目里落地过导出 2000 万行、单文件 3GB 以上也没有再出现内存溢出。这篇内容不是讲理论是把我在生产环境里踩过的坑、调过的参数、写过的代码结构完整分享出来。适合正在处理大数据量导出的 Java 开发者也适合那些用 MyBatis、JDBC 做报表导出但总被 OOM 困扰的团队。2. 常规导出的三宗罪为什么内存会爆在写正确方案之前先复盘一下常见的错误做法。很多人一开始都会写类似这样的代码// 错误示范一次性查询所有数据 ListOrder orders orderMapper.selectAll(); // 错误示范一次性拼装所有CSV行 StringBuilder sb new StringBuilder(); for (Order order : orders) { sb.append(order.getId()).append(,) .append(order.getAmount()).append(\n); } // 错误示范一次性写入文件 FileUtils.writeStringToFile(new File(/tmp/orders.csv), sb.toString(), UTF-8);这段代码在数据量小的时候没有任何问题一旦数据量到千万级三个环节全部爆掉。2.1 List 对象本身就把堆占满了第一个爆点是ListOrder。假设一行订单数据在 Java 堆里占用 200 字节一千万行就是 2GB 左右。这还只是业务对象本身的估算值实际上 ArrayList 的底层数组、每个对象的对象头、String 内部 char[] 的额外开销算下来单行占用往往超过 400 字节。一千万行就是 4GB 起步。大多数应用服务器配置的 Xmx 也就 2GB~4GB光查出来就已经撑爆了。2.2 StringBuilder 拼接字符串导致内存翻倍第二个爆点是 StringBuilder。CSV 每一行拼出来的字符串会占用一份新的内存空间StringBuilder 底层 char[] 还有扩容拷贝开销。更致命的是拼接过程中原来在 List 里的对象仍然活着StringBuilder 里的字符串也在活着内存峰值几乎是“对象占用量 字符串占用量”的总和。我之前实测过List 占 3GB 时再把所有行拼成一个超大字符串额外又吃掉了 2GB 多等于翻倍。2.3 一次性写文件让内存和磁盘同时遭殃第三个爆点是FileUtils.writeStringToFile这个方法会先把整个字符串再转成 byte[]然后又复制一份。到了这一步堆里同时存在 List、StringBuilder、String、byte[]四份数据共享同一段业务内容。千万级导出走到这一步堆内存基本是死刑。所以常规方案的问题本质是数据生命周期太长了。从数据库到文件中间所有数据都停留在内存里而不是边读边释放。正确做法是让数据像流水一样读一行、处理一行、写一行、丢掉一行。3. 核心设计数据库游标 CSV Writer让数据“流过”内存千万级导出的标准套路是“源端流式读 目标端流式写”。源端用 JDBC 游标逐行从数据库取数目标端用 BufferedWriter 或 CSVWriter 逐行写文件。整条链路上内存里同一时刻最多只保留一批数据比如几百行这样堆内存占用就从“数据总量级别”降到了“批大小级别”。3.1 游标查询为什么能避免加载全部数据JDBC 默认的查询行为是“一次性把结果集全部拉到客户端内存”。想改变这个行为需要做两件事在 Statement 上设置fetchSize让数据库每次只传一批数据给客户端设置连接参数useCursorFetchtrueMySQL或使用ResultSet.TYPE_FORWARD_ONLY模式。以 MySQL 为例连接串里要加上useCursorFetchtrue同时statement.setFetchSize(1000)。这样 MySQL 服务端会维护一个游标客户端每次调用rs.next()时JDBC 驱动才从服务端拉取下一批数据。注意useCursorFetchtrue是 MySQL 驱动支持游标读取的关键参数不加这个光设置 fetchSize 对 MySQL 可能不生效。MyBatis 也支持游标查询用CursorT作为返回值类型public interface OrderMapper { CursorOrder scanAll(); }select idscanAll resultTypecom.example.Order fetchSize1000 select id, user_id, amount, create_time from t_order /select调用方拿到CursorOrder之后遍历时可以逐行处理底层打开了一个游标结果集。千万级数据不会一次性加载到内存。注意MyBatis 游标查询要求在 Spring 管理的事务里使用而且打开游标之后要及时遍历关闭否则数据库连接会被长期占用。如果你用的是 Spring Boot需要把查询方法放在一个Transactional方法中或者手动控制连接和关闭。3.2 CSV 写出端不要手动拼大字符串很多人在写出端也会犯错先拼一个超大 CSV 字符串再写文件。正确做法是拿到一行数据就直接追加到BufferedWriter并且每写一批就flush()一次。我建议直接用org.apache.commons.csv.CSVPrinter或者com.opencsv.CSVWriter它们会自动处理字段转义、逗号、引号、换行符等规则。手写拼接最容易出的问题就是字段里包含逗号或换行符导致 CSV 错列。下面是我常用的写法try (BufferedWriter writer Files.newBufferedWriter(Paths.get(filePath), StandardCharsets.UTF_8); CSVPrinter printer new CSVPrinter(writer, CSVFormat.DEFAULT.withHeader( ID, 用户ID, 金额, 创建时间))) { try (CursorOrder cursor orderMapper.scanAll()) { for (Order order : cursor) { printer.printRecord(order.getId(), order.getUserId(), order.getAmount(), order.getCreateTime()); // 每1000行flush一次避免缓冲区积压过多数据 if (rowCount.get() % 1000 0) { printer.flush(); } } } } catch (IOException e) { log.error(导出CSV写入失败, e); }这里的flush()频率很关键。flush 太频繁会降低吞吐因为每次 flush 都是一次磁盘 IOflush 太少又会让BufferedWriter内部缓冲区变得很大。我们线上测试下来每 1000 行 flush 一次8KB 默认缓冲区足够磁盘 IO 和内存占用都很均衡。3.3 双流之间的背压与缓冲控制游标读和文件写之间天然就是生产者和消费者的关系。游标从数据库拉一批数据然后遍历写入文件写完这一批再拉下一批这个循环天然形成了“背压”——数据库不会无限往内存塞数据文件写入速度慢时游标遍历就会停在那里等。这比“异步队列 多线程写文件”的方式简单且稳定得多。我见过有人把数据先丢到阻塞队列再起线程池消费结果队列积压几十万条任务内存照样爆。对于单文件导出没必要引入队列一个线程串行处理就足够了。只有在需要生成多个分片文件并并行处理时才考虑用多线程。4. 手把手实现一套可落地的异步导出服务上面说完了原理这一节给出一套能直接抄作业的 Spring Boot 实现。整体流程是前端发起导出请求后端生成一个导出任务异步线程负责查询数据库并写 CSV 文件完成后把下载地址返回给前端。4.1 表结构与场景假设假设我们要导出一张订单流水表t_order有 2000 万行数据字段包括CREATE TABLE t_order ( id bigint(20) NOT NULL, user_id varchar(64) DEFAULT NULL, amount decimal(20,2) DEFAULT NULL, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;导出需求是把全量订单导出为 CSV文件名包含日期导出过程中不能影响线上服务。4.2 Mapper 层游标查询完整代码首先定义一个 DTO 和 MapperData public class Order { private Long id; private String userId; private BigDecimal amount; private LocalDateTime createTime; }Mapper public interface OrderMapper { Select(select id, user_id, amount, create_time from t_order) Options(fetchSize 1000) CursorOrder scanAllOrders(); }注意Options(fetchSize 1000)只对 JDBC 层的 Statement 生效结合 MySQL 连接串里的useCursorFetchtrue才能真正逐批读取。在 Spring Boot 的配置文件里数据源 URL 要这样写spring.datasource.urljdbc:mysql://localhost:3306/report_db?useUnicodetruecharacterEncodingutf8useCursorFetchtruedefaultFetchSize1000这里顺便说一个容易踩的坑有些版本的 MySQL JDBC 驱动要求useCursorFetchtrue时连接必须是非 autoCommit 模式否则游标读取会报错。Spring Boot 的 DataSource 默认会开启事务管理但如果你在 Service 方法上忘了加Transactional游标读取就可能在提交模式下运行导致报错。所以最稳的方式是把游标遍历放在事务方法里。4.3 异步任务与文件写出实现下面定义一个导出服务Service public class CsvExportService { private static final int FLUSH_LINE_COUNT 1000; private final OrderMapper orderMapper; private final ThreadPoolTaskExecutor exportExecutor; public CsvExportService(OrderMapper orderMapper, Qualifier(exportExecutor) ThreadPoolTaskExecutor exportExecutor) { this.orderMapper orderMapper; this.exportExecutor exportExecutor; } public void exportAsync(String filePath) { exportExecutor.execute(() - export(filePath)); } Transactional public void export(String filePath) { Path path Paths.get(filePath); try (BufferedWriter writer Files.newBufferedWriter(path, StandardCharsets.UTF_8); CSVPrinter printer new CSVPrinter(writer, CSVFormat.DEFAULT.withHeader( ID, 用户ID, 金额, 创建时间))) { long rowCount 0; try (CursorOrder cursor orderMapper.scanAllOrders()) { for (Order order : cursor) { printer.printRecord(order.getId(), order.getUserId(), order.getAmount(), order.getCreateTime()); rowCount; if (rowCount % FLUSH_LINE_COUNT 0) { printer.flush(); } } } printer.flush(); log.info(导出完成总行数{}, rowCount); } catch (Exception e) { log.error(导出失败, e); throw new RuntimeException(e); } } }这段代码有几点值得说Transactional保证了游标遍历期间连接不会被归还连接池这是 MyBatis Cursor 能正常工作的前提。try-with-resources保证了 Cursor 和 Writer 在异常时都能关闭避免连接泄漏。flush()放在每 1000 行一次既能及时落盘又不会频繁触发磁盘 IO。千万级数据导出时间一般需要几十秒到几分钟所以必须异步执行避免阻塞用户请求。线程池的定义可以这样配Bean(exportExecutor) public ThreadPoolTaskExecutor exportExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); executor.setMaxPoolSize(2); executor.setQueueCapacity(100); executor.setThreadNamePrefix(csv-export-); executor.initialize(); return executor; }注意这里核心线程数不要配太多。导出是 IO 密集型任务但数据库的游标查询和文件写入都可能成为瓶颈通常一个导出任务占满一条数据库连接并发导出太多会影响线上数据库。我建议同时只允许 1~2 个导出任务运行。4.4 对外接口与任务状态为了让前端能追踪导出进度我一般会设计一个简单的任务表来记录状态public class ExportTask { private String taskId; private String fileName; private String status; // RUNNING, SUCCESS, FAILED private Long totalRows; private String errorMsg; private LocalDateTime createTime; private LocalDateTime finishTime; }任务开始时插入一条 RUNNING 记录完成后更新为 SUCCESS 并记录总行数失败则更新为 FAILED。前端轮询接口拿到任务状态后展示进度和下载链接。只靠异步方法返回一个 taskId比同步阻塞等待体验好得多。5. 生产环境实测参数调优与踩坑记录代码能跑通只是第一步真正到了生产环境还要面对很多细节问题。我分几类说一下都是实际踩过的。5.1 fetchSize、超时、连接参数调优前面提到 MySQL 游标读取需要useCursorFetchtrue。还有一个关键点是defaultFetchSize如果连接池中有些连接的属性没有正确继承可能会造成游标读取失效。我建议在 JDBC URL 里显式加上defaultFetchSize1000这样所有从这个 DataSource 拿到的连接都会带上默认 fetchSize。数据库连接的超时要注意很多 MySQL 实例的wait_timeout是 8 小时但导出大表时单个查询时间可能很长尤其是冷数据查询规划、磁盘 IO 都可能拖慢速度。如果查询超过 MySQL 的net_read_timeout或net_write_timeout默认值连接会被服务端断开导出任务报Communications link failure。生产环境我们一般把连接串加上netTimeoutForStreamingResults0来关闭流式结果集的超时限制或者适当调大 MySQL 服务端的net_read_timeout和net_write_timeout。还有连接池配置。Druid 或 HikariCP 里建议把connection-timeout调大一些因为长时间运行的流式查询会占用连接如果任务并发其他请求可能拿不到连接。我们线上 HikariCP 的最大连接数是 20导出任务最多占 2 个剩余的 18 个留给正常业务基本不会互相影响。5.2 千万级数据下的 GC 表现与堆内存监控我做过一次完整的监控记录导出 1100 万行数据堆内存设置为-Xms2g -Xmx2g全程 GC 表现非常平稳Young GC 平均每几秒一次每次停顿不到 50msFull GC 在整个导出过程中只发生过一次还是系统初始化时产生的。对比几种写法的内存占用峰值方案峰值堆内存导出时间1100万行查全量List 拼接字符串 一次性写文件OOM无法完成分页查询 逐批写约 200MB约 3 分钟游标查询 流式写约 80MB约 2 分 20 秒“分页查询 逐批写”也是一种可行的方案但会有深分页效率问题。比如LIMIT 10000000, 1000这种写法MySQL 还是要扫描前面 1000 万行才能拿到目标数据越到后面越慢。如果数据量大且不要求强一致快照我会优先选择游标查询而不是分页。5.3 编码、换行、Excel 兼容性坑CSV 看起来简单实际上有很多细节。我导出过一次中文乱码问题原因是 Excel 默认用 GBK 打开 CSV 文件。如果你的用户主要用 Excel 打开建议文件头加 UTF-8 BOM也就是在文件开头写入\uFEFF。像这样writer.write(\uFEFF);加了 BOM 之后Excel 打开 UTF-8 编码的 CSV 就不会乱码但 Mac 上的 Numbers 可能把 BOM 当成一个字符显示。这个要看你用户的工具偏好我一般是默认加上因为国内用 Excel 的人多。换行符也要注意。CSV 标准允许\r\n但很多导出工具用\n在 Windows 上打开会变成一行。Apache Commons CSV 默认写的是\r\n如果你手动用BufferedWriter记得newLine()方法会根据系统不同写出不同的换行符。为了保证跨平台一致我建议显式设置CSVFormat.DEFAULT.withRecordSeparator(\r\n)。还有字段里的特殊字符问题。如果某个字段包含逗号、双引号或换行符CSV 格式要求用双引号包起来字段内部的双引号用两个双引号转义。使用CSVPrinter会自动处理这些手写拼接就很容易踩坑。我见过线上导出的 CSV 因为备注字段包含分号和换行符导致财务系统解析错行排查了半天才发现是转义问题。6. 如果还想更快分片并行与文件切割串行流式导出已经能解决内存问题但有时候用户就是觉得慢希望十分钟能导完。这时候可以考虑分片并行。6.1 并行度与数据库连接数权衡思路是根据主键 ID 把全量数据切成多段例如按 ID 范围分成 4 段每段开一个线程分别导出最后合并。但这里要非常小心每个游标查询都会占用一条数据库连接如果分片数量等于 10就会同时占用 10 条连接连接池再设 20剩余连接就不够了。建议最多并行 2~4 个任务同时把连接池最大连接数调到 40~50。分片查询也不是简单的WHERE id BETWEEN ? AND ?因为表可能不是严格连续的主键。更稳的分片方式是用主键的MIN(id)和MAX(id)计算等宽区间再对每个区间用游标查询。若数据分布不均有的区间数据量很大导出速度会被最慢的分片拖累。收益大但复杂度也高。6.2 文件切割与合并策略并行导出时每个线程写一个临时文件全部完成后按顺序合并。合并时要注意多个文件的表头只保留一份。用Files.copy把临时文件内容按顺序写到同一个目标文件速度很快。合并完成后再删除临时文件。如果文件数量多建议在文件名中加序号作为排序依据防止合并顺序错乱。也可以不合并直接分多个 zip 包下载。我做过一个导出功能按月份切割成 12 个小 CSV再打包成一个 zip用户解压后看到的是按月份命名的多个文件。这种体验往往比一个 3GB 的 CSV 更好因为 Excel 根本打不开超过 1048576 行的单文件用户可以按月份分拆导入。6.3 有些“优化”其实没必要做还有两种常见的优化思路我个人试过之后觉得收益有限引入中间存储比如先把数据导出到临时表再导出临时表。这样多一次数据库写入磁盘开销翻倍除非复杂查询确实需要否则不建议。用多线程写同一个文件。同一个文件只能由一个线程顺序写多线程写文件要加锁等于没并行。不如直接写多个文件再合并。真正收益最大的优化是减少数据库侧不必要的数据量。比如不需要的字段不要 select能过滤的数据尽量 filter 到最少这比任何并发优化都实在。7. 最后再分享两个实际经验第一个经验是导出任务一定要有超时控制和状态监控。千万级数据导出不是秒级操作网络抖动、数据库连接断开、磁盘空间不足都可能让任务卡住。我吃过亏导出任务跑了一半服务器磁盘满了任务线程一直卡在写文件上既不报错也不退出用户点了一次又一次积压了十几个导出任务。后来我加了任务表超时机制超过 30 分钟的任务自动标记失败并释放资源才彻底解决。第二个经验是做好导出文件的生命周期管理。CSV 文件落到本地磁盘后占用的空间相当可观。我负责的一个系统每天生成上百个导出文件一个月下来磁盘占用多出几百 GB。建议导出完成后定期清理超过一定天数的文件或者在文件下载之后立刻标记可删除。库存目录从“按天保留”改成“按小时保留”磁盘压力小了很多。最后说一句个人的体会CSV 导出这个老功能看起来没什么技术含量一旦数据量上来各种环境差异、数据库限制、Excel 兼容问题都会冒出来。但核心思路始终是那一条——别让数据囤在内存里让它从源头流到终点。把这套流式方案做好千万级导出也就是一个普通的日常需求而已。本文还有配套的精品资源点击获取