Java千万级CSV导出优化:从OOM到稳定流式导出
发布时间:2026/9/7 2:40:50 作者:尧图编辑部 阅读量:1,286

简介面向需要处理千万级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。一个MapString, Object哪怕只有十几个字段光对象头、哈希表结构、字符串对象本身的字符数组算下来一条轻松占300到500字节。500万条乘一下就是1.5G到2.5G的内存开销还没算List扩容的余量。而JVM堆撑死就那么大不OOM才怪。就算你硬把堆调大比如用-Xmx4gGC也会被频繁的Full GC拖死。全量对象在堆里长期存活老年代瞬间被打满系统整体就废了。1.2 解决的核心思路数据只“流经”内存不“驻留”内存正确思路是把“查全部再写”改成“边查边写”。数据库游标每次只从服务端拉取一小批数据处理完立刻释放引用让GC能及时回收。文件写出端则用一个缓冲流攒一批数据再flush到磁盘。这里的关键认知是导出流程里数据应该像水管里的水一样流过你的程序而不是像蓄水池一样全灌进来。水管里同一时刻只要保存一小段水就够了。具体到代码就是两个点的配合——读取端用游标逐步取数写出端用缓冲流分批落盘中间不要有List实体这种中间层。2. 读取端实现游标和fetchSize的正确姿势2.1 别再用 List对象 一把梭先看反面教材。很多人写导出代码是这样的先调一个Mapper方法内部执行 select * from tableMyBatis默认把所有结果一次性装进ArrayList返回给你之后你再循环写CSV。这一步看似没什么实际上内存就是在这一下爆掉的。正确的做法是让数据库分批次把结果给你你处理完这一批再要下一批。JDBC本身提供了游标机制设置好fetchSize每次从数据库服务端拉取指定行数到客户端内存处理完这一批客户端内存里的这部分数据就可以被回收了。2.2 JDBC游标方案连接串加两个参数就行用原生JDBC或者Spring的JdbcTemplate其实只需要在数据库连接串上做点文章。以MySQL为例连接URL里加上jdbc:mysql://127.0.0.1:3306/dbname?useCursorFetchtruedefaultFetchSize10000然后把查询语句按正常方式执行拿到ResultSet之后循环遍历。这里的原理是useCursorFetchtrue让MySQL驱动启用服务端游标resultSet不再一次性把所有行都拉到客户端而是每调用next()时按fetchSize指定的大小从服务端分批取数据。我实测下来的经验是fetchSize设成5000到10000比较合理。太小了每批都要和数据库交互一次网络往返次数太多整体性能反而下降太大了单批数据在客户端内存里占用又变高和流式处理的初衷就冲突了。2.3 MyBatis的Cursor方案代码更干净如果你的项目用的是MyBatis更推荐用游标类型作为Mapper方法的返回值。定义接口方法时返回类型写成Cursor 然后通过注解或XML配置fetchSizeSelect(SELECT * FROM big_table WHERE create_time #{start}) Options(fetchSize 10000) CursorOrderDO 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 (CursorOrderDO cursor mapper.scanOrdersByTime(start)) { for (OrderDO order : cursor) { // 在这里把对象转成一行CSV文本立刻写出去 } } }2.4 游标没有生效怎么办很多人配完才发现吃内存还是老样子。先自查这几个点连接串里有没有拼上 useCursorFetchtrue。MySQL驱动默认是不开服务端游标的。MySQL版本和驱动版本是不是太老5.1.x的驱动对useCursorFetch支持不完整建议至少用5.1.47以上。多个数据源的情况下确认你导出用的那个连接池拿到了带参数的新连接。有些连接池启动时就建好了连接你光改连接串不重启实际连接还是旧的。MyBatis的Cursor方法如果同时开了二级缓存可能会先把结果缓存起来等于流式失效。导出用的Mapper建议select标签上加上 useCachefalse。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;charsetUTF-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这些杂乱细节。碰到新的导出需求几十行代码就能接完已经稳定跑了几十次全量数据导出。在整个排查过程中我最深的感受是千万级导出的难点从来不是那个导出的动作本身而是你愿不愿意改变数据的运输方式。哪怕只是一个导出功能只要数据量一上来很多问题就会从角落冒出来。希望这篇分享能帮到你也欢迎在实际动手时多试多调。本文还有配套的精品资源点击获取