1. 一次线上导出 OOM:事故现场与根因分析
打开服务端的异常日志,最让人心里一沉的就是这行:
java.lang.OutOfMemoryError: Java heap space如果这行出现在导出功能里,那基本可以断定:大量数据被一股脑加载进了堆内存,GC 回收不过来,JVM 最终放弃抵抗。我接手这个导出模块时,线上已经连续崩了三次,用户导一次百万级订单数据,服务就"消失"几分钟,然后报 502。说白了,就是导出实现图省事,把全表查出来塞进内存再写文件。
1.1 最典型的反面代码
我当时看到的第一版实现,几乎是网上随处可见的写法:
public void exportAll(HttpServletResponse response) { List<Order> list = orderMapper.selectAll(); // 100万+ 条,全量进堆 Workbook workbook = new XSSFWorkbook(); Sheet sheet = workbook.createSheet("orders"); for (int i = 0; i < list.size(); i++) { Row row = sheet.createRow(i); // 每个单元格都 createCell + setCellValue } workbook.write(response.getOutputStream()); }这段代码每一步都在制造内存压力。查询时全量加载到 List,生成 Excel 时在内存里构建完整的单元格树,最后 write 的时候又把整个工作簿对象序列化一次。2GB 堆内存,在 80 万行、每行 15 个字段的场景下就已经开始频繁 Full GC,到 120 万行直接 OOM。
1.2 内存放大效应:数据到底占了多少倍空间
很多人觉得"100 万条数据也就几百兆",这是把数据在磁盘上的大小直接等同于内存占用,实际完全不是一回事。我给一个粗略但贴近真实的估算:
| 环节 | 单条开销 | 100万条总量 | 说明 |
|---|---|---|---|
| 数据库行数据(磁盘/网络) | ~500 B | ~500 MB | 以 20 个字段的订单表为例 |
| ORM 对象(List ) | ~2 KB | ~2 GB | 对象头、字段包装、集合扩容 |
| POI XSSF 单元格树 | ~1 KB/单元格 | 15 GB+ | 每行 15 列就是 1500 万个 Cell 对象 |
这个表格里 POI 的单元格开销尤其致命。XSSF 是基于 DOM 的内存模型,每创建一个 Cell 就有对应的 Java 对象驻留在堆里,1500 万个对象对 JVM 的 GC 压力是毁灭性的。这也是为什么"数据本身不大"却照样 OOM 的核心原因——中间过程的对象放大远比原始数据要多。
1.3 先分清是哪种 OOM
这里我想多说一句:OOM 不是一个"包治百病"的名词,排查前最好先看异常信息到底属于哪种类型。
Java heap space:堆空间耗尽,最常见,导出场景基本都属于这种。GC overhead limit exceeded:GC 一直在回收但效果甚微,JVM 认为"怎么回收都腾不出空间",本质还是堆不够或者对象堆积太快。Direct buffer memory/unable to create native thread:堆外内存或线程耗尽的提示,在导出场景里相对少见,但如果用了 Netty 或大量线程池,也要留意。
日志里明确是Java heap space,那就聚焦堆内存的使用方式:别把所有数据都堆在堆里。
2. 零 OOM 的核心思路:从"全量加载"切换到"流式消费"
问题的答案不是"把堆内存调大",而是改变数据流转的模型。调大堆内存只是把崩溃点往后挪,数据量再涨一截,该 OOM 还是 OOM。
2.1 用一个比喻理解流式处理
把全量导出比作搬家:普通做法是先叫一辆大卡车,把所有家具一次性装上车再开到新家,行李一多车就装不下。流式做法的思路是"传送带"——家具从旧家一件一件挪出来,经过传送带送进新家,全程不需要一个能装下所有家具的仓库。
对应到代码里,就是三句话:
- 数据库侧:用游标/流式查询,逐行或逐批取数据,而不是一次
executeQuery把结果集全部拉回客户端。 - 处理侧:每取到一批就处理一批,处理完就丢弃引用,让对象尽快变成"垃圾"。
- 写出侧:写 Excel 用流式 API,写 CSV 直接按行 append,绝不先构建完整个文件对象再写。
2.2 三条主流实现路径对比
根据自己的数据源和场景,可以从下面三种路径里选:
| 路径 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 流式查询 | JDBCStatement设置TYPE_FORWARD_ONLY+fetchSize,MySQL 配合useCursorFetch=true | 内存占用极低,稳定 | 连接占用时间长,需要保持事务 | 百万级以上、表结构固定 |
| 分页查询 | 循环按 ID/时间范围取批,每批 1000~5000 条 | 实现简单,不需要特殊驱动配置 | 深分页有性能陷阱,需要 order by 保证稳定排序 | 无法游标化时的替代方案 |
| 分批写出 | 配合以上任一读取方式,每批攒够了统一 write 到输出流 | 减少 IO 次数,兼顾性能 | 批次过大反而内存上涨 | 所有场景 |
我在实际项目中,优先使用流式查询;数据库版本或驱动限制导致流式查询不可用时,再退到基于索引的键集分页(keyset pagination),而不是传统 limit/offset 分页。原因很简单:limit/offset 越翻越慢,offset 到几十万之后,数据库的扫描代价会大得离谱。
2.3 流式查询时的一个易错点
流式查询通常要求结果集是"只读、单向滚动"的,即用ResultSet.TYPE_FORWARD_ONLY和ResultSet.CONCUR_READ_ONLY创建 Statement。如果你看到某些框架或者封装好的 Mapper 方法默认用了缓存结果集或双向滚动,流式就会失效,数据还是会被整个拉进内存。所以实现时要确认你拿到的ResultSet确实是活的、按行拉取的,而不是框架帮你一次性 list 出来再包装的。
3. Java + 流式查询 + SXSSFWorkbook 落地细节
理论说完了,直接给一份可以"抄作业"的实现。我把这套方案放在一个 2GB 堆、双核 4GB 内存的测试环境里跑过 300 万行导出,堆水位始终没超过 800MB,GC 次数也完全可控。
3.1 MySQL 侧的 JDBC 配置
MySQL Connector/J 有个历史包袱:默认不启用游标式读取,executeQuery会一口气把客户端结果集拉到 JVM 内存里。必须显式打开:
// jdbc 连接串加参数 // jdbc:mysql://host:3306/db?useCursorFetch=true&defaultFetchSize=1000如果不开useCursorFetch,就算你在代码里设置了fetchSize,MySQL 驱动也只会把它当作"一次性拉多少条到本地",不会真正做服务端游标。这是很多人踩过的坑:代码里写了setFetchSize,内存该爆还是爆,因为驱动压根走的不是流式路径。
3.2 核心导出代码(MySQL + SXSSFWorkbook)
@Component public class StreamingExcelExporter { private static final int BATCH_SIZE = 5000; public void exportOrders(OutputStream outputStream) { // SXSSFWorkbook 的窗口大小:内存里最多保留 100 行,旧行自动刷到临时文件 try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) { Sheet sheet = workbook.createSheet("orders"); writeHeader(sheet); String sql = "SELECT id, order_no, user_id, amount, status, create_time FROM orders"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement( sql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { ps.setFetchSize(BATCH_SIZE); try (ResultSet rs = ps.executeQuery()) { int rowIndex = 1; List<Object[]> batch = new ArrayList<>(BATCH_SIZE); while (rs.next()) { batch.add(new Object[]{ rs.getLong("id"), rs.getString("order_no"), rs.getLong("user_id"), rs.getBigDecimal("amount"), rs.getString("status"), rs.getTimestamp("create_time") }); if (batch.size() >= BATCH_SIZE) { writeBatch(sheet, rowIndex, batch); rowIndex += batch.size(); batch.clear(); } } if (!batch.isEmpty()) { writeBatch(sheet, rowIndex, batch); } } } workbook.write(outputStream); } finally { // SXSSF 的临时文件必须手动清理 // workbook.dispose(); } } }3.3 代码里有几个关键点,逐个解释
第一个是new SXSSFWorkbook(100)。SXSSFWorkbook 是 POI 提供的流式版本,跟 XSSFWorkbook 最大的区别是:它只保留窗口大小(window size)内的行对象在内存里,超过窗口的旧行会自动序列化到磁盘临时文件。窗口设得太小会让磁盘 IO 变频繁,太大则内存回收不彻底。我实测 100~200 是比较均衡的区间。
第二个是fetchSize与批次的配合。fetchSize=5000意思是每次从数据库网络层取 5000 行到驱动缓冲,业务代码里再攒够 5000 行写一次 Sheet。因为 SXSSF 每写一行都可能有内部刷新逻辑,攒批能显著减少不必要的调用。但批次也不是越大越好,5000 行每行 15 个字段的对象数组,撑死也就几个 MB,属于安全区间。
第三个是workbook.dispose()。SXSSFWorkbook 在写出过程中会把旧行刷到系统临时文件,如果只调close()而不调dispose(),临时文件不会清理,长期运行会积累磁盘垃圾。这个坑在文档里写得很轻,但实际线上运行久了就会发现临时目录越来越大。
3.4 如果表里带了大字段怎么办
订单表是常规行数据,但如果表里有很长的备注、JSON、甚至 BLOB/CLOB 字段,内存模型要重新考虑。一个 2MB 的文本字段,在rs.getString()时就产生了 2MB 的字符串对象,5000 条一攒就是 10GB 的风险。这种情况下要做"嵌套流式":外层是结果集的流式游标,遇到大字段不要攒批,立即写盘、立即释放引用,批次大小要动态调小。我的经验是:表里有超过 100KB 的大字段时,批次降到 500 行是比较安全的。
4. 不是所有导出都该用 Excel:CSV 与文件压缩的取舍
做完第一版 Excel 导出后,我又遇到了另一个问题:Excel 2007+ 的单 Sheet 行数上限是 1048576,百万级数据导出的行数已经贴着天花板了。就算 SXSSFWorkbook 不会 OOM,用户拿到的文件也可能因为行数超限而打不开或显示不全。
4.1 Excel 行数上限与多 Sheet 拆分
Excel 的硬性限制必须心里有数:
| 版本 | 单 Sheet 最大行数 | 说明 |
|---|---|---|
| Excel 97-2003 (.xls) | 65,536 | POI 的 HSSF 模型 |
| Excel 2007+ (.xlsx) | 1,048,576 | POI 的 XSSF/SXSSF 模型 |
如果数据量超过 100 万,就得考虑拆成多个 Sheet,每个 Sheet 控制在 50 万行左右,既留出余量,也避免单个 Sheet 打开太慢。多 Sheet 的实现很简单:workbook.createSheet("数据_1")、workbook.createSheet("数据_2"),写满一个就切换下一个。
4.2 用 CSV 绕开 Excel 的内存问题
如果业务方对文件格式没有硬性要求,很多场景我会直接建议导 CSV。CSV 是纯文本,可以逐行写输出流,没有单元格对象,内存占用几乎可以忽略不计。100 万行 CSV 文件也就几十 MB,比 xlsx 小得多,也更容易被各种系统解析。
public void exportOrdersToCsv(OutputStream outputStream) throws IOException { try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8))) { // 写 UTF-8 BOM,否则 Windows 上的 Excel 打开中文会乱码 writer.write('\uFEFF'); writeCsvHeader(writer); try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement( sql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) { ps.setFetchSize(5000); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { writer.write(formatCsvRow(rs)); writer.newLine(); } } } writer.flush(); } }这里有个小细节:OutputStreamWriter不要用UTF-8之外的编码直接写中文,否则 Excel 打开会出现乱码。还是建议明确指定 UTF-8 并加 BOM。另外 CSV 导出在while (rs.next())里逐行写即可,不需要攒批——BufferedWriter 本身已经在内存里做了缓冲。
4.3 大文件压缩与下载体验
数据量到百万级后,文件本身可能上百 MB。我通常会在导出服务外面套一层压缩:
// 直接输出 .zip,用户下载后解压 try (ZipOutputStream zipOut = new ZipOutputStream(response.getOutputStream())) { zipOut.putNextEntry(new ZipEntry("orders.csv")); // 将上面的 CSV 导出写入 zipOut zipOut.closeEntry(); }压缩对文本型数据效果非常好,经常能把 200MB 的 CSV 压到 30MB 以内,下载时长和带宽压力都会小很多。
5. 数据源差异:MySQL、Oracle、StarRocks 等场景的导出注意点
导出代码写完之后,我又在不同数据源上踩了不少差异化的坑,这里单独列一节,按数据源说。
5.1 MySQL:必须开 useCursorFetch,但也要小心事务
前面提过,MySQL 流式查询要useCursorFetch=true。开了游标之后,一个隐含问题是:游标读取期间连接不能归还连接池,否则游标会被打断。因此这段读取代码要么用独立的数据库连接,要么确保整个读取过程处于一个未提交的事务里,读取完成立即关闭连接。绝不能在流式读取过程中去做其他"借连接"的操作,不然极容易死锁或拿不到连接。
5.2 Oracle:fetchSize 默认值小得惊人
Oracle JDBC 驱动的默认 fetchSize 是 10,也就是每 10 行去数据库取一次网络包。如果不设置 fetchSize,百万级数据导出会慢到怀疑人生——每 10 行一次网络往返,光 IO 开销就能把导出时间拉长几倍。所以 Oracle 场景同样要ps.setFetchSize(5000),但不需要像 MySQL 那样额外加连接参数。
5.3 StarRocks/Apache Doris 等 OLAP 引擎:别让 JVM 当搬运工
像 StarRocks、Doris 这类 OLAP 引擎,本身的数据导出能力很强,官方推荐的做法是用SELECT ... INTO OUTFILE或通过 Broker 直接把结果写到 HDFS/S3/对象存储,而不是让应用服务器把数据拉回来再转发。之前遇到一个需求,数据量几千万行,如果按"Java 拉流式结果集再写文件"的方式走,应用节点的带宽和内存开销都很大。改成INTO OUTFILE之后,导出任务完全在引擎内部完成,JVM 只负责触发任务和轮询状态,内存压力直接归零。
5.4 工具类导出的适用边界
有人提到 DBeaver、PL/SQL Developer 这类工具。工具不是不能用,但要看场景:
- DBeaver 自身的导出是流式的,导出百万级数据基本不会撑爆工具自身内存,日常做数据抽取很方便。
- PL/SQL Developer 在 Oracle 场景下导出表结构和数据,适合小表、一次性操作;大表导出时也会在客户端积累数据,不建议作为生产环境的批量导出通道。
- 自己的服务集成导出,还是要考虑内存可控性、权限控制和任务进度记录,这是工具替代不了的。
判断标准就一条:是一次性临时操作,还是用户会反复使用的系统功能。前者用工具没毛病,后者必须走代码方案。
6. 验证"零 OOM"的监控与压测方法
改造完成后,"零 OOM"不是拍脑袋说出来的,要有监控数据和压测结果支撑。我分享一套自己用的验证方法。
6.1 关键监控指标
导出一类任务的监控,重点看三个指标:
- 堆内存使用率:导出过程中堆水位是否呈现"稳定平台"而不是"持续上涨"。
- Full GC 频率:正常流式导出下,Full GC 应该极少甚至为 0,Young GC 可以频繁但不能伴随长时间停顿。
- 活跃对象大小:可以用
jmap -histo:live或 Arthas 看导出期间堆里活跃对象,如果看到大量org.apache.poi.xssf.usermodel.XSSFCell,说明流式 API 没生效。
6.2 压测中的表现
用 2GB 堆跑 300 万行导出,实测结果大概是这样的:
| 指标 | 优化前(全量加载 + XSSFWorkbook) | 优化后(流式查询 + SXSSFWorkbook) |
|---|---|---|
| 堆峰值 | 1.9GB,接近 OOM | ~700MB |
| Full GC 次数 | 8 次,多次长时间停顿 | 0 次 |
| 导出耗时 | 未跑完就 OOM | 约 45 秒 |
| 临时文件 | 无 | 写入 60MB 左右临时文件 |
这里的临时文件是 SXSSFWorkbook 刷出去的,属于正常现象,记得在finally里处理即可。
6.3 压测时最容易翻车的几个边界
有几类边界情况,我在压测阶段反复调整才稳定下来,列出来供参考:
- 并发导出:如果 10 个用户同时触发导出,每个导出占用 700MB 堆,那 2GB 堆照样 OOM。需要加信号量或队列,限制同时执行的任务数,比如最多 3 个并发,其余排队。
- 查询超时:流式查询占用连接时间变长,数据库 wait_timeout 或 JDBC socketTimeout 设置过小,导出中途会断连。建议在导出专用连接上适当调大超时时间。
- 恢复/重试:百万级导出耗时不短,网络闪断、对端关闭等异常要处理好。我习惯把导出过程拆成"生成文件"和"传输文件"两个阶段,先落盘到临时目录,再通过响应流推送,推送失败还能从临时文件重试。
7. 这套思路后来还被我用在哪
按这套方案重构导出模块之后,后续又遇到过几个和"数据大、内存小"相关的新需求,比如定时全量备份、把数据从正式环境同步到测试环境、Kafka 消费链路积压时把内存打满的问题。它们的共性和导出完全一致:数据量一大,只要中间环节有"攒"的动作,内存迟早出问题。
7.1 Kafka 消费场景的同类问题
换到数据链路里看,同一个坑会以不同的面貌出现。我遇到过消费线程把拉取到的消息先在本地 List 里攒一批,攒够 5 万条再统一落库,结果高峰期消息积压,List 越攒越多,最后 OOM。当时修复的办法很朴素:把"攒 5 万条"改成"攒 500 条 + 定时批量落库 + 每批提交位移",本质上就是一个小窗口的流式消费。数据流不被截断、不被无限缓存,内存就安全。
7.2 从导出延伸到同步数据的场景
同样的思路也用在把数据从正式区导出到测试区的场景。之前有人会先把源表全量查出来,内存里转一圈,再插入目标库;换成流式读源表、一批一插入目标库、定期提交事务的方案后,内存峰值下降了 60% 以上。这里面的核心不是具体用什么框架,而是始终提醒自己:数据是流,不是堆。
这几句话看着像心得,其实都是我一次次改代码改出来的血泪教训。现在接手任何大数据量的功能,我都会先画一条数据流,标出每个环节可能滞留的对象,凡是有"全部、一次性、整个"这种字眼的实现,我都会先打个问号。百万级数据导出零 OOM,靠的是让数据像水一样流过程序,而不是靠某个神仙参数。