千万级CSV导出不再怕OOM:流式查询与分片合并实战 简介面向需要处理大规模数据导出的 Java 工程师围绕千万级 CSV 导出场景提供一套避免内存溢出的完整实现方案重点解决传统一次性加载导致的内存瓶颈。包内共 10 个文件涵盖 9 个 Java 源码文件和 1 个说明文档压缩包仅 10KB代码量虽小但结构清晰。实现类包括 CsvExportThread、CsvExportBatch、ExecutorThread、ThreadPools覆盖分批读取、ExecutorService 线程池并行处理、BufferedWriter 流式写入 CSV可根据数据量灵活调整分块大小与线程数CSVUtils、ZipUtil、DownLoad 等辅助工具则补齐了文件解析、压缩与下载环节。说明文档还对多线程任务拆分、异常捕获与重试、日志监控与性能调优思路做了梳理方便直接应用到生产环境。资源当前已有 15562 人学习适合有一定 Java 基础、希望解决高并发大数据导出与内存占用优化问题的开发者参考可将其中的分块导出框架迁移到真实业务场景。1. 千万行CSV导出为什么先灌进内存几乎是必死路径用 Java 导出一个 1000 万行的 CSV落盘后通常只有 2~4GB可一旦先把它读成 Java 对象再整体写出堆内存占用会是文件的 4~10 倍OOM 几乎是必然。这不是 Java 不擅长大数据量导出而是把“文件文本”建模成了“内存对象”一行几百字节的文本在堆里要变成几十个小对象。真正能让你活下来的不是调大 -Xmx而是放弃“先读完全量再写文件”的流程改走流式查询、常驻缓冲、分片合并这套路径。理解这条流水线的底层取舍才会明白为什么千万级大数据量导出不用怕内存溢出。2. 堆内存的两个“隐形膨胀点”不是数据太多而是模型太重CSV 在磁盘上是连续的字节进入 JVM 后却是一堆带对象头、引用和数组的对象。同样是千万行数据从“文件里的一行文本”变成“内存里的一行列表元素”体积能翻好几倍。这一章先把两个最容易把内存撑爆的膨胀点拆开。2.1 装进 List 的行不是“一行文本”是几十个小对象以 1000 万行、每行 10 列、平均每列 16 个字符的数据为例。原始文本按 UTF-8 计算大约在 2GB 上下但如果把它读成一个ListString每个字符串在 HotSpot JVM 里至少包括8 字节的引用、String 对象自身头部以及一个底层的char[]char[]又带着自己的对象头和对齐填充。一个 16 字符的 String 在堆里通常要占到 48~56 字节而它在 CSV 文件里只贡献 16 字节的 UTF-8 内容。也就是说光“字符串对象化”这一层成本就至少放大 3 倍。形态1000 万行、每行 10 列、平均列长 16 字符的近似成本合计CSV 文本一行约 160~220 字节约 1.6~2.2GBListString每列一个 String每行约 600~900 字节约 6~9GBArrayList 扩容瞬间引用数组翻倍时旧数组和新数组同时存活峰值再上浮ArrayList 扩容本身是另一个帮凶ensureCapacity通常按 1.5 倍左右增长把千万个引用从旧数组复制到新数组复制期间旧数组还没释放。这种结构性开销在千万行级别会被放大得很快。业务里“平均列长 16 字符”往往还是乐观估计ID、描述、JSON 串动辄几十到几百字节字段越宽内存放大越夸张。这也是为什么“2GB 文件4GB 堆总能装下”这种直觉在千万级导出面前完全不成立。2.2 StringBuilder 拼接的放大效应字符串不是“最后才出现”的另一个常见做法是在循环里用StringBuilder拼一行再把toString()放进 List最后统一写文件。代码如下ListString rows new ArrayList(expectedRows); while (rs.next()) { StringBuilder line new StringBuilder(); for (int c 1; c columns; c) { line.append(rs.getString(c)).append(,); } line.deleteCharAt(line.length() - 1); rows.add(line.toString()); // toString 又复制了一次 char[] } writeAll(rows);问题不止“最后才写文件”这一层。StringBuilder内部维护一个char[]追加过程中容量不够时会翻倍扩容新数组复制旧内容旧数组在 GC 回收前暂时还活着每个循环新StringBuilder都会走一遍这个过程。更致命的是toString()会复制一份新的char[]而这份char[]被 List 持有整个导出期间都不会被回收。也就是说堆里除了最终所有行的内容还叠加了每一轮拼接时的临时数组、复制旧数组产生的垃圾以及 GC 老年代里不断晋升的字符串对象。所以“先拼好再导出”在千万行级别不只是慢是结构性撑爆内存。你可以把初始容量从默认值调大减少扩容次数但那只能缓解临时复制真正长期活着的还是那 1000 万个字符串和它们的char[]。这就是最典型的隐形膨胀点每一步都只放大一点点乘上千万之后就成了几个 GB。2.3 正确粒度的选择让“一行”在内存里只活一个循环正确方向是把“拼一行”和“写一行”合并成同一个动作并且复用同一块缓冲区。下面是一个可运行的骨架public final class CsvLineWriter { private final BufferedWriter out; private final char[] buffer new char[64 * 1024]; public void writeRow(String[] fields) throws IOException { int pos 0; for (int i 0; i fields.length; i) { if (pos 0) buffer[pos] ,; if (fields[i] null) continue; // 转义逻辑在这里判断特殊字符必要时写入双引号 for (int j 0; j fields[i].length(); j) { if (pos buffer.length) { out.write(buffer, 0, pos); pos 0; } buffer[pos] fields[i].charAt(j); } } buffer[pos] \n; out.write(buffer, 0, pos); } }这个类的关键点有两处一是buffer只分配一次后续每行写入的字符都覆盖旧内容没有新增长生命周期对象二是writeRow返回后即使调用方手里还拿着fields数组Writer 也没有持有它的引用。整条导出链路里JVM 中长期存活的大对象只有这 64KB 字符缓冲和外面那层 BufferedWriter 的字节缓冲内存占用是 O(1)不是 O(rows)。代码里还可以把字段转义、空值处理都塞进同一个循环避免为每行生成额外字符串。3. 查询侧解法用JDBC游标“推开”数据让堆内存变成常量写端做对了查询端如果一次性把 1000 万行全部取回内存照样崩。查询侧的核心问题是不要让 JDBC 驱动把整个结果集实体化到客户端堆里而是让它像水管一样一点一点把数据“推”过来。3.1 先破旧思路LIMIT/OFFSET 分页为什么更糟很多开发者会想“我每次LIMIT 10000分 1000 次查内存不就安全了”这个思路在小结果集上没问题到千万行时反而是最贵的做法。数据库拿到包含 offset 的查询必须先扫描并排序到 offset 之前的所有行再丢掉这些行返回目标页第 1000 页的 offset 接近 999 万意味着数据库实际处理了近千万行只为返回其中 1 万行。整体导出成本接近 O(N²)数据库服务端的临时排序、临时表也会占用大量磁盘和 CPU。分页还有一个一致性陷阱数据在导出过程中持续变化连续两页之间可能因为新插入或删除的行造成同一行被导出两次或某一行永远漏掉。带ORDER BY的稳定排序只能保证页内顺序不能解决数据集合本身变化的问题。分页是给交互式浏览设计的不是给整表导出设计的。真正适合大数据量导出的是流式游标。3.2 流式游标与 fetchSize 的组合千万行导出需要什么条件标准做法是一个专用连接关闭自动提交设为只读给 PreparedStatement 设置fetchSize然后在一个事务里顺序消费完整的 ResultSet。try (Connection conn exportDataSource.getConnection(); PreparedStatement ps conn.prepareStatement(exportSql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { conn.setAutoCommit(false); // 多数驱动在非自动提交时才启用流式 conn.setReadOnly(true); // 暗示数据库不产生写事务 ps.setFetchSize(2000); // 本地最多缓存 2000 行不是“分页大小” ps.setFetchDirection(ResultSet.FETCH_FORWARD); String[] fields new String[columnCount]; try (ResultSet rs ps.executeQuery(); CsvLineWriter writer new CsvLineWriter(Paths.get(export.csv))) { long count 0; while (rs.next()) { for (int c 1; c columnCount; c) { fields[c - 1] rs.getString(c); } writer.writeRow(fields); count; } // 整个 ResultSet 消费完后提交保证服务的游标正常关闭 conn.commit(); } }流式的基本单位是“批”不是“行”。驱动每次从服务端取回一批行比如 2000 行应用消费完这批后再取下一批堆里的活跃结果行数最多只有这批。setFetchSize只是给驱动的“建议批量”并不是硬性开关真正生效往往依赖三个前提结果集类型是TYPE_FORWARD_ONLY、并发模式是CONCUR_READ_ONLY、连接处于非自动提交状态。三者缺一个有的驱动就会老老实实把整个结果集取回来。fetchSize要根据网络和单行宽度调太小比如 100会让网络往返次数暴涨导出耗时明显增加太大比如 50000本地活跃行太多堆占用升高。内网环境 1000~5000 是常见区间如果单行超过 1KB建议降到 1000 以下。注意很多连接池会对连接做代理包装导致setFetchSize没有真正传给底层驱动。流式导出务必使用专用连接或者先验证连接池对“流式模式”的透传能力。3.3 事务与快照导出期间并发写入如何保证数据一致流式读取要求整个 ResultSet 消费期间事务一直活着这就带来一个问题导出耗时可能长达几十分钟期间业务还在写同一张表。如果不做任何控制导出结果可能是“每一行读到的时间点都不一样”行与行之间对不上。通常有三种处理思路。第一种把导出连接设为只读并选择合适的事务隔离级别让这次查询基于事务开始时的一个快照视角之后业务的新提交不影响已开始的结果集。第二种先把要导出的主键集合固化下来比如执行一句查询把符合条件的 ID 写入临时表再基于临时表分批去取明细这个方案把“确定集合”和“导出数据”拆成了两个阶段集合稳定导出就算中途失败也能从断点续跑。第三种接受一个物理时间窗口内的近似一致明确告诉消费方“这是一段时间内的数据不是精确快照”。这里还有一条感情上的取舍流式游标代码最简洁但对连接状态要求苛刻临时表快照代码更繁琐却能避开长事务持有快照带来的资源堆积。如果业务对一致性要求高我一般倾向临时表方案。无论选哪种都要记住整个导出期间不能把连接拿去跑其他 SQL游标是靠底层事务撑着的中途提交或切换连接都会导致结果集失效。4. 写出侧的最小机制字节缓冲、分片与合并查询端把数据“推开”之后写端也要跟上。这一层要解决三个问题怎么写最省内存、失败怎么恢复、输出文件怎么保证所有人都能正确打开。4.1 从 ResultSet 直接写文件为什么比先拼文件字符串快先收集全部行再一次写入本质是把写操作从“流水的末端”变成了“内存里的二次存储”数据先堆积在堆里再转换到磁盘。正确的做法是让写文件发生在每一行读取之后把磁盘 IO 变成顺序追加。Charset utf8 StandardCharsets.UTF_8; Path outPath Paths.get(export.csv); BufferedWriter writer Files.newBufferedWriter( outPath, utf8, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); writer new BufferedWriter(writer, 1 20);这里显式指定 UTF-8 是必须的不是细节洁癖。如果不指定字符集文件内容取决于运行环境的默认编码换一台机器、换一个 Linux 发行版导出的中文可能就乱码。1MB 的缓冲区让字符到字节的转换能批量进行如果缓冲太小每一行都会触发多次系统调用千万行积累下来的时间差可以到几十分钟。写完成后仍然要经历流式导出常见的“双写入”铺层底层 Buffer 攒到满再输出给文件描述符调用flush()时真正落盘。日志里的“写入成功”不一定代表数据已经物理落盘所以进程退出前一定要close()而不是只调用flush()。4.2 为什么用分片文件导出是顺序合并而不是单次写完单文件直接写完在内存上是可行的但在工程上不抗故障导出进行到 800 万行时进程被杀这 800 万行全部作废重启后只能从头再跑。分片文件的意义是把一次“千万级大事务”拆成多个“小任务”任何一个小任务失败重跑成本都只限于那一个分片。final long chunkRows 500_000; long total 0; int part 0; BufferedWriter current openPart(part, true); // 每个part都写表头 while (rs.next()) { writeRow(rs, current); total; if (total % chunkRows 0) { current.close(); part; current openPart(part, true); // 每个part都可独立查看 } } current.close(); mergeParts(allParts, finalCsv);chunkRows越大part 文件越少合并开销越小但单个 part 的重试代价越高500000 到 1000000 是个常见区间。每个 part 都写表头的取舍是任何一个 part 单独拿出来都能用文本工具验证坏掉一个 part 不影响其他 part 的有效性代价是合并时要去掉多余的表头。合并本身是纯顺序复制按 part 顺序把每个 part 的第 2 行开始追加到主文件。这和外部排序里的“归并”是同一套思想只不过不需要比较关键字因为数据已经按业务顺序排好了。4.3 RFC4180转义、BOM与行分隔符导出质量的三个坑CSV 没有严格的国际标准但绝大多数工具默认遵循 RFC4180字段内出现逗号、双引号、换行时整个字段要用双引号包起来字段内的双引号用两个双引号转义。这个规则看着简单不做的人非常多。static String escapeCsv(String field) { if (field null) return ; if (field.indexOf(,) 0 || field.indexOf() 0 || field.indexOf(\n) 0 || field.indexOf(\r) 0) { return field.replace(\, \\) ; } return field; }这里有个性能细节没有特殊字符的字段直接原样返回不产生新的 String 对象只有触发转义的字段才新建字符串。千万行导出中大多数普通字段不会含逗号和引号这个“先检查再拼接”的顺序能把分配量降一个量级。换行符的处理要看下游环境交付给常见表格软件时用\r\n最稳交付给 Linux 脚本处理时\r可能被误认为是字段内容通常用\n更干净。BOM 的选择也是同理带 BOM 的 UTF-8 对表格软件友好打开不乱码不带 BOM 对命令行管道更友好。导出之前先确认“消费方是谁”比事后补救重要得多。5. 千万级导出避坑实录五个高频翻车现场再稳的方案也会在一些看似正常的场景里突然翻车。这里整理了五个最容易踩的坑每条都按“现象 → 原因 → 解决”给出。5.1 现象开发库正常生产库一跑就OOM同一套代码测试环境 100 万行跑得很稳生产环境 1000 万行启动不到一分钟直接OutOfMemoryError: Java heap space。 原因测试环境结果集小驱动按默认方式一次性取回来也扛得住生产环境结果集大驱动把整个 ResultSet 实体化到客户端堆里。另一个常见因素是测试环境用的连接池代理比较“透明”生产环境的代理层把setFetchSize吞掉了流式模式实际上没生效。 解决导出必须使用专用连接不要随手从连接池拿确认三个条件同时满足查询类型是TYPE_FORWARD_ONLY、并发模式是CONCUR_READ_ONLY、连接是非自动提交。启动后用 JDK 自带监控工具观察堆曲线堆从 100MB 一路爬到几个 GB 就说明流式没生效堆稳定在几十 MB 才是真的安全。不要等技术栈里所有日志都正常、到 900 万行才崩那时候浪费的几个小时已经救不回来了。5.2 现象内存没炸磁盘炸了导出任务跑了近一个小时最后报“磁盘空间不足”临时目录被 part 文件塞满最终文件只写了一半。 原因只给最终 CSV 预留了空间没算 part 文件、日志、数据库临时排序区也占同一块分区。导出过程是一条“边写边看”的流水线磁盘消耗是波动的很多人用“最终文件大概 3GB”去评估忽略了中间分片同时存在。 解决导出前按“预估行数 × 平均行字节 × 1.2”计算最终文件体积再留出 1~2 倍的中间分片空间用接口检查目标分区剩余空间低于阈值直接拒绝任务。part 目录和最终文件放同一个分区避免合并时要跨盘复制。合并成功后立即清理 part 目录并把这个清理动作写成固定的结束步骤不要依赖“我记得等一下删一下”。磁盘问题比 OOM 更隐蔽因为它通常出现在任务快结束的时候前面所有工作量都白费。5.3 现象打开CSV乱码、列错位、表格软件报格式不匹配文件生成成功但交付给下游后中文全部变成乱码某个字段里有逗号打开后被切成两列还有人说“文件格式与扩展名不匹配”。 原因输出时没有显式指定字符集运行环境默认编码一变中文就乱字段里的逗号、引号、换行没有按 RFC4180 转义CSV 的结构被破坏了。 解决所有输出都显式用 UTF-8 写入每个非空字段先经过转义函数只对需要转义的字段加双引号如果主要交付对象是业务同事的表格软件就在文件开头写入 BOM。导完以后再做自检按相同转义规则解析一遍文件断言每行列数与表头一致再对比总行数和数据库计数。自检花五分钟能避免交付后被连续反馈“格式不对”这类问题最难排查因为程序本身没有任何报错。5.4 现象导出任务与业务事务互相等待、锁超时导出跑到一半在线业务开始报锁等待有时连导出自己都超时回滚两边一起卡住。 原因导出查询虽然是读操作但在某些数据库实现下也会因为长事务积累大量版本信息或查询计划走了需要加锁的读取路径。长事务占用的资源随行数线性上涨和业务写入形成互相等待。 解决导出连接设为只读并选择快照或读已提交的隔离级别如果仍然有锁冲突就把要导出的主键先固化到临时表然后按主键范围分批读取每批用独立的短事务。锁等待超时参数尽量设短一点让冲突快速失败并进入重试而不是两个任务无限互相等。导出的价值在最终文件不在事务持续时间长短。5.5 现象setFetchSize设了内存还是涨代码里明明调了setFetchSize(2000)导出到 500 万行时堆内存依然一路爬升最后接近顶部。 原因驱动是否启用流式有一组前提条件最典型的是结果集类型必须是TYPE_FORWARD_ONLY或者连接不能处于自动提交连接池代理也可能替换 PreparedStatement 对象使setFetchSize完全没有生效。这类问题最大的麻烦在于你很难从代码里看出来它到底有没有生效。 解决显式设置三个条件后先跑一个 500 万行的模拟查询观察堆曲线如果驱动确实不支持流式或者连接池透传不了就改用主键范围分批查询把主键切分成区间段每段独立查询、独立写 part最后合并。这个方案不依赖驱动的特殊行为编码量大一点但可验证、可续跑。多年的经验是流式配置有成百上千种“看着对实际没生效”的可能不要相信代码设置只相信运行时的堆曲线。6. 为“常数内存”买单顺序性、快照与验证6.1 顺序性换内存导出方案里真正的取舍一路拆下来可以发现所有规避 OOM 的手段都是在买同一样东西顺序性。数据只能从头到尾读一遍不能随机跳取不能反复打开一次失败之后没办法精确地从第 800 万行续传只能退到某个 part 文件重新导出。这个取舍是公平的不想让数据在内存里“住下来”就要接受数据在路上“排队经过”。还有一个隐藏代价是快照。如果数据库不支持读取时的一致性快照或者不想长时间占用事务资源就得用“主键集合先落临时表”的额外步骤相当于为导出一致性付了一笔独立的 IO 成本。所以导出方案没有银弹游标式流读写代码最短但对连接要求最苛刻临时表快照最稳但代码更长分片文件最灵活但合并逻辑需要额外维护。选哪种取决于你有多少重试预算、下游对文件一致性要求到什么程度。6.2 验证方法先把千万行的Mock源跑一遍不论选哪种方案验证都应该是第一步而不是等线上崩了再排查。先写一个纯内存的模拟数据源把写文件链路单独压测long expected 10_000_000L; long started System.currentTimeMillis(); try (CsvLineWriter writer new CsvLineWriter(Paths.get(mock.csv))) { for (long i 0; i expected; i) { writer.writeRow(new String[]{String.valueOf(i), value i, 中文}); if (i % 100_000 0) { long heap Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); System.out.printf(rows%d heap%dMB elapsed%ds%n, i, heap / 1024 / 1024, (System.currentTimeMillis() - started) / 1000); } } }跑完后观察从第 10 万行到第 1000 万行的堆曲线如果曲线基本是平直的说明写端没有累积对象如果堆随行数线性上升说明有引用被留住了需要回去检查 Writer 或字段数组是否被意外持有。Mock 源验证通过后再替换成真实的 JDBC ResultSet验证查询端同样是流式的。这样把“写文件”和“读数据”两个故障域分开哪一环出问题一眼就能定位。从那以后我接到任何导出类需求都先做这两步先确认不能“把整张表装进堆”再确认每 10 万行堆曲线平稳。宁可多写几个 part也不在内存里造一座千万行的楼留下一颗后悔药总比在 900 万行时连后悔的机会都没有强。希望帮到你。本文还有配套的精品资源点击获取