简介:面向需要处理千万级CSV导出任务的Java开发者,这份资源针对一次性加载数据导致OutOfMemoryError的问题,给出了一套基于分批处理与多线程并行的代码级解决方案。包内共10个文件,其中9个Java源文件覆盖批量导出、线程池调度、CSV读写工具、结果压缩与下载接口等模块,另附1个txt说明文档,压缩包整体仅10KB,代码精简、逻辑集中。核心实现演示了将数据拆分为多批,通过ExecutorService与Future提交和汇总结点任务,使用BufferedWriter逐行写入以降低内存占用,并结合ZipUtil对导出结果进行压缩;同时涉猎内存映射文件、异常捕获与重试、日志监控等生产环境中的优化思路。已有15559人学习下载,对于希望避免内存溢出并提升导出性能的开发者来说,是一份具有直接参考价值、值得研读的示例工程。 干后端这行,导出CSV大概是最不起眼又最容易翻车的需求之一。我接手过一个报表导出功能,单表七百万条数据,第一次上线就直接OOM,当时日志里那行 OutOfMemoryError: Java heap space 看得人头皮发麻。后来排查才发现,问题不在导出本身,而在查数据的方式——一把梭把所有记录全部捞进内存,再转头去写文件。这篇就聊聊我怎么把CSV导出从几十万条上限一路做到千万级还稳如老狗,适合正在被大数据量导出折磨的Java开发同学,尤其是刚入行两三年、准备做报表导出功能重构的朋友。
1. 先搞清楚:千万级数据为什么会内存溢出
1.1 内存溢出的罪魁祸首不是文件,而是查询方式
很多人第一反应是“文件太大了”,其实CSV文件哪怕一千万行、每行一百字节,也就一个G左右,文件系统完全吃得消。真正炸掉内存的是中间这层Java对象。
打个比方,你从数据库查出500万条记录,每条记录用一个Map或者实体类对象装着再放进List。一个Map<String, Object>哪怕只有十几个字段,光对象头、哈希表结构、字符串对象本身的字符数组,算下来一条轻松占300到500字节。500万条乘一下,就是1.5G到2.5G的内存开销,还没算List扩容的余量。而JVM堆撑死就那么大,不OOM才怪。
就算你硬把堆调大,比如用-Xmx4g,GC也会被频繁的Full GC拖死。全量对象在堆里长期存活,老年代瞬间被打满,系统整体就废了。
1.2 解决的核心思路:数据只“流经”内存,不“驻留”内存
正确思路是把“查全部再写”改成“边查边写”。数据库游标每次只从服务端拉取一小批数据,处理完立刻释放引用,让GC能及时回收。文件写出端则用一个缓冲流,攒一批数据再flush到磁盘。
这里的关键认知是:导出流程里,数据应该像水管里的水一样流过你的程序,而不是像蓄水池一样全灌进来。水管里同一时刻只要保存一小段水就够了。具体到代码就是两个点的配合——读取端用游标逐步取数,写出端用缓冲流分批落盘,中间不要有List<实体>这种中间层。
2. 读取端实现:游标和fetchSize的正确姿势
2.1 别再用 List<对象> 一把梭
先看反面教材。很多人写导出代码是这样的:先调一个Mapper方法,内部执行 select * from table,MyBatis默认把所有结果一次性装进ArrayList,返回给你之后你再循环写CSV。这一步看似没什么,实际上内存就是在这一下爆掉的。
正确的做法是让数据库分批次把结果给你,你处理完这一批再要下一批。JDBC本身提供了游标机制,设置好fetchSize,每次从数据库服务端拉取指定行数到客户端内存,处理完这一批,客户端内存里的这部分数据就可以被回收了。
2.2 JDBC游标方案:连接串加两个参数就行
用原生JDBC或者Spring的JdbcTemplate,其实只需要在数据库连接串上做点文章。以MySQL为例,连接URL里加上:
jdbc:mysql://127.0.0.1:3306/dbname?useCursorFetch=true&defaultFetchSize=10000然后把查询语句按正常方式执行,拿到ResultSet之后循环遍历。这里的原理是,useCursorFetch=true让MySQL驱动启用服务端游标,resultSet不再一次性把所有行都拉到客户端,而是每调用next()时,按fetchSize指定的大小从服务端分批取数据。
我实测下来的经验是,fetchSize设成5000到10000比较合理。太小了,每批都要和数据库交互一次,网络往返次数太多,整体性能反而下降;太大了,单批数据在客户端内存里占用又变高,和流式处理的初衷就冲突了。
2.3 MyBatis的Cursor方案:代码更干净
如果你的项目用的是MyBatis,更推荐用游标类型作为Mapper方法的返回值。定义接口方法时,返回类型写成Cursor ,然后通过注解或XML配置fetchSize:
@Select("SELECT * FROM big_table WHERE create_time >= #{start}") @Options(fetchSize = 10000) Cursor<OrderDO> scanOrdersByTime(@Param("start") LocalDateTime start);拿到Cursor之后,配合try-with-resources使用,遍历完记得关闭。注意一个坑:用Cursor时,SqlSession不能提前关闭,否则游标还没读完,连接已经被回收了,会报“Operation not allowed after ResultSet closed”这类错误。最好把SqlSession的打开、遍历、关闭写在一个方法块里,别拆散到多个Service方法之间传递。
try (SqlSession session = sqlSessionFactory.openSession()) { OrderMapper mapper = session.getMapper(OrderMapper.class); try (Cursor<OrderDO> cursor = mapper.scanOrdersByTime(start)) { for (OrderDO order : cursor) { // 在这里把对象转成一行CSV文本,立刻写出去 } } }2.4 游标没有生效怎么办
很多人配完才发现,吃内存还是老样子。先自查这几个点:
- 连接串里有没有拼上 useCursorFetch=true。MySQL驱动默认是不开服务端游标的。
- MySQL版本和驱动版本是不是太老,5.1.x的驱动对useCursorFetch支持不完整,建议至少用5.1.47以上。
- 多个数据源的情况下,确认你导出用的那个连接池拿到了带参数的新连接。有些连接池启动时就建好了连接,你光改连接串不重启,实际连接还是旧的。
- MyBatis的Cursor方法,如果同时开了二级缓存,可能会先把结果缓存起来,等于流式失效。导出用的Mapper建议select标签上加上 useCache=false。
3. 写出端:CSV文件生成别忽略这些细节
3.1 用缓冲流攒批写出,别一条一条写
写CSV本身不复杂,但很多人也会在这里踩坑。最朴素的写法是拿到一条数据就往FileWriter里write一行,如果是千万级数据,会产生上千万次系统调用和字符编码操作,性能非常感人。
正确做法是用BufferedWriter,设置一个合理的缓冲区,比如32KB或者64KB,攒一批再flush到磁盘。我常用的写法是:
try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(new FileOutputStream(targetFile), StandardCharsets.UTF_8), 64 * 1024)) { writer.write('\ufeff'); // 写UTF-8 BOM,避免Excel打开乱码 for (...) { String line = buildCsvLine(data); writer.write(line); writer.newLine(); if (行数 % 10000 == 0) { writer.flush(); // 定期flush,防止数据积压在缓冲区 } } }每攒够一万行flush一次,是为了防止IO缓冲区长期不落盘,万一中途报错,已处理的部分能及时保存,排查问题也方便。
3.2 手动拼CSV还是用OpenCSV
如果你的字段都是纯数字、纯英文字符,不包含逗号、引号、换行,那手写拼接就够了。但现实里的导出数据经常有用户备注、地址这种自由文本,里面各种逗号、双引号、换行都可能出现。
CSV格式虽然简单,但转义规则还是有的:字段里如果包含逗号、双引号或换行符,整个字段要用双引号包起来,字段内部的双引号要写成两个双引号转义。自己手写容易漏,所以更建议直接用OpenCSV的CSVWriter。
import com.opencsv.CSVWriterBuilder; import com.opencsv.ICSVWriter; try (ICSVWriter csvWriter = new CSVWriterBuilder(writer) .withSeparator(',') .withQuoteChar('"') .withEscapeChar('"') .withLineEnd("\r\n") .build()) { csvWriter.writeNext(headers, false); for (...) { csvWriter.writeNext(new String[]{orderId, customerName, remark}, false); if (行数 % 10000 == 0) { csvWriter.flush(); } } }OpenCSV的writeNext会帮你处理转义,你只需要老老实实传字段数组就行。但要注意,OpenCSV底层也是基于Writer的,缓冲区和flush策略仍然要自己控制。
3.3 两个必踩的坑:Excel乱码和CSV注入
第一个坑是编码。用UTF-8直接生成的CSV,用记事本或某些老工具打开没问题,但用Excel双击打开经常会乱码。解决办法就是在文件最开头写一个UTF-8的BOM,也就是0xFEFF。上面代码里的 writer.write('\ufeff') 就是干这个的。加上之后,Excel才能正确识别这是UTF-8编码的文件。
第二个坑是CSV注入,现在很多安全扫描会盯这个。如果某个字段的内容以 =、+、-、@ 开头,Excel打开时会把这段内容当作公式执行,轻则弹警告,重则可能被恶意构造出执行系统命令的公式。稳妥的做法是,对这类字段做一次特殊处理:如果字符串以这些危险字符开头,就在前面加一个单引号或者TAB字符,让Excel把它们当普通文本显示。
private String safeCell(String value) { if (value != null && value.matches("^[=+\\-@].*")) { return "'" + value; } return value; }4. 完整实现:一个可落地的千万级导出流程
4.1 后端核心代码
下面给一个相对完整的示例。用Excel表格数据作为例子可能不太直观,但原理是一样的。我从一个订单表里导出符合时间条件的订单,忽略订单明细,只导出主表字段。
public void exportOrders(HttpServletResponse response, LocalDateTime start, LocalDateTime end) throws IOException { // 设置响应头 response.setContentType("text/csv;charset=UTF-8"); String fileName = URLEncoder.encode("订单导出_" + start.toLocalDate(), "UTF-8").replace("+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + fileName + ".csv"); // 获取连接,开启游标查询 try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement( "SELECT id, order_no, customer_name, amount, create_time FROM big_order WHERE create_time BETWEEN ? AND ?", ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { ps.setFetchSize(10000); ps.setTimestamp(1, Timestamp.valueOf(start)); ps.setTimestamp(2, Timestamp.valueOf(end)); try (ResultSet rs = ps.executeQuery(); BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(response.getOutputStream(), StandardCharsets.UTF_8), 64 * 1024); ICSVWriter csvWriter = new CSVWriterBuilder(writer) .withSeparator(',') .withQuoteChar('"') .withEscapeChar('"') .withLineEnd("\r\n") .build()) { writer.write('\ufeff'); csvWriter.writeNext(new String[]{"订单ID", "订单号", "客户名称", "金额", "创建时间"}, false); int count = 0; while (rs.next()) { String[] row = new String[]{ rs.getString("id"), rs.getString("order_no"), safeCell(rs.getString("customer_name")), rs.getBigDecimal("amount").toPlainString(), rs.getTimestamp("create_time").toLocalDateTime().toString() }; csvWriter.writeNext(row, false); count++; if (count % 10000 == 0) { csvWriter.flush(); } } } } }这个写法有几个关键点:
- PreparedStatement创建时指定 ResultSet.TYPE_FORWARD_ONLY 和 ResultSet.CONCUR_READ_ONLY,这是安全使用游标的前提。
- 设置FetchSize为10000,让MySQL分批返回数据。
- CSVWriter和BufferedWriter包在一起,flush的时候会逐层刷到输出流。
- 金额字段用toPlainString()而不是toString(),避免BigDecimal输出成科学计数法。
4.2 为什么不用异步线程池+临时文件
网上能看到很多方案是把导出任务丢到线程池,先生成临时文件,再让用户下载。这个方案不是不行,但对于报表导出这种常规需求,复杂度高了不少。你需要处理任务状态、临时文件的清理、用户等待的交互逻辑、并发导出时磁盘空间的管理。
我个人的做法是优先用同步导出。只要数据量在几百万到一千万条这个区间,流式写出的耗时一般在几十秒内,前端配合Loading提示,用户是可接受的。只有当导出量达到几千万甚至上亿条,或者单次导出就要跑好几分钟的时候,同步接口容易把网关的响应超时打满,那时候再考虑异步任务+下载链接才对。
4.3 前端配合:别把响应整个加载进内存
如果你用Axios去请求导出接口,默认行为是把响应体整体读进内存再回调处理。一个1G的CSV文件,浏览器内存也得跟着涨,万一多次导出,页面直接卡死。
最简单可靠的方式是直接用浏览器原生下载能力,别绕一层Ajax。设置好Content-Disposition响应头后,用window.open或者一个隐藏的a标签点击触发下载就行。这样文件是流式落到磁盘的,浏览器内存不会跟着爆。
如果一定要用Axios做带上认证信息的下载,记得在响应拦截器里把responseType设为'blob',然后手动创建一个ObjectURL来触发下载,同时要注意在下载结束后手动revokeObjectURL释放掉浏览器里的Blob内存。
5. 实战中的常见问题和排查技巧
5.1 导出到一半连接超时
尤其是MySQL,如果数据量大,SQL执行加遍历可能超过几十秒,连接池里默认的connectionTimeout或socketTimeout可能就触发了。常见报错是 Communications link failure 或者 Socket read timed out。
处理方法分两种:如果用的是Druid或HikariCP,调大connectionTimeout和socketTimeout只能覆盖一部分场景;更根本的是给数据库服务器和连接池都留足时间。我的建议是,导出专用的查询连接把socketTimeout设到5分钟以上,但主业务连接池的配置不要动,避免长事务拖垮正常业务。
5.2 内存确实不溢出了,但GC压力很大
即使改成游标查询,如果有人在高并发下多次同时点导出,每个请求都占用一个游标连接,数据一批批地加载,GC还是会有压力。解决办法是把导出接口做简单限流,比如单用户同时只能有一个导出任务,全局限流控制在两三个并发以内。这是业务层面的自我保护,尤其是在生产环境核心库上执行大查询,本就应该谨慎。
5.3 MySQL里查询本身很慢,导出时间翻倍
数据量大时,就算游标搞定内存,SQL执行计划如果有问题,照样让你等到怀疑人生。比如 where 条件里的时间字段没索引,或者查询里join了大表,全表扫描加临时表,出口流量再大也没用。
我一般处理方式是,导出用的SQL单独做优化:只查询需要的字段,不select *;where条件使用有索引的字段,最好是覆盖索引;如果表数据量太大,可以考虑按ID范围分片查询,把一个大SQL拆成多个小SQL,配合游标使用。拆分的逻辑可以参考每片十万条ID区间,多线程并行取数,性能还能再上一个台阶。
5.4 Excel提示格式不正确或打开后数字变成科学计数法
这个见到太多次了。第一个原因是你设的响应Content-Type是application/octet-stream或者text/html,浏览器可能直接下载了一个普通文件,Excel打开时识别异常。正确格式需是text/csv。
第二个原因是字段太长,比如订单号、身份证号这种超过15位的数字,Excel打开默认转成科学计数法,看着像数据丢了,其实是显示问题。规避办法有两种:要么导出时在字段前加一个制表符“\t”,强制Excel按文本识别;要么在字段前加单引号,让Excel当作文本。但要注意,这样处理后数据源本身要保持干净,别真把制表符写进数据库了。
最后再分享一个小技巧
上面这套方案,后来我在项目里统一封装成了一个StreamExporter工具类。导出时只要传入数据源查询函数和字段映射配置,内部统一处理游标、转义、BOM、flush这些杂乱细节。碰到新的导出需求,几十行代码就能接完,已经稳定跑了几十次全量数据导出。
在整个排查过程中,我最深的感受是,千万级导出的难点从来不是那个导出的动作本身,而是你愿不愿意改变数据的运输方式。哪怕只是一个导出功能,只要数据量一上来,很多问题就会从角落冒出来。希望这篇分享能帮到你,也欢迎在实际动手时多试多调。
本文还有配套的精品资源,点击获取