有一次线上告警把我从半夜三更拽起来,一看监控面板,老牌的报表导出接口直接OutOfMemoryError,服务连续重启了几次才缓过来。当时心里就一个念头:百万级数据的导出,怎么就总是绕不开OOM这个坎?
后来我把这套东西彻底重做了一遍,从查询方式、内存模型、写入方式一路改到异常定位链路,压测到150万行数据导Excel和CSV,堆内存稳定在几百MB级别,全程零OOM、零Full GC抖动。这篇文章就把整套思路和落地代码原原本本写出来,包括中间踩过的坑和排查OOM时用到的工具链。如果你正在折腾数据导出,或者线上已经出现过OOM但不知道从哪下手,这份实操笔记应该对你有直接帮助。
1. 先说清楚:百万级导出为什么会OOM
很多人以为OOM是“数据量太大,内存装不下”,这个理解没错,但不够精确。要解决问题,得先搞清楚内存到底被谁吃掉了。
1.1 一次典型的OOM现场还原
我之前接手过一个导出历史订单的接口,业务方要求能一次性导出某段时间内所有订单,加上明细行数,单次导出规模轻松过百万行。
初版代码的逻辑并不复杂:先从数据库里把全量数据查出来,装进一个List,然后用Apache POI的HSSFWorkbook或者XSSFWorkbook把数据全部写入内存中的Workbook对象,最后一次性输出到HTTP响应流。这套逻辑在几万行数据时跑得很好,等数据量升到几十万、上百万行,问题就全冒出来了。
- 数据库全量查询的结果集被JDBC驱动全部加载到堆内存,一条订单带十几二十个字段,100万行就是几百MB到上GB的对象。
- List里每个Java对象还有对象头、字段引用、集合底层数组的空间浪费,实际占的内存比“数据本身”要大得多。
- POI的XSSFWorkbook会把整个Excel的DOM结构放进内存,每行每列都对应Java对象,百万级行数时内存消耗是数据本身的几倍。
- 多个用户同时导出,或者导出期间还有别的业务流量,堆内存一下就撑爆了。
我当时看dump文件里的支配树(Dominator Tree),排在最前面的就是两个对象:一个是java.util.ArrayList,里面装着上百万个订单DTO;另一个是org.apache.poi.xssf.usermodel.XSSFWorkbook。这两个货加起来占掉了堆里将近80%的空间。所以所谓的百万级导出OOM,本质上不是“数据”占内存,而是“集合容器”和“Excel文档模型”在疯狂吃内存。
1.2 数据导出OOM的三大内存黑洞
结合多个项目的排查经验,导出场景的OOM基本逃不出下面三个内存黑洞:
黑洞一:结果集全量装载。 默认情况下,MySQL JDBC驱动会把查询结果一次性全部读取到客户端内存中,然后才让你通过ResultSet.next()逐行遍历。你以为自己在“一行一行处理”,实际上数据早就全部躺在堆里了。100万条数据、每条2KB,仅仅结果集就是2GB。
注意:MySQL驱动这种“一次性全量拉取”的行为,和Oracle驱动默认的游标式读取完全不同。很多从Oracle转MySQL的团队,在这里会踩同一个坑。
黑洞二:Excel/XLSX文档模型驻留堆内存。 如果用的不是POI的SXSSF或是EasyExcel这类流式写入方案,而是用XSSFWorkbook这种DOM模型,那么Excel里每一个单元格、每一种样式、每一个合并区域都是一个Java对象。百万行乘以20列,就是2000万个Cell对象,这种内存开销非常吓人。
黑洞三:中间过程产生的大量临时对象。 即使是流式处理,如果每条数据都要做复杂的类型转换、正则清洗、对象拷贝,或者用了String.format拼接超长字符串,年轻代会频繁晋升老年代,最终触发Full GC甚至OOM。
所以要根治导出OOM,核心思路就一条:让数据像水管里的水一样流出去,而不是把它全抽到水桶里再倒出去。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心设计思路:全链路流式化,消灭“一次性堆积”
定了这个总基调之后,接下来所有方案都是围绕“流式”两个字展开的。无论数据源是MySQL、Oracle还是文件,整个导出链路都要做到:没有哪个环节会一次性把百万级数据完整放在内存里。
2.1 数据库侧:别一把捞,用游标和流式查询
数据库侧最重要的改造,就是让查询结果不要一次性全量加载到应用内存。MySQL有两种常见做法。
第一种是分页循环查询。比如每次查1万条,查完写入文件,再查下一批。这个方案简单直观,几乎所有ORM框架都支持,但有个隐患:如果表数据在导出期间持续变化,分页可能出现“重复数据”或者“漏数据”。需要根据业务允许程度决定是否加快照读、排序字段是否稳定。
第二种是游标式流式查询。关键配置是这三样:
java复制// 以MyBatis为例,设置以下三个属性
// resultSetType = ResultSet.TYPE_FORWARD_ONLY
// fetchSize = Integer.MIN_VALUE 或明确的小批量值
// connection.setAutoCommit(false)
当fetchSize设置为Integer.MIN_VALUE时,MySQL JDBC驱动会强制使用流式读取,一行一行地从服务端拉取数据,不会把整个结果集缓存到客户端内存。如果设置的是正数,则每次从服务端取对应行数的数据,配合只向前游标,也能实现批量流式效果。
我个人的推荐是:能用游标流式查询就用游标,不能用的时候退而求其次用稳定分页,两种方式后面都会给代码。
2.2 写入侧:别组装整份文件,边写边刷盘
数据库侧流式处理完了,写入侧同样不能把整个Excel或CSV存在内存里再输出。这里有几个选型层次:
- 如果导出的是Excel格式,用
SXSSFWorkbook(POI的流式版本)或EasyExcel。SXSSFWorkbook内部维护一个滑动窗口,只有窗口内的行驻留内存,窗口外的行会被刷到临时文件。EasyExcel是阿里开源的封装,API更简单,也内置了流式读写,大型导出场景下非常稳。 - 如果业务允许,优先导出CSV。CSV本质就是纯文本文件,用
BufferedWriter一行一行写,写完一批就flush(),内存占用几乎可以忽略不计。唯一的注意点是CSV对字段里包含逗号、引号、换行符的情况要做转义处理。
实际项目里我会根据用户需求区分:要求xlsx格式、要带样式、要合并单元格的,走EasyExcel;只要数据、后续要导入其他系统的,走CSV。这两个方案都不需要在内存里维护整份文档结构。
2.3 防止“误伤”:导出任务别拖垮主业务
还有一个很容易被忽略的点:即使导出本身做得再流式,如果是在业务线程池里跑,一个导出请求占着线程不释放,别的接口也会跟着遭殃。
所以设计上要把握好这几点:
- 导出接口统一走独立线程池,核心线程数和队列容量单独规划,避免和普通业务请求争抢线程。
- 导出任务要先创建任务,立刻返回“任务处理中”的状态,让前端轮询进度。连接超时、页面刷新都不能影响后台任务的执行。
- 后台任务监控导出进度,并设置合理的超时时间,异常时做好清理。
这样即使导出过程中真出了问题,炸的也只是导出线程池,不会把用户下单、登录这种核心链路拖下水。
3. 代码怎么落地:从查询到写出,把每一步都做扎实
思路理清了,接下来上实操代码。我尽量把关键配置和细节写全,方便你直接参考改造。
