
简介面向Java开发人员的CSV大数据量导出示例代码包针对千万级数据导出时传统单线程加载导致的内存溢出问题给出明确优化思路。实现上采用分批读取与多线程并行处理并通过线程池管理任务同时结合逐行写入、CSV工具封装以及压缩下载模块减少内存占用提升导出速度。资源共10个文件以9个Java源文件为主另附1份说明文档包体仅10KB代码紧凑便于阅读和迁移。方案还提到内存映射文件、异常捕获与失败重试等进阶处理说明文档能帮助理解多线程导出整体流程。已有15562人学习下载适合正在优化大规模CSV导出功能、需要参考稳定实现方案的Java工程师。1. 千万级 CSV 导出不内存溢出先破除「数据必须先进内存」的思维定式第一次接到「用 Java 把一千万行数据导成 CSV」的需求大多数人的第一反应是加堆内存或者写一个分页查询慢慢跑。结果往往是内存加到 4GB 照样 OOM分页越查越慢最后导出来的文件几个 GB过程里 JVM 堆占用却飙到 90% 以上。反直觉的点在于千万级导出能不能撑住和「内存有多大」关系不大和你是否让数据全部进了堆关系极大。这套方案的底层支撑是 JDBC 游标读取Cursor加逐行写出Streaming Output再配一层恰到好处的缓冲让 JVM 堆里始终只驻留几百行数据。搞懂这一套协同机制你就不只是会配参数而是能在任何语言里复刻同样的思路读一行、写一行、丢一行。2. 内存为什么会爆三个隐性炸弹藏在 JDBC 与字符串拼接里先弄清楚一件事磁盘上几个 GB 的 CSV 文件本身不吃 JVM 内存真正吃掉堆的是导出过程中的中间数据。我拆过几个导出 OOM 的现场问题几乎都落在三个环节——查询结果全量驻留、字符串拼接产生海量临时对象、写入端缓冲策略失当。任何一个环节失控千万级数据都会变成死局。2.1 隐性炸弹一查询结果全量驻留堆内存最常见的误区是把executeQuery()理解成「拿到一个数据库游标一行一行取」。实际上主流 JDBC 驱动的默认行为是在ResultSet返回给你之前整张结果集已经被网络传输到客户端并以byte[]形式驻留堆内存。你调用rs.next()时只是在遍历这份已经全部到位的本地数组。以最常见的 MySQL 驱动为例它内部对结果集有两种数据形态RowDataStatic和RowDataCursor。默认走RowDataStatic也就是全量装载只有满足特定前置条件时才会走服务端游标的RowDataCursor。全量装载的代价可以估算一千万行、每行 30 个字段假设平均每个字段 30 字节原始字节就是 900MB再加上数组对象头和 GC 复制时的临时副本2GB 堆很容易触顶。为什么驱动要把全量装载作为默认因为对几千行的小查询来说一次网络往返把数据全部拉回来比反复与服务端交互快得多——默认设计服务的是小结果集场景千万级导出恰好踩中了它的盲区。这就是很多系统「10 万行没事100 万行卡死500 万行直接 OOM」的阶梯式崩塌来源不是数据量线性增长而是内存占用跨过了堆容量临界点。2.2 隐性炸弹二字符串拼接制造海量短生命周期对象第二个坑藏在最不起眼的拼接代码里。while (rs.next()) { sb.append(...); writer.write(...); }看着没问题但如果你在循环里new StringBuilder()就等于每行都创建一个新char[]。StringBuilder 默认容量只有 16 字符append 超过容量就要扩容扩容逻辑大致是(oldCapacity 1) 2把旧的char[]完整复制进新数组。一行业务数据 300 字符往往要扩容 4 到 5 次一千万行就是几千万次数组复制。更隐蔽的是 ORM 映射。很多导出代码会把ResultSet映射成 DTO 再拼字符串框架为每行反射创建对象、逐个 setter 赋值相当于每行多造了几十个短生命周期对象。这些对象进入新生代后几乎立刻变成垃圾Minor GC 被反复触发CPU 大量烧在内存分配与回收上导出速度跟着雪崩。真正吃掉内存的往往不是「数据本身」而是数据在转换过程中产生的「中间态对象」。2.3 隐性炸弹三写入端缓冲太小GC 频繁介入第三颗雷在写出方向。FileWriter默认几乎不缓冲每行write都会穿透到文件系统层一次用户态到内核态的切换。有人为了性能加上BufferedWriter但每写一行立刻flush()等于把缓冲的意义全部废掉数据根本没有攒起来每一次 flush 都在强制底层执行一次磁盘写入。反过来另一种极端做法是把所有内容先写进ByteArrayOutputStream最后一次性写盘——这等于把整个 CSV 文件在堆内存里又复制了一份内存占用直接翻倍。写出端正确的缓冲策略是保证输出流带缓冲flush 的频率从「每行一次」降低到「每几千行一次」让数据以块为单位进入操作系统页缓存。缓冲区间的大小设计决定了磁盘 IO 和 JVM GC 谁抢占 CPU。方案内存占用特征瓶颈全量加载再写文件结果集 文件内容双份驻留堆内存直接触顶逐行查询无缓冲写出内存低但系统调用爆炸CPU 耗尽在用户态/内核态切换游标 块级刷盘内存恒定IO 成块磁盘吞吐可预期可优化3. 流式导出怎么 work游标读取与逐行写出的底层协同破局点在于把「查询」从「一次搬回家」改成「边吃边吐」。数据库本身一直支持流式取数只是驱动默认没开或者需要你在代码里显式声明一系列前置条件。下面展开两条线索驱动层面的游标机制以及 CSV 写入侧的配合。3.1 游标读取的真相MySQL 与另一类驱动行为完全不同先说 MySQL 驱动。它的游标模式不是setFetchSize()就能触发的还需要在连接 URL 里加useCursorFetchtrue并且同时满足三个条件autoCommitfalse、结果集类型为TYPE_FORWARD_ONLY、并发模式为CONCUR_READ_ONLY。四个条件缺一个驱动会静默退回全量装载模式——这是「我设置了游标但内存还是爆」最典型的原因下一章避坑部分会再展开。满足条件后executeQuery()返回时客户端内存几乎是空的驱动在服务端维护一个游标rs.next()时按fetchSize批量从服务端取行。举个例子fetchSize500驱动每调用 500 次next()才去数据库抓下一批单批 500 行在网络往返和客户端内存之间取得平衡。这里有一个本质差异全量模式是一次网络往返搬回所有数据游标模式是多次往返、每次只搬一小批换来的是堆内存上界恒定。另一类主流数据库的 JDBC 驱动PostgreSQL设计上更激进只要setFetchSize()设为大于 0 的值驱动自动与服务端协商游标协议不需要额外 URL 参数。这两者的差异反映了各自驱动团队对「默认安全」的理解不同——MySQL 把全量拉取当默认PostgreSQL 把流式当可选优化但共同点是游标读取都必须以「结果集只在向前遍历」为前提你不能在流式过程中跳回上一行。3.2 CSV 转义与逐行写入不是「拼一行写一行」那么简单CSV 不是简单的逗号分隔文本。按照 RFC 4180字段内如果出现逗号、双引号或换行整个字段必须用双引号包裹字段内部的双引号要翻倍为两个。千万行数据里难免有几条用户备注带换行不转义就会出现「一行数据在文件里被拆成两行」的错位灾难。这一规则必须在每个字段写入时都做判断不能偷懒。中文场景还有一个隐藏细节UTF-8 编码的 CSV 如果不带 BOMExcel 打开时会按系统 ANSI 代码页猜编码中文乱码几乎必现。解法是在写第一行之前先输出\uFEFF这三个字节的 BOM 头。需要注意BOM 只对「CSV UTF-8 Excel 打开」的组合有意义如果对接方要求 GBK 编码则不需要。3.3 代码骨架一套可运行的流式导出模板下面是一个可直接落地的核心模板。先看结构再逐行讲机制。import java.io.BufferedWriter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.sql.*; public class CsvStreamExporter { private static final int FETCH_SIZE 500; // 服务端每批返回 500 行 private static final int FLUSH_ROWS 5_000; // 每 5000 行刷一次磁盘 public static long export(String url, String user, String pwd, String sql, String csvPath) throws Exception { // url 示例jdbc:mysql://host:3306/db?useCursorFetchtrue try (Connection conn DriverManager.getConnection(url, user, pwd)) { conn.setAutoCommit(false); // 游标模式要求事务开启 try (PreparedStatement ps conn.prepareStatement( sql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY); BufferedWriter writer Files.newBufferedWriter( Paths.get(csvPath), StandardCharsets.UTF_8)) { ps.setFetchSize(FETCH_SIZE); // 限制每批拉取行数 long rowCount 0; StringBuilder line new StringBuilder(512); // 复用的行缓冲 try (ResultSet rs ps.executeQuery()) { ResultSetMetaData meta rs.getMetaData(); int cols meta.getColumnCount(); writeHeader(writer, meta, cols); while (rs.next()) { appendRow(line, rs, cols); // 拼好一行 writer.write(line.toString()); // 写出一行 line.setLength(0); // 清空复用避免扩容复制 if (rowCount % FLUSH_ROWS 0) { writer.flush(); // 成块刷入 OS 缓冲区 } } } return rowCount; } } } private static void appendRow(StringBuilder line, ResultSet rs, int cols) throws SQLException { for (int i 1; i cols; i) { if (i 1) line.append(,); String v rs.getString(i); if (v null) continue; // CSV 转义含逗号/引号/换行的字段用双引号包裹内部引号翻倍 if (v.indexOf(,) 0 || v.indexOf() 0 || v.indexOf(\n) 0) { line.append().append(v.replace(\, \\)).append(); } else { line.append(v); } } line.append(\n); } private static void writeHeader(BufferedWriter w, ResultSetMetaData meta, int cols) throws Exception { StringBuilder sb new StringBuilder(128); for (int i 1; i cols; i) { if (i 1) sb.append(,); sb.append(meta.getColumnLabel(i)); } sb.append(\n); w.write(sb.toString()); } }代码的关键机制拆成四点第一conn.setAutoCommit(false)不是可有可无。MySQL 服务端游标与事务绑定如果自动提交保持开启每条 SQL 执行完事务立即关闭游标随之销毁驱动只能退回全量模式。这行代码是游标协议能存活的前提。第二ps.setFetchSize(500)决定的是「网络往返的批次行数」不是总行数。500 这个值太小会导致与数据库的交互次数激增太大又会让单批内存重新变大。实际项目中 500 到 2000 是常见区间具体值要看平均行宽行越宽fetchSize 越要调小。第三line.setLength(0)配合外层复用一个StringBuilder把每行的拼接成本从「新建数组 多次扩容复制」降到「原地覆盖」。这是千万级导出里容易被忽略却收益最大的微优化。第四flush 间隔取 5000 行单批大约 1MB 到 2MB 的文本进入 OS 页缓存。这个粒度下即使进程中途崩溃丢失的数据量也控制在可接受范围同时避免了每行一次系统调用的性能灾难。提示这套模板假设单行数据不会特别巨大。如果某个字段是几 MB 的 JSON 或 Base64单行驻留本身就决定了内存上界需要对该字段单独做流式读写不再适用字符串拼接。4. 千万级导出的完整架构分页策略、双缓冲与临时文件合并游标读取不是万能钥匙。某些场景下你拿不到长事务连接或者数据源根本不在关系型数据库里。这时候要退到第二套方案体系分页取数、双缓冲消费、以及文件切分合并。4.1 keyset 分页为什么是千万级的最优解很多系统不允许导出任务长时间占着事务连接比如连接池不够、主库压力敏感于是改用分页查询。最常见的写法是LIMIT offset, size这会在千万级数据里迅速退化offset 到 500 万时数据库要把前 500 万行完整扫出来再丢弃代价随页码线性上涨。测量结果通常表现为「前 10 页飞快第 100 页明显卡顿第 1000 页几乎跑不动」。keyset 分页也叫 seek 分页换了一个思路不翻页只定位。-- 深翻页写法offset 越大扫描行数越多 SELECT * FROM orders WHERE export_status 0 ORDER BY id LIMIT 1000000, 50000; -- keyset 写法每次都从上次的最后一行 id 继续向后扫 SELECT * FROM orders WHERE export_status 0 AND id ? ORDER BY id LIMIT 50000;keyset 分页每一页都从索引定位点开始范围扫描B 树的时间复杂度是 O(log n m)与已经导出了多少页无关每页耗时恒定。这是它在千万级仍然可用的根本原因。两个使用前提要记住ORDER BY的字段必须与WHERE里比较的字段一致并且该字段有索引如果按create_time排序且时间可能重复需要改成(create_time, id)复合条件保证每行有唯一确定的前后位置。还有一个容易踩的坑增量导出时不要在遍历过程中逐行更新export_status。一旦某一行被更新它的排序位置改变可能被扫两次或漏掉。正确的做法是先按条件选完一批、写入文件最后再统一批量更新状态。4.2 双缓冲与背压让数据库读和磁盘写重叠单连接流式读已经能保证内存稳定但 CPU 在加工每行数据时数据库连接是闲置的磁盘在写入时CPU 又在等待。为了让三个阶段重叠常见架构是生产者-消费者模型一个线程从ResultSet读取并加工另一个线程负责写文件中间用有界队列传递行数据。队列必须是有界的。ArrayBlockingQueue容量设为 5000 行左右生产者在队列满时阻塞等待消费者从队列取行写入。这个「满则等待」的机制就是背压Backpressure——它保证无论消费者处理多慢内存占用不会突破「队列容量 单批结果集」的上限。很多人在这里犯错用无界LinkedBlockingQueue消费者一旦跟不上队列就变成又一个堆内存炸弹OOM 只是时间问题。双缓冲适合的场景是每一行都做非平凡加工比如字段解密、格式转换、多表拼接。如果只是原样导出单线程顺序读写往往更快——因为省掉了线程切换和队列开销。这个「看起来更复杂反而更慢」的反直觉结果是导出架构设计里很常见的权衡陷阱。4.3 临时文件合并数据源不支持游标时的兜底方案如果数据源不是 SQL而是 HTTP 接口或搜索引擎的分页响应游标和 keyset 都不适用。这时候的方案是按数据源能提供的最大单页大小把数据切成若干个临时文件每个切片内部逐行写入 CSV最后把所有切片顺序合并成一个最终文件。合并操作是顺序 IO从每个切片读一行、追加到目标文件内存里始终只有一行缓冲内存上界与文件总大小无关。切片的第一个文件保留表头其余切片生成时不写表头合并时直接按行追加即可# 伪命令part_0001.csv 含表头其余切片不含表头按顺序追加 cat part_0001.csv part_0002.csv part_0003.csv final.csv这个方案的代价是磁盘占用翻倍所有切片和最终文件同时存在总耗时比单连接流式长。它的价值在于把「数据源不支持流式」这种死局变成了「内存恒定、可断点续导」的可行路径。每个切片对应独立的数据请求哪个切片失败就只重导那一片这是它优于全量重跑的地方。5. 避坑记录千万级导出最容易踩的五个坑这一节是真实导出模块里的血泪总结。每一条都对应过一个「看起来没问题跑起来就翻车」的玄学现场按「现象 → 原因 → 解决」说清楚。5.1 现象fetchSize 设了没生效结果集还是全量进了内存有同学反馈代码里写了ps.setFetchSize(1000)跑导出时堆内存照样涨到 1.5GB。查到最后问题出在连接 URL 上——驱动根本没进入游标模式。原因MySQL 驱动要走上服务端游标协议必须同时满足四个条件缺一不可连接 URL 加useCursorFetchtrue、autoCommitfalse、结果集类型TYPE_FORWARD_ONLY、并发模式CONCUR_READ_ONLY。setFetchSize()只是其中一个环节URL 参数缺失时驱动直接按全量模式执行。解决把连接串改成jdbc:mysql://host:3306/db?useCursorFetchtrue并在代码里显式声明结果集类型。验证方法很简单executeQuery()返回后立刻看一眼Runtime.getRuntime().totalMemory()如果堆占用几乎没变化说明游标生效如果瞬间涨了几百 MB就是退回了全量模式。5.2 现象OOM 发生在导出中段GC 日志里全是 Full GC某项目用游标改完后前 10 分钟一切正常20 分钟后突然java.lang.OutOfMemoryError: Java heap space。问题不在查询而在行加工。原因导出时把每行数据映射成 DTO放到ArrayList里攒着等攒够一万行再批量写文件。这个「攒一批」的动作让内存驻留量和结果集大小重新挂钩游标省下的内存又被 List 吃回去了。更隐蔽的是每行new StringBuilder()让新生代持续产生垃圾GC 线程和导出线程互相抢 CPU。解决坚持「拿一行、拼一行、写一行、丢一行」不建 DTO用rs.getString(i)直接取下标StringBuilder在循环外建好setLength(0)复用。判断标准用jstat -gcutil观察 Old 区占比整个导出过程应该是一条平稳的直线而不是阶梯式上涨。5.3 现象CSV 用 Excel 打开中文全乱记事本打开却正常导出的文件用 Notepad 看完全正常发给业务方用 Excel 打开全是乱码。很多人第一反应是编码写错了改成 GBK 后乱得更厉害。原因Excel 对 CSV 的默认编码识别规则是按系统区域设置的 ANSI 代码页猜测。文件是 UTF-8 编码但没带 BOM 时Excel 把它当本地代码页解析中文自然乱码。解决写入文件第一行之前先输出 UTF-8 的 BOM 头\uFEFF。在 Java 里就是writer.write(\uFEFF)放在写表头之前。如果对接方明确要求 GBK那就不需要 BOM直接按 GBK 编码写。这一条属于「五分钟修复但不知道时能卡半天」的典型问题。5.4 现象导出越跑越慢前 100 万行 10 分钟后 900 万行要 1 小时现象是导出模块在数据量超过百万后每页耗时随页码增长数据库服务器 CPU 飙高应用端反而很闲。原因LIMIT offset, size的深翻页写法。offset 到 900 万时数据库要先扫描 905 万行再丢弃前面的 900 万行只为取出最后 5 万行。这个成本不是线性增长是随 offset 增大而持续抬高的。解决改成 keyset 分页用WHERE id ? ORDER BY id LIMIT 50000。判断是否成功修复的标准很直接每页耗时应该恒定在同一个量级不再随页码增加。另一个排查点如果排序字段没有索引keyset 也会退化先检查执行计划里是不是全表扫描。5.5 现象导出的文件行数不对某行之后所有字段集体错位CSV 文件被业务方导入系统后报格式错误打开一看某一行开始字段整体往右偏了一列行数也比数据库里少了几条。原因自由文本字段订单备注、用户地址里包含了逗号、换行或双引号导出时没有做 CSV 转义。字段里的换行让一行数据在文件里被物理拆成两行后续所有行的解析都跟着错位。这类问题的数据占比可能只有万分之一但有 1000 万行数据时就意味着会有几千行炸掉。解决按 RFC 4180 规则处理每个字段——含逗号、换行或双引号的字段用双引号包裹字段内部的双引号翻倍。导出完成后做一次回验用 CSV 解析库把文件读一遍统计行数和数据库里的count(*)对比不一致就说明还有字段没转义对。6. 设计取舍流式、分页还是「看起来流式」我最后这样选导出方案没有银弹三套方案分别对应不同的约束条件我的选择逻辑是一张三分表场景特征推荐方案内存上界连接可控、SQL 简单、允许事务游标 逐行写fetchSize 行数 行缓冲不允许长事务、需要断点续导keyset 分页单页行数 行缓冲数据源不是 SQL接口、搜索引擎切片 文件合并单切片行数 行缓冲判断一个方案是不是「真流式」我一般只看一条标准任意时刻堆里驻留的数据量是否有一个与总数据量无关的上界。这个上界要么由fetchSize决定要么由单页大小或单切片大小决定。凡是把 100 万行先塞进List再分批写文件的写法本质上只是把 OOM 推迟到下一个环节换了个位置爆而已不叫流式。验证方法比想象中简单。用-Xms64m -Xmx64m启动导出进程跑 100 万行数据堆占用稳定不 OOM才算真正吃透了这套机制。如果这个配置下 1 万行就挂先别怀疑堆太小回头检查结果集是不是又全量加载了。从那以后我每次写导出模块都会先核对三件事连接 URL 有没有带游标参数、ResultSet是不是FORWARD_ONLY、逐行拼接有没有复用同一个StringBuilder。这套「强迫症」换来的回报是导出一类代码成了我手上最不容易出事故的模块。如果你正手头有一份导出工程模板要改成千万级按上面这套参数和验证思路逐项对照落地就行每一个坑的解法都对应着一次真实的线上教训。希望帮到你。本文还有配套的精品资源点击获取