资讯动态

搞定千万级CSV导出:Java流式方案,告别内存溢出

发布时间:2026/10/16 6:45:36 来源:尧图企业网站定制
简介针对Java后端处理千万级CSV导出的常见痛点这份资源提供了一套完整的多线程分批导出实现方案适合有Java基础、正在应对大数据量报表导出或日志导出场景的开发者参考。资源压缩包共10个文件以9个Java源码为主覆盖数据分批拆分、线程池管理、CSV逐行读写、压缩与下载等核心模块另附1个txt说明文档梳理批量导出的整体设计思路与实现细节。包体仅10KB代码精简、无冗余依赖便于阅读和迁移改造已有15562人学习下载。方案采用第三方CSV库或Java 8 Stream API逐行处理文件通过ExecutorService构建线程池并行处理数据块避免一次性加载全部数据从根本上规避内存溢出问题同时引入MappedByteBuffer内存映射文件降低内存占用并加入异常捕获与失败重试的补偿机制兼顾导出效率与稳定性。代码结构清晰关键步骤包含注释学习后可以掌握大数据量导出的分片策略、线程池参数配置、内存优化手段及异常处理思路对生产环境的性能调优很有帮助适合作为模块改造与方案设计的直接参考。1. 千万级CSV导出不动内存的流式方案先解决“怎么不爆内存”接到某公司月度报表导出的活儿时我第一反应是把数据查出来塞进 List再拼字符串写文件。结果一跑千万行数据直接把堆内存干到溢出GC 日志刷屏服务直接假死。后来把整套方案重构成流式导出核心就一句话数据不驻留内存读一条写一条写满一个分片就收掉。这份资源里给的正是这个思路的完整落地代码解决的就是 java csv大数据量导出 时最头疼的内存溢出问题。适用场景很明确业务表几千万行、导出文件需要给运营或财务打开、服务器堆内存只有 2G 到 4G 的普通配置。适合正在被 OOM 折磨的后端开发者也适合想把导出模块做稳的架构师。技术栈不复杂JDBC 游标查询、BufferedWriter 流式写文件、ZIP 分卷压缩三件事串起来就够了。2. 读端JDBC 游标查询不要一次 List 装下全表2.1 为什么 ListEntity 在千万级下必炸大多数业务代码里导出功能都是这么写的先SELECT * FROM 大表然后 MyBatis 或 JPA 把结果集全部映射成实体对象装进ListEntity再遍历拼 CSV。这条路在十万行以内是通的百万行勉强能跑但千万行一定会出问题。一张宽表 30 个字段每行对象加上 List 结构开销内存占用轻松超过 2G。更隐蔽的是JDBC 驱动默认会把查询结果一次性全部拉回客户端内存也就是说SELECT执行完服务端结果集已经完整躺在堆里了后续你做什么都救不回来。这个资源里的读端方案根源上避开了“全量结果集”这个概念用游标方式逐行拉取每从数据库拿一条处理一条丢掉一条。JVM 堆里同一时刻只存在一行数据内存占用恒定在几十 MB 级别跟表的总行数彻底脱钩。2.2 fetchSize 与游标读取的配置细节游标读取不是简单地设置一个参数不同数据库驱动行为差别很大。以最常见的 MySQL 连接串为例关键配置长这样String jdbcUrl jdbc:mysql://127.0.0.1:3306/report_db ?useCursorFetchtrue defaultFetchSize1000 useUnicodetruecharacterEncodingutf8; try (Connection conn DriverManager.getConnection(jdbcUrl, user, password); PreparedStatement ps conn.prepareStatement( SELECT id, user_name, amount, created_at FROM biz_order WHERE create_date ?, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { ps.setString(1, 2025-01-01); ps.setFetchSize(1000); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { // 这里拿到当前行立刻写入 CSV不要累积 } } }这段代码里有三个决定成败的点。useCursorFetchtrue是 MySQL 驱动开启服务端游标的总开关不开它setFetchSize基本是摆设。TYPE_FORWARD_ONLY表示结果集只前进不后退这是游标模式的前提MyBatis 里如果配了可以滚动的 ResultSet游标直接失效。setFetchSize(1000)表示每次从服务端取 1000 条到客户端这个值不是越大越好建议在 500 到 2000 之间太大会让单次网络传输阻塞过久太小则请求次数暴增。2.3 游标模式下能做什么不能做什么游标读取有几个隐含约束新手最容易在这上面翻车。第一整个查询过程中数据库连接不能被占用去做别的事游标是绑定在连接上的你用连接池时一旦连接被借走游标就断了。所以在导出任务里我一般直接DriverManager.getConnection单独开一条连接不走连接池导出完再关。第二游标模式下事务隔离级别要留意。默认的 REPEATABLE_READ 在 MySQL 下会长时间持有读视图如果导出的是千万级大表建议把事务隔离降到 READ_COMMITTED否则 undo log 膨胀会把数据库实例拖垮。连接串上加一句transactionIsolationREAD_COMMITTED就行。第三游标读取过程中如果外部有并发写正在改这张表你读到的是快照数据一致性没问题但导出时间过长会导致主从复制延迟报警。千万级数据的导出耗时基本在分钟级到小时级尽量挑业务低峰期跑或者直接走只读从库。3. 写端BufferedWriter ZIP 输出逐行刷盘不驻留3.1 内存写 vs 流式写的本质差别读端解决了“数据不堆内存”写端同样不能把 CSV 内容先拼成一个巨大的字符串再一把写出去。很多 OOM 其实不是查数据撑爆的而是StringBuilder.append()叠了一万行之后字符串数组频繁扩容旧数组刚被回收新数组又分配Full GC 连续触发CPU 飙高服务卡死。流式写的核心是 BufferedWriter 内部缓冲区默认 8KB写满自动刷到磁盘。配合flush()手动控制可以做到最多几千行落一次盘。这样内存里永远只有一小段缓冲区内容文件多大都不影响 JVM 堆。这个资源里给的写入核心代码我拆出来大概是这个骨架String filePath /data/export/order_202501.csv; try (BufferedWriter writer new BufferedWriter( new OutputStreamWriter(new FileOutputStream(filePath), StandardCharsets.UTF_8), 8192)) { // 先写列头 writer.write(id,user_name,amount,created_at); writer.newLine(); int batchRows 0; // rowData 是当前一行的字段数组从 ResultSet 取完立刻拼 while ((rowData rs.nextRow()) ! null) { writer.write(joinCsvRow(rowData)); writer.newLine(); batchRows; // 每 2000 行刷一次盘防止宕机丢太多数据 if (batchRows 2000) { writer.flush(); batchRows 0; } } writer.flush(); }joinCsvRow负责把一行字段拼成逗号分隔的字符串注意字段里有逗号、引号、换行符时必须用双引号包裹这是个常见的标准坑。8192是 BufferedWriter 缓冲区大小写 2000 行刷一次盘是磁盘 IO 和内存占用之间比较平衡的取值。flush()不是越频繁越好每条都 flush 会变成逐行写盘性能直接掉一个数量级。3.2 千万行单文件还是分卷取决于打开方千万行 CSV 就算生成成功直接给业务方用 Excel 打开也是灾难。Excel 单表上限 1048576 行超过就打不开。所以写端方案必须支持分卷每个 CSV 文件写到 100 万行就收掉开下一个文件继续写最后把所有分卷打包成一个 ZIP。这个资源里的实现就是这么干的配套的拆分逻辑类似这样int maxRowsPerFile 1_000_000; int fileIndex 1; long totalRows 0; BufferedWriter currentWriter createNewFile(fileIndex); while (rs.next()) { currentWriter.write(joinCsvRow(currentRow)); currentWriter.newLine(); totalRows; rowsInCurrentFile; if (rowsInCurrentFile maxRowsPerFile) { currentWriter.flush(); currentWriter.close(); fileIndex; currentWriter createNewFile(fileIndex); writeHeader(currentWriter); // 每个分卷都要有列头 rowsInCurrentFile 0; } }分卷大小这个参数要按业务实际调。运营要拿去 Excel 透视100 万行一包最稳如果是给程序做后续处理单个文件 500 万行也扛得住。我一般会把分卷行数放到配置中心线上出问题不用改代码重新发布。3.3 ZIP 压缩与流式收口分卷文件散落一地不是交付形态最终要压成一个 ZIP。这里有个很多人踩过的逻辑坑ZIP 不是把所有文件读进内存再压缩而是要用 ZipOutputStream 包住文件写入流边写边压。ZipEntry必须写在文件内容之前closeEntry()必须在一个文件写完、下一个文件开始之前调用顺序错了 ZIP 就是坏的。try (ZipOutputStream zos new ZipOutputStream(new FileOutputStream(/data/export/order_202501.zip))) { for (Path partFile : partFiles) { zos.putNextEntry(new ZipEntry(partFile.getFileName().toString())); Files.copy(partFile, zos); zos.closeEntry(); } }Files.copy(partFile, zos)内部也是流式复制源文件按块读入不会整个加载到堆里。压缩级别用 ZipOutputStream 默认值即可没必要上DEFLATED最高压缩比那个 CPU 开销在千万级数据量下会拖很久。真要压得狠可以考虑 gzip 单文件输出但业务方一般不会接受 .gz 格式。4. 千万级导出的排序、深分页与分段拉取4.1 深分页的坑offset 越大越慢部分团队做导出时不用游标而是用分页查询每页查 1 万条页码往后翻。这在百万级内勉强能用到了千万级就会暴露一个致命问题LIMIT 8000000, 10000这种查询数据库要先把前 800 万行扫出来丢掉再取后面的 1 万行。越往后翻单页查询越慢整个导出过程呈二次曲线增长。我接手过一个导出任务前 100 万行跑了 40 秒到 500 万行时一页要 20 秒到剩下最后 100 万行时每页要 1 分多钟整个任务跑了快两个小时连接超时直接中断。后来把分页改成 keyset 分页按主键定位游标位置每一页都是从指定 ID 往后取走主键索引单页耗时恒定在几十毫秒。long lastId 0L; int pageSize 10000; while (true) { String sql SELECT id, user_name, amount, created_at FROM biz_order WHERE create_date ? AND id ? ORDER BY id ASC LIMIT ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, 2025-01-01); ps.setLong(2, lastId); ps.setInt(3, pageSize); try (ResultSet rs ps.executeQuery()) { int rowCount 0; while (rs.next()) { writeCsvRow(rs); lastId rs.getLong(id); rowCount; } if (rowCount pageSize) break; // 最后一页 } } }WHERE id ?利用主键索引直接跳到上次结束的位置ORDER BY id ASC保证游标顺序单调递增LIMIT ?圈定每页大小。这个方案唯一的要求就是表有连续或近似连续的主键没有主键的表得先建一个带索引的排序列。keyset 分页比游标更适合跨系统调用因为每次查询都是独立的短事务不用长时间占着连接。4.2 需要排序怎么办文件内排序 归并业务上常常要求导出的数据按金额降序或按时间排序。游标读取天然是按主键顺序扫的你要是为了排序加一个ORDER BY amount DESC那数据库得先做一次全表排序产生临时文件耗时翻倍。更稳的做法是分两次处理第一次按普通游标把数据全部导出到临时 CSV 分片文件第二次用外部排序的思路每个分片文件内部按目标字段排序再做多路归并。这个资源里的工具包提供了一个简单的分片归并实现核心逻辑是每个分片文件的数据量控制在内存能容纳的范围内读进 List 排序后写回然后用PriorityQueue做多路归并输出到最终文件。// 多路归并每个分片文件一个 BufferedReader按排序字段取最小 PriorityQueueRowWithReader queue new PriorityQueue( (r1, r2) - r1.sortValue.compareTo(r2.sortValue) ); for (BufferedReader reader : readers) { String line reader.readLine(); if (line ! null) { queue.offer(new RowWithReader(line, reader)); } } try (BufferedWriter writer createFinalWriter()) { while (!queue.isEmpty()) { RowWithReader row queue.poll(); writer.write(row.line); writer.newLine(); String nextLine row.reader.readLine(); if (nextLine ! null) { queue.offer(new RowWithReader(nextLine, row.reader)); } } }PriorityQueue每次弹出一个当前最小行然后从对应的分片文件再读下一行进堆。整个归并过程内存里最多同时存在“分片数量”行数据跟总量无关。排序字段如果是数字注意在compareTo之前转成数字类型否则字符串排序会按字典序出现 100 排在 20 前面的情况。4.3 多线程分段导出按主键段并行合并 ZIP单线程导千万行实测速度大概在每分钟 80 到 120 万行取决于字段宽度和磁盘速度全量跑完要 10 分钟左右。某些时效性要求高的场景等不了这么长那就得上多线程分段。分段思路很简单先查一次MIN(id)和MAX(id)把主键区间切成 N 段比如按每段 50 万行估算每个线程负责一段各自游标导出到独立分片文件最后主线程把所有分片压成一个 ZIP。这个资源里给的线程池配置我直接拿来用参数是这样调出来的int segmentCount 8; // 跟 CPU 核数对齐 int minId queryMinId(conn); int maxId queryMaxId(conn); int step (maxId - minId) / segmentCount; ExecutorService pool Executors.newFixedThreadPool(segmentCount); ListFuturePath futures new ArrayList(); for (int i 0; i segmentCount; i) { int segStart minId i * step; int segEnd (i segmentCount - 1) ? maxId : segStart step; futures.add(pool.submit(() - exportSegment(segStart, segEnd))); } for (FuturePath f : futures) { partFiles.add(f.get()); }线程数不是越多越好。每个线程占用一个数据库连接连接池最大连接数往往只有 20 到 50分段过多会把连接池打满影响线上其他业务。8 段是常规配置实测百万行数据导出从单线程 12 分钟左右压到 2 分半以内。磁盘 IO 是另一个瓶颈机械盘遇到多个线程并发写不同文件磁头来回跳速度反而不如单线程SSD 上并行收益明显。另外分段导出后每个分片都要有列头合并 ZIP 时文件顺序要按分段顺序命名比如part_0001.csv到part_0008.csv否则业务方解压出来顺序是乱的。5. 避坑/常见问题排查从 OOM 到半个文件的五大翻车现场5.1 现象堆内存没涨多少GC 却把 CPU 吃满导出任务跑起来之后JVM 堆一直维持在 500MB 左右但 CPU 持续 100%GC 日志里 Full GC 间隔越来越短。原因是 CSV 写入阶段每行都做字符串拼接StringBuilder扩容时旧字符数组要复制复制产生的临时对象在新生代堆积触发频繁 Minor GC升代之后又触发 Full GC。解决手段是把行拼接从改成固定容量的StringBuilder并且控制flush()频率减少单次 IO 的上下文切换。实际改完GC 频率降了一个数量级。5.2 现象设置 fetchSize 后没有任何效果代码里写了ps.setFetchSize(500)但查看数据库监控发现还是全量结果集一次拉回。原因是 MySQL 驱动在未开启useCursorFetchtrue时setFetchSize会被忽略。另一个隐蔽场景是 MyBatis 的ResultHandler搭配fetchSize如果 Mapper 接口返回类型是List游标配置也会失效必须把查询方法改成void返回并用ResultHandler逐条消费。排查步骤先看连接串有没有useCursorFetchtrue再看 Statement 是不是FORWARD_ONLY最后确认没走 MyBatis 的 List 收集逻辑。5.3 现象导出到一半连接断开任务中断游标模式下一次查询长时间占用连接数据库端的wait_timeout或者中间层的空闲连接回收机制会把连接干掉。表现就是跑了 5 分钟后抛Communications link failure或者Connection is closed。解决连接串上加socketTimeout600000单位是毫秒设成 10 分钟同时确保整个导出过程没有任何空闲时间每读一行就写一行游标在持续拉取数据时连接不会被视为空闲。跨网络环境还要排查防火墙对长连接的空闲策略有些场景需要在 TCP 层加 keepalive。5.4 现象CSV 里长数字变成科学计数法导出订单号、身份证这类超过 15 位的数字时Excel 打开 CSV 会显示成1.23457E14精度还丢了。原因是 CSV 没有列类型信息Excel 自动把纯数字列按数值处理。解决有两个手段二选一字段前后加制表符在字段值前拼\tExcel 会当成文本或者字段拼接时用双引号包一层比如12345678901234567Excel 也会按文本识别。\t方案对数据内容无影响双引号方案要注意内容本身有转义。代码里统一用sanitizeCell()处理每个字段遇到长数字强制拼\t这个函数在资源包里已经封装好了。5.5 现象打开 CSV 文件中文全部乱码文件用 UTF-8 写的Windows 上 Excel 双击打开却显示乱码。原因是 Excel 默认按 GBK 解析无 BOM 的 CSVUTF-8 内容少了 BOM 头就是乱码。解决写文件时在第一个字节位置写入\uFEFF。OutputStreamWriter场景下先writer.write(\uFEFF)再写列头。注意这个 BOM 只出现在第一个文件里分卷文件的后续分卷不需要加。加了 BOM 之后用 Excel 打开乱码问题直接消除但用BufferedReader读回这个文件时第一行第一个字符会带着 BOM程序里要removeBom()处理一下。6. 收尾BOM、列头校验与千行自查导出功亏一篑的最后一公里资源包里的代码功能完整但真正上线前我建议把下面三个校验动作加进去它们救过我两次线上事故。第一个是导出完成后立刻消费一遍 ZIP 里的每个分卷校验分卷数量、每个文件的列头是否一致、行数是否和数据库统计的行数对得上。不要相信导出过程中打的日志日志说写入了 1024 万行不代表文件完整只有解压出来逐个数才算数。第二个是抽样验证解压出第一个分卷用流式读取前 1000 行和后 1000 行确认没有半行数据、没有字段错位。第三个是交付形态检查业务方用的 Excel 版本不同分卷行数上限要按最低版本设老版本 Excel 只能承受 6 万行左右这个信息要在需求阶段问清楚不要默认对方用的是新版本。加 BOM 的代码长这样放在创建每个分卷文件之后public static void writeBom(BufferedWriter writer) throws IOException { writer.write(\uFEFF); }列头校验我用一个简单方法把每一分卷的第一行读出来和标准列头比对不一致直接让任务失败并提示分卷序号不要静默修复。标准列头: id,user_name,amount,created_at 分卷第3个文件列头: id,user_name,amount,create_date → 校验失败那次的教训是这样的某次上线前我把flush()改到每 5000 行一次本地测了小文件没问题线上跑了一千多万行最后 ZIP 文件大小看起来也正常结果业务方反馈第 7 个分卷最后缺了 300 行。查下来是磁盘写满导致 close 失败但异常被吞了ZipOutputStream 流关闭时报错文件已经损坏。从那以后我每次导出任务结束都强制走一遍分卷行数校验和大小检查用解压测试代替肉眼判断。希望帮到你这一套跑通了千万级导出就不再是玄学。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑