
简介面向中高级Java开发者这份资源围绕千万级数据量的CSV导出场景针对一次性加载导致的内存溢出与性能瓶颈提供了完整的优化方案与可直接运行的示例代码。压缩包共十个文件其中九个Java源文件覆盖分批处理、多线程并行写入、线程池管理、内存映射等核心实现另有一个txt文档简述设计思路与注意事项资源总大小仅十KB轻量便于阅读。目前已有15562人学习下载其设计思路在批量导出场景中有较高参考价值。读者可以从中掌握利用缓冲流逐行读写、按十万条分块配合线程池避免内存溢出的关键做法以及通过内存映射文件进一步降低内存占用、保障系统稳定的具体方法并参考异常捕获、日志监控等稳定性设计直接应用到生产环境的批量导出任务中。1. 导出千万级CSV老是被内存溢出打断这个坑几乎每个Java后端都踩过一个典型的翻车现场线上报表导出功能数据库里 900 多万行数据点一下「导出」页面转圈三分钟接口直接抛java.lang.OutOfMemoryError: Java heap space服务跟着重启。把 JVM 堆内存从 2G 调到 4G 再试照样炸。这就是 Java 做 CSV 大数据量导出最折腾人的地方问题不在堆给得不够而是代码把千万行数据全塞进了内存再统一写文件。这篇文章写给正被同类问题卡住的后端开发者——做过报表导出、数据迁移、对账文件生成被 OOM 折磨过或正在被折磨。解决思路其实就一句话查询走流式写入走缓冲堆里同一时刻只允许存在一小批数据。下面按「为什么炸 → 怎么改 → 避坑」的顺序把整个方案讲透。2. 为什么导出千万级CSV会内存溢出三个藏在代码里的根因先把最常见的「错误写法」摆出来。很多人的导出代码长这样// 危险写法只适合几千行的数据 ListUser users userMapper.selectAll(); // 一会儿把所有行都装进堆 StringBuilder sb new StringBuilder(); for (User u : users) { sb.append(u.getId()).append(,).append(u.getName()).append(\n); } Files.write(Paths.get(users.csv), sb.toString().getBytes(StandardCharsets.UTF_8));这段代码在数据量小的时候没有任何问题数据量一旦到百万、千万堆就肉眼可见地告急。下面三个根因基本解释了为什么它必炸。2.1 根因一一次查询把所有行装进List堆被对象头和数据双重吃光先算一笔账。假设导出的表有 1000 万行每行 5 个字段idLong、nameString、ageInteger之类。selectAll()执行完堆里至少多出这么几块东西List 的引用数组1000 万条引用按压缩指针 4 字节算约 40MB每一行的实体对象一个 User 对象光是对象头加字段对齐就要 16 字节起步加上 Long、Integer 这些包装类型各自的 16 字节对象头单行 5080 字节很常见1000 万行就是 500800MBname 字段的字符串每个 String 对象有 16 字节头 char[] 引用char[] 里每个字符 2 字节。一个 10 个字符的名字char[] 加对象头大约 40 字节1000 万行又是 400MB后面sb.toString()和getBytes()还会再复制一整份。也就是说光是把数据查出来堆里就已经多了 12GB 的占用。JVM 堆开 1G、2G 根本不够看开到 4G 也就是延缓死刑。这里有个反直觉的点真正吃掉内存的不只是「数据本身」还有大量看不见的对象头、包装类型、char[] 对齐填充。那有人会说我不用 List我用分页每页 1000 行循环查。这在几万行时可行到千万级同样难受——LIMIT 5000000, 1000这种深分页数据库每次都要从头扫描到第 500 万行再丢弃越到后面越慢整个导出时间膨胀到不可接受。而且如果循环里又把每一页数据累计到一个大 List 去排序、去重内存最终还是被堆满。深分页解决的是「界面翻页」问题解决不了「全量导出」问题。2.2 根因二StringBuilder无脑拼接和字符集转换制造了大量重复对象上面那段错误代码里StringBuilder也不是省油的灯。append过程中底层 char[] 会按 1.5 倍左右的倍率扩容每次扩容都要把旧数组复制一遍。当拼接目标是几百 MB 的字符串时扩容瞬间会同时存在新旧两份大数组GC 压力瞬间拉满。更隐蔽的是sb.toString().getBytes(StandardCharsets.UTF_8)这一步。toString()会把整个 StringBuilder 的 char[] 再复制一份给 StringgetBytes()再按 UTF-8 编码出一份 byte[]。也就是说最终写文件的那一下堆里同时存在char[] 原数组、String 内部的 char[]、byte[] 三份超大对象。一次导出 500MB 文本这一步就可能瞬时多出 1GB 内存占用。字符串拼接在循环里还有一个经典翻车写法String line u.getId() , u.getName() \n; // 循环内每行一个匿名StringBuilder每执行一次都会 new 一个 StringBuilder、生成若干个临时 String。千万行就是千万次小对象创建minor GC 频率暴涨最后通常跟出java.lang.OutOfMemoryError: GC overhead limit exceeded——这行报错不是堆给少了是 JVM 花了 98% 的时间在 GC 却只回收了 2% 的内存典型「大量小对象 无效拼接」症状。我对付这类问题的原则很简单数据在业务层只做「读取一行 → 拼一行 → 写一行」绝不在内存里把全部文本攒起来。任何一步「先全部拼好再写」的设计都是把 OOM 的倒计时调快了。2.3 根因三fetchSize 没生效数据库驱动还在默默缓存所有行很多人知道要「流式查询」于是在 PreparedStatement 上调用了setFetchSize(1000)但内存照样爆。原因是JDBC 驱动默认不开启流式fetchSize在某些数据库上直接被忽略结果集仍然被一次性全部拉回客户端内存。这事的坑特别多先说常见关系型数据库的两类差异MySQL 的 Connector/J 驱动默认执行查询时服务端把所有结果一次性发给客户端setFetchSize(int)必须在连接串上显式开启useCursorFetchtrue才会走服务端游标在 5.x 老驱动里还得配合ResultSet.TYPE_FORWARD_ONLY并把fetchSize设为Integer.MIN_VALUE驱动才会退回流式协议。PostgreSQL 的 JDBC 驱动fetchSize的生效有硬性前提——连接必须setAutoCommit(false)。如果开着自动提交驱动会认为查询是一次性事务仍然全量读取结果集fetchSize形同虚设。看到这你大概明白了报错信息一模一样但根因可能各不相同。调了 fetchSize 还 OOM 的十有八九是「以为配置生效了实际驱动根本没走流式」。这个黑匣子如果不拆开换大堆内存、换机器都是在赌运气。3. 边查边写游标流式读取 缓冲流分批落盘千万行导出内存稳如老狗3.1 核心原理游标结果集省内存的本质流式读取的机制可以简单理解成传统模式下驱动执行完查询就把所有结果行缓存到客户端内存里流式模式下驱动只在客户端保留一个游标位置调用一次rs.next()才从服务端取下一行取到一批fetchSize 大小之后再触发下一次网络拉取。堆里同一时刻最多只存在一批行而不是全部行。所以导出千万行 CSV 的正确姿势是两条腿走路查询端用流式 ResultSet 控制「堆里最多有多少行」写入端用带缓冲的 Writer 控制「磁盘最少写多少次」。查询每一批最多 1000 行写入每攒够 1MB 再落盘一次整个导出的内存占用就能稳定在一个很低的水平。注意流式模式下 ResultSet 连接是长时间占用的所以这段代码必须在同一个事务里跑完中途不能把连接还给连接池。这也是为什么 MyBatis 里用Cursor时官方反复强调要在 SqlSession 未关闭的范围内遍历游标。3.2 可直接运行的完整示例JDBC 游标 CSV 转义 UTF-8 BOM下面这份代码是完整的、可以直接放进项目里的方法。import java.io.BufferedWriter; import java.io.FileOutputStream; import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import javax.sql.DataSource; /** * 千万级CSV导出游标流式读取 缓冲流分批落盘 * 适用场景支持服务端游标的关系型数据库 */ public class CsvBigExport { public static void exportBigCsv(DataSource dataSource, String sql, String filePath) throws Exception { try (Connection conn dataSource.getConnection()) { // 关键1关闭自动提交。PostgreSQL 必须这样 fetchSize 才生效 conn.setAutoCommit(false); try (PreparedStatement ps conn.prepareStatement( sql, ResultSet.TYPE_FORWARD_ONLY, // 只允许向前读 ResultSet.CONCUR_READ_ONLY)) { // 只读结果集 // 关键2每批拉取的行数配合驱动开启服务端游标 ps.setFetchSize(1000); try (ResultSet rs ps.executeQuery(); // 关键31MB 缓冲攒够一批再刷盘减少磁盘IO次数 BufferedWriter writer new BufferedWriter( new OutputStreamWriter( new FileOutputStream(filePath), StandardCharsets.UTF_8), 1024 * 1024)) { // 关键4UTF-8 BOM避免 Excel 打开中文乱码 writer.write(\uFEFF); writer.write(id,name,age,remark\n); while (rs.next()) { // 每一行一个小的StringBuilder拼完即弃不累积 StringBuilder line new StringBuilder(128); line.append(rs.getLong(id)).append(,) .append(csvEscape(rs.getString(name))).append(,) .append(rs.getInt(age)).append(,) .append(csvEscape(rs.getString(remark))).append(\n); writer.write(line.toString()); } writer.flush(); } } finally { // 关键5恢复默认状态避免连接池里其他线程拿到异常事务 conn.setAutoCommit(true); } } } private static String csvEscape(String value) { if (value null) { return ; } // 字段里含逗号/双引号/换行时必须用双引号包裹 if (value.indexOf(,) 0 || value.indexOf() 0 || value.indexOf(\n) 0 || value.indexOf(\r) 0) { // 内部的双引号转义为两个连续双引号 return value.replace(\, \\) ; } return value; } }几个参数逐个说明ResultSet.TYPE_FORWARD_ONLY和CONCUR_READ_ONLY流式读取的硬性前提。如果用默认参数创建 Statement部分驱动会退回全量缓存模式。这个细节网上很多例程没写漏掉它setFetchSize等于白设。ps.setFetchSize(1000)每次从数据库拉取 1000 行。取值不是越大越好也不是越小越好。太小了网络往返次数暴涨导出一千万行要发一万次请求太大了流式优势被稀释。我一般取 10005000 之间视单行字段宽度调整。BufferedWriter的 1MB 缓冲写入端每攒 1MB 才真正调用一次 IO。如果没有这层缓冲每行一次磁盘写千万行就是千万次 IO导出时间会从分钟级拖到小时级。writer.write(\uFEFF)BOM 头只能写在文件最前面且只能写一次。它是给 Excel 认编码用的写在中间反而会产生不可见字符。提示MySQL 8.x 的注意点是连接串必须带useCursorFetchtrue代码里的setFetchSize(1000)才会被驱动认可两个缺一个查询都会退化为全量缓存。5.x 老驱动则把setFetchSize(1000)改成setFetchSize(Integer.MIN_VALUE)这是老驱动触发流式协议的标志值。两种写法不要混用。3.3 MyBatis 用 Cursor 的写法与三个注意事项如果你项目里用的是 MyBatis不一定要手写 JDBC。Mapper 方法返回类型从ListUser改成CursorUser底层走的还是 JDBC 流式结果集// Mapper 接口声明 CursorUser selectAllByCursor();// Service 中使用游标必须在 SqlSession 存活时间内遍历完 try (SqlSession sqlSession sqlSessionFactory.openSession(); CursorUser cursor sqlSession.getMapper(UserMapper.class).selectAllByCursor()) { for (User user : cursor) { // 逐行写入文件 writer.write(...); } }用 Cursor 有三个坑要提前知道。第一sqlSessionFactory.openSession()不要传自动提交保持手动控制第二遍历结束后再关 SqlSession绝不能先关连接再遍历游标否则会抛ResultSet is closed之类的异常第三Mapper XML 里如果写了fetchSize1000需要确认数据库驱动支持MySQL 同样依赖连接串的useCursorFetchtrue。本质上 Cursor 只是把 JDBC 的流式查询包了一层前面讲的驱动配置它一个都躲不掉。3.4 参数选型与压测建议把上面几个参数放在一起看它们各自管一段瓶颈参数管什么建议值改大 / 改小的影响setFetchSize每批从数据库拉到客户端的行数10005000太小网络往返多太大流式优势变小BufferedWriter缓冲每攒多少字节刷一次盘1MB8MB太小 IO 频繁太大占用内存和临时空间autoCommit流式读取的事务前提falsePostgreSQL 必须 falseMySQL 建议 false连接串socketTimeout单个 socket 最大读超时60000010分钟太长连接悬挂太短大导出被中途掐断拿到参数之后我建议做一次「先小后大」的压测先用limit 100000导出 10 万行观察耗时和内存再用全量跑一千万行同时用监控工具盯堆内存。如果 10 万行时堆就明显上涨先回去查 fetchSize 是否生效不要急着加-Xmx。加堆内存是最后的兜底手段不是第一解决方案——你永远不知道下一个项目的数据量会不会翻倍。4. 导出文件乱码、列错位、连接断掉五条高频踩坑记录这一章全是我和同行踩过的真实翻车记录每条按「现象 → 原因 → 解决」拆开方便你对号入座。4.1 现象导出的 CSV 用 Excel 打开中文全是乱码现象是文件在 Notepad、IDE 里看是正常的一用 Excel 打开中文变成「鏂囨。鎺掗敊」之类的乱码。原因CSV 文件本身是 UTF-8 编码但 Excel 打开无 BOM 的 UTF-8 文件时会按本地系统编码中文系统里是 GBK去解读编码对不上就乱码。解决写文件时在最开头写入一个 BOM 头\uFEFFExcel 看到 BOM 就会按 UTF-8 解析。上面示例代码里writer.write(\uFEFF)就是干这个的。如果明确知道使用者只会用 Excel 打开也可以直接把文件按 GBK 输出但 BOM 方案兼容性最好。注意 BOM 一定要在写任何数据之前写入且全文件只能出现一次。4.2 现象长数字 ID 变成科学计数法精度丢失现象数据库里的雪花 ID 是 19 位数字导出到 CSV 后用 Excel 打开显示成1.23457E17后几位变成了 0。原因Excel 对超过 15 位的纯数字默认按数值处理并转成科学计数法超过 15 位之后的精度直接丢弃。这不是 CSV 规范的问题是 Excel 的显示逻辑。解决在需要保留精度的数字列后面拼一个制表符\t让 Excel 判断这一列不是纯数字从而按文本显示writer.write(csvEscape(String.valueOf(rs.getLong(id))) \t,); // 数字后补制表符Excel 按文本处理注意这里的顺序数字 \t再接逗号逗号仍然是字段分隔符。如果 CSV 最终不是给 Excel 用的这招会多出一个制表符所以只在确认使用者用 Excel 打开时用。另一种做法是把单元格写成1234567890123456789的公式形式Excel 会显示为文本但对其他解析器不友好我不太推荐。4.3 现象字段里带逗号、换行导出后列错位现象某一行数据里有个字段存的是多行文本比如备注导出后 Excel 里这一行被拆成了两行后面的列全部右移。原因CSV 规范规定字段内如果包含逗号、双引号、换行符整个字段必须用双引号包裹字段内部的双引号用两个连续双引号转义。很多初版实现只处理了逗号漏了换行和双引号。解决上面示例里的csvEscape方法就是标准处理。核心判断条件不要漏if (value.indexOf(,) 0 || value.indexOf() 0 || value.indexOf(\n) 0 || value.indexOf(\r) 0) { return value.replace(\, \\) ; }顺便说一个常见误用有人在replace里先处理了逗号再处理双引号结果把转义规则搞乱。正确的顺序是「先判断是否需要包裹再统一替换双引号」不要分两步处理。写完之后用一个带逗号、换行、双引号的测试样本验证一下这比任何理论都可靠。4.4 现象导出到一半报 Connection reset任务直接失败现象导出小数据量没问题一跑千万级执行到 70% 左右抛Communications link failure或Connection reset by peer看数据库端日志发现连接被服务端主动关闭。原因大导出期间结果集游标长时间占用连接而数据库、负载均衡设备或防火墙对空闲连接有默认超时常见 3060 秒。虽然数据一直在传但在某些中间设备看来这条连接长时间没有「新的查询活动」就被当作空闲连接回收了。解决给连接串显式配置更大的超时时间。常见的是连接串上加socketTimeout单位毫秒把它调大到能覆盖整个导出时长同时确认数据库侧的wait_timeout/idle_in_transaction_session_timeout足够大。注意这个参数对「一次查询内持续拉取」同样有效它卡的是 socket 级别的读超时。我一般直接设 10 分钟起步导出完再排查会不会话悬挂而不是让大导出卡死在中途。4.5 现象fetchSize 配了内存还是涨到溢出现象代码里明明写了ps.setFetchSize(1000)导出过程中用监控工具看堆内存Old 区还是匀速上涨最终 OOM。原因通常是下面几种之一按出现频率排MySQL 8.x 驱动没在连接串里加useCursorFetchtruesetFetchSize被忽略用的是老驱动却把 fetchSize 设成了正数 1000而不是Integer.MIN_VALUEMyBatis 的 Mapper 方法返回类型还是ListUser而不是CursorUser查询结果被全部缓存代码里虽然流式读取了但把每一行对象又收集到另一个 List 里统一排序、去重后再写文件等于绕了一圈又回到全量加载。解决逐条对照排查。最直接的办法是打印驱动日志确认每次网络请求带回的行数更省事的是做一个小实验——把 fetchSize 改成 10导出 100 万行如果内存还是爆说明流式根本没生效因为 10 行一批的模式下堆里不可能堆下几百万行对象。这个「把参数改极端再验证」的技巧能帮你快速区分是配置问题还是代码问题。5. 快速定位导出异常一张报错对照表和三步验证法导出 CSV 的报错看着五花八门归纳下来就那么几类。先给一张我排查时常用的对照表报错关键字最可能的根因优先检查项Java heap space查询结果全量进堆fetchSize 是否真的生效GC overhead limit exceeded大量小对象 无效字符串拼接是否在循环里拼大字符串Communications link failure连接超时被回收socketTimeout / wait_timeoutMalformedInputException读写两侧字符集不一致统一 UTF-8导出文件中文乱码没有 BOM 头文件首字节是否是 EF BB BFCSV 列错位字段含逗号/换行未转义csvEscape 是否覆盖全部特殊字符对照表里最容易被忽视的是「fetchSize 是否真的生效」。我见过太多人配置写了、代码没写全比如漏了TYPE_FORWARD_ONLY或者连接串少了参数症状和没配置完全一样。所以我把验证方法固定成三步每次遇到导出相关故障都按这个顺序走。第一步小样冒烟测试。先给查询加LIMIT 100000导 10 万行确认文件内容、编码、转义全对。这一步能过滤掉绝大部分业务逻辑错误把问题范围缩小到「数据量导致的」还是「逻辑导致的」。第二步观察内存曲线。导出过程中用jstat -gcutil pid 1000每隔一秒打一次 GC 状态重点看 Old 区的占用曲线。如果 Old 区随导出匀速上涨、Full GC 次数增加基本可以断定查询端在往堆里堆数据如果内存平稳文件也在持续变大那就是写入端或超时的问题。这一步能直接区分「查询端」和「写入端」两个嫌疑方向。第三步看磁盘文件的增长。导出过程中用ls -l盯输出文件大小文件在持续增长说明主循环在正常推进。如果文件涨但内存也涨问题在查询端如果文件完全不涨、接口卡死问题多半在查询句柄或事务配置上比如游标没有真正被遍历。顺手再说一个细节文件导出完一定记得看「文件大小和行数是否匹配」。用wc -l数一下行数和数据库里的COUNT(*)对得上再交付。行数不对的半成品文件发出去比不发更麻烦。从那以后我每次写导出功能都强制自己先过四道问数据量级是多少、能不能走流式、文件最终给谁看、特殊字符和编码处理了没。四个问题答完再动手写代码这个习惯帮我挡住了后面至少三次翻车。如果你现在正被 OOM、乱码、连接断开轮流折磨按上面的对照表和验证三步走一遍大概率能在半小时内定位到根因。希望帮到你。本文还有配套的精品资源点击获取