有一次线上告警把我从半夜三更拽起来,一看监控面板,老牌的报表导出接口直接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. 代码怎么落地:从查询到写出,把每一步都做扎实
思路理清了,接下来上实操代码。我尽量把关键配置和细节写全,方便你直接参考改造。
3.1 MySQL流式查询的正确姿势
先看游标式流式查询的完整示例,基于Spring Boot + MyBatis:
java复制@Mapper
public interface OrderMapper {
// 注意:这个方法必须使用流式游标
void scanOrdersForExport(@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
ResultHandler<OrderExportDO> handler);
}
Mapper XML里的SQL不需要做特殊处理,就是正常查询:
xml复制<select id="scanOrdersForExport" resultType="com.example.export.OrderExportDO">
SELECT id, order_no, user_id, amount, status, create_time
FROM t_order
WHERE create_time BETWEEN #{startTime} AND #{endTime}
ORDER BY id
</select>
关键在调用方。MyBatis的ResultHandler回调模式天然适配流式导出,逐行回调,不会把结果集堆积在List里:
java复制public void exportOrders(LocalDateTime start, LocalDateTime end, OutputStream outputStream) {
// 必须拿到原生连接来做流式配置
SqlSessionTemplate sqlSessionTemplate = ...; // 注入
SqlSession sqlSession = sqlSessionTemplate.getSqlSessionFactory().openSession();
try {
// 关键:关闭自动提交,否则流式读取不生效
Connection connection = sqlSession.getConnection();
connection.setAutoCommit(false);
Statement statement = connection.createStatement(
ResultSet.TYPE_FORWARD_ONLY,
ResultSet.CONCUR_READ_ONLY
);
statement.setFetchSize(Integer.MIN_VALUE); // MySQL流式模式生效的开关
// 通过MyBatis的ResultHandler逐行回调写出
OrderMapper mapper = sqlSession.getMapper(OrderMapper.class);
AtomicLong rowCount = new AtomicLong(0);
mapper.scanOrdersForExport(start, end, context -> {
OrderExportDO order = context.getResultObject();
// 这里每来一行就写出,不积压
writeOneRowToCsv(order, outputStream);
rowCount.incrementAndGet();
});
outputStream.flush();
log.info("导出完成,共导出{}行", rowCount.get());
} finally {
sqlSession.close();
}
}
如果用原生的JDBC,就更好理解了:
java复制String sql = "SELECT ... FROM t_order WHERE create_time BETWEEN ? AND ? ORDER BY id";
PreparedStatement ps = connection.prepareStatement(
sql,
ResultSet.TYPE_FORWARD_ONLY,
ResultSet.CONCUR_READ_ONLY
);
ps.setFetchSize(Integer.MIN_VALUE);
connection.setAutoCommit(false);
ResultSet rs = ps.executeQuery();
while (rs.next()) {
String orderNo = rs.getString("order_no");
// 逐行处理
}
注意:
Integer.MIN_VALUE是MySQL JDBC驱动约定好的特殊值,表示“流式读取”。改成别的正数并配合useCursorFetch=true理论上也可以,但我在5.x和8.x驱动上都实测过,还是MIN_VALUE这套最稳,不会出现部分版本驱动行为不一致的问题。
分页查询方案也贴一下,适合实在没法改游标的场景:
java复制int pageSize = 5000;
long lastId = 0L;
boolean hasMore = true;
while (hasMore) {
// 用ID大于上一批最大ID的方式翻页,避免深分页OFFSET性能问题
List<OrderExportDO> page = orderMapper.selectOrdersAfterId(lastId, pageSize);
if (page.isEmpty()) {
hasMore = false;
break;
}
// 每批数据直接写出,写完后这批对象就可以被GC回收
for (OrderExportDO order : page) {
writeOneRowToCsv(order, outputStream);
}
outputStream.flush();
lastId = page.get(page.size() - 1).getId();
}
这种ID分页方式比LIMIT/OFFSET稳定很多,数据量越大优势越明显。
3.2 Excel百万行导出:从XSSFWorkbook到SXSSFWorkbook再到EasyExcel
如果一定要导出真正的xlsx文件,千万别用XSSFWorkbook硬扛。POI在3.8之后的SXSSFWorkbook就是为了解决这个问题设计的:
java复制// 窗口大小200行,超过这个数量的行会被写入磁盘临时文件
SXSSFWorkbook workbook = new SXSSFWorkbook(200);
workbook.setCompressTempFiles(true); // 临时文件压缩,磁盘占用更小
SXSSFSheet sheet = workbook.createSheet("订单数据");
// 表头
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("订单号");
// 逐行写入
int rowNo = 1;
try (ResultSet rs = executeStreamQuery(...)) {
while (rs.next()) {
Row row = sheet.createRow(rowNo++);
row.createCell(0).setCellValue(rs.getString("order_no"));
// 注意事项:SXSSF临时文件只在workbook.close()时自动清理
}
}
workbook.write(outputStream);
workbook.dispose(); // 尽早释放临时文件
workbook.close();
不过如果让我现在选,直接用EasyExcel更省心。它底层虽然也是POI的SXSSF,但API封装得更好,写起来基本不太需要考虑内存问题:
java复制public void exportExcel(OrderQuery query, HttpServletResponse response) {
String fileName = "order_export_" + System.currentTimeMillis() + ".xlsx";
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("UTF-8");
response.setHeader("Content-Disposition",
"attachment;filename=" + URLEncoder.encode(fileName, "UTF-8"));
EasyExcel.write(response.getOutputStream(), OrderExportDO.class)
.sheet("订单数据")
.useDefaultStyle()
// 最关键的一步:大数据量下必须开inMemory模式,否则默认使用文件缓存
// 但注意excel的xlsx本身就会写临时文件,这里默认就是流式
.doWrite(() -> pageQueryOrders(query));
}
说句大实话,EasyExcel的doWrite接收一个com.alibaba.excel.context.AnalysisContext不直接支持流式读取,所以我的做法是自己写一个PageQueryExecutor分批查询,每次查询5000条喂给EasyExcel,它内部逐行写入Sheet,内存占用始终可控。这块后面在第3.4节把完整代码补上。
提示:EasyExcel导出xlsx时,虽然对内存做了大量优化,但xlsx格式本身有较大的结构开销,百万行文件可能需要几十MB到上百MB内存,这点是比CSV差的。能用CSV解决的问题,不必执着于Excel。
3.3 更干脆的方案:CSV流式导出
CSV方案是我个人在实际项目中用到最多的,尤其是面对“百万级以上”数据时。Java标准库就能搞定,不需要额外依赖:
java复制public void exportCsv(OrderQuery query, OutputStream outputStream) throws IOException {
// 注意:如果响应给前端,需要使用UTF-8 BOM,否则Excel打开中文会乱码
outputStream.write(0xEF);
outputStream.write(0xBB);
outputStream.write(0xBF);
try (BufferedWriter writer = new BufferedWriter(
new OutputStreamWriter(outputStream, StandardCharsets.UTF_8), 8192 * 4)) {
// 表头
writer.write("订单号,用户ID,金额,状态,下单时间");
writer.newLine();
// 分页批次查询,每次5000条,在回调中逐行写出
long lastId = 0;
int batchSize = 5000;
List<OrderExportDO> page;
do {
page = orderMapper.selectOrdersAfterId(lastId, batchSize);
for (OrderExportDO order : page) {
writer.write(escapeCsvField(order.getOrderNo()));
writer.write(',');
writer.write(escapeCsvField(String.valueOf(order.getUserId())));
// ... 其他字段
writer.newLine();
}
writer.flush(); // 触发底层缓冲区写入网络或磁盘
lastId = page.get(page.size() - 1).getId();
} while (page.size() == batchSize);
}
}
private String escapeCsvField(String value) {
if (value == null) return "";
if (value.contains(",") || value.contains("\"") || value.contains("\n")) {
// 双引号转义
return "\"" + value.replace("\"", "\"\"") + "\"";
}
return value;
}
这套方案跑下来,百万行CSV导出,堆内存增量基本在20MB以内,大部分时间都在等IO。我给这个方案配了一个1GB堆的容器,压测150万行,GC完全没压力。
3.4 EasyExcel与分页查询结合:完整示例
EasyExcel虽然名叫“Easy”,但很多人用的时候还是习惯把所有数据加载到内存再一次性doWrite。正确的流式+分页写法是这样的:
java复制public class StreamExportService {
private final OrderMapper orderMapper;
@Autowired
public StreamExportService(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
public void exportOrderExcel(Long maxId, OutputStream out) {
// 使用EasyExcel的WriteSheet,每批写入后清空list,避免累积
ExcelWriter excelWriter = EasyExcel.write(out, OrderExportDO.class).build();
WriteSheet writeSheet = EasyExcel.writerSheet("订单数据").build();
long lastId = 0L;
int batchSize = 10000;
boolean hasMore = true;
while (hasMore) {
List<OrderExportDO> batch = orderMapper.selectRangeAfterId(lastId, batchSize);
if (batch.isEmpty()) {
hasMore = false;
break;
}
// 一批一批写入,每次写入后这批list就可以被回收
excelWriter.write(batch, writeSheet);
lastId = batch.get(batch.size() - 1).getId();
if (batch.size() < batchSize) {
hasMore = false;
}
}
excelWriter.finish();
}
}
有几个细节需要说明:
batchSize不是越大越好,我实测过5000到20000之间比较合适。太大会让单批数据在内存中停留时间变长;太小会增加查询次数和网络往返。- 每批写完建议统计一下累计行数,方便做进度展示。
- 如果要在同一个Excel里写多个Sheet,每写完一个Sheet就调用一次
excelWriter.write,最终再finish。
这种写法的内存画像非常好看:主导出线程的Young Gen会周期性地因为一批数据而增长,然后立刻被GC回收,Old Gen几乎没有明显变化。
3.5 线程池与服务层设计:别让导出炸了核心业务
对于导出这种重型操作,我强烈建议走异步任务 + 独立线程池。下面给一个简化版设计:
java复制@Component
public class ExportTaskManager {
private final ThreadPoolTaskExecutor exportExecutor;
public ExportTaskManager() {
exportExecutor = new ThreadPoolTaskExecutor();
exportExecutor.setCorePoolSize(2);
exportExecutor.setMaxPoolSize(4);
exportExecutor.setQueueCapacity(10);
exportExecutor.setThreadNamePrefix("export-worker-");
exportExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
exportExecutor.initialize();
}
public void submitExport(Long taskId, ExportRequest request) {
exportExecutor.execute(() -> {
try {
// 1. 更新任务状态为“处理中”
// 2. 调用流式导出逻辑,把文件写入临时目录
// 3. 上传文件到OSS或转为可下载的临时URL
// 4. 更新任务状态为“完成”
} catch (Exception e) {
// 记录失败原因,更新任务状态
log.error("导出任务{}执行失败", taskId, e);
}
});
}
}
这里的核心是:一旦任务提交成功,HTTP请求立即返回任务ID,前端拿任务ID轮询进度。这样哪怕导出一千万行数据要跑几分钟,也不会占用HTTP连接,更不会因为页面刷新导致导出中断。
对于临时文件,我习惯用File.createTempFile写到系统临时目录,导出完成后如果文件不大,直接返回一个带签名的下载URL;如果文件特别大,就转存到对象存储(OSS/MinIO),再给下载链接。同时配一个定时清理任务,把超过24小时的临时文件删掉。
4. 线上OOM如何快速定位:从dump到MAT,一套可以照抄的排查方法
就算前置方案做得再好,也难免遇到历史代码或者第三方系统突然OOM。学会快速定位OOM,是每个后端的基本功。这一节我结合前段时间一个线上事故,讲讲完整的排查链路。
4.1 先保住现场:OOM瞬间该做的几件事
线上出现OOM时,最忌讳的就是手忙脚乱重启。重启虽然恢复了服务,但也把最有价值的现场证据给毁了。我现在的标准动作是:
- JVM参数里提前加上
-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath=/data/dumps/,这会让JVM在OOM瞬间自动生成堆转储文件。 - 如果没有预置参数,而进程还没死透,用
jmap -dump:format=b,file=/data/dumps/oom.hprof <pid>手动导一份。 - 用
jstat -gcutil <pid> 1000 10连续观察几次GC情况,看看是Old区满了还是Metaspace满了。 - 如果是容器环境,先确认这是JVM堆OOM还是容器内存OOM,
dmesg -T | tail -20能看到OOM Killer的痕迹。
提示:
HeapDumpOnOutOfMemoryError参数一定要提前加。线上环境谁也不能保证不OOM,这个参数就是你的保险。等出了问题再去jmap往往来不及。
拿到dump文件后,我一般用MAT(Eclipse Memory Analyzer)来分析。内存回报没问题的话,也可以用命令行版本的jhat,但MAT的交互体验好太多,强烈推荐。
4.2 一份dump日志的完整解读过程
上个月的线上事故过程是:用户反馈某个报表导出很慢,随后告警群推送“服务内存使用率超过90%”,再然后进程被OOM Killer杀掉。
运维把自动生成的dump文件拉到本地后,我先看MAT的Overview -> Biggest Objects。那次排第一的是一个char[],占了1.8GB。继续看Dominator Tree,发现这个巨大的char[]是被一个StringBuilder引用的,这个StringBuilder出现在某个物流面单导出的工具类里。
顺着引用链往下找,代码逻辑是先把所有物流面单号拼接成一个超大字符串,再一次性写出。数据量从平时的10万涨到80万之后,这个字符串从几百MB直接飙到快2GB,加上堆里其他对象,瞬间触发OOM。
定位具体代码后,修复方案很简单:拆成循环写入,每写完一万条就清空一次StringBuilder并flush。一行核心代码的问题,排查过程却花了大半天,原因就是第一次看MAT时被“最大对象”牵引着走,忽略了最底层的调用链。
看完对象后,我还会看两个视图确认GC状态:
Histogram:按类统计对象实例数和占用内存,看一下是否有某个自定义DTO实例数量异常庞大。Thread Overview:看看OOM瞬间有哪些线程在跑,很多时候能直接看到正在执行导出SQL的线程,再结合SQL日志就能锁定查询。
4.3 定位后的几种典型结论与处置
我在不同项目里遇到过下面几种OOM,处置方案各不相同:
老年代持续增长型 OOM。表现为Old区被数据对象或缓存占满。优先查是否有大List、Map缓存未清理,以及导出、批处理任务是否全量装载。处置:改为分批/流式,必要时给缓存加容量上限和过期策略。
GC Overhead Limit Exceeded型。JVM提示GC overhead limit exceeded,说明GC频繁但回收效果极差,大量时间花在GC上。这类问题往往是系统创建了海量小对象,或者堆设置太小。处置:堆大小要合理,但更要看谁在制造海量对象。用-XX:+PrintGCDetails看GC日志,如果执行完GC堆还是满的,大概率是集合引用了大对象没释放。
Metaspace/直接内存溢出型。堆内存监控正常,但进程被系统杀掉,或者报OutOfMemoryError: Direct buffer memory。导出场景中,NIO的ByteBuffer用得太多、或者POI临时文件读写没关流都可能遇到。处置:检查-XX:MaxDirectMemorySize,排查直接内存分配逻辑,关闭未使用资源。
线下无法复现型。最好处理也最难处理。我一般会在测试环境用JVM参数调小堆内存(比如-Xmx256m)来复现大数据量导出的内存消耗,压测脚本把并发拉上去后基本能复现。
5. 常见问题与避坑经验
这一节把我这些年踩过的、别人踩过跟我反馈过的问题做个汇总。每一件都有真实依据,不是凭空想象。
5.1 问题速查表
| 现象 | 根因 | 解决方案 |
|---|---|---|
| MySQL大数据量查询OOM | JDBC默认一次性全量加载结果集 | 使用流式查询(fetchSize=MIN_VALUE),或改成ID分批查询 |
| 导出的xlsx文件超大OOM | XSSFWorkbook DOM模型驻留内存 | 换SXSSFWorkbook或EasyExcel |
| CSV导出中文乱码 | 没有写入UTF-8 BOM头 | 写入流最前面加3个字节:EF BB BF |
| 导出期间有数据重复/遗漏 | 分页没有稳定排序,或用OFFSET深分页 | 用ID游标翻页,加ORDER BY id |
| 导出接口把Tomcat线程池占满 | 长任务直接跑在HTTP线程里 | 改异步任务+独立线程池+轮询状态 |
| 导出进程被系统杀掉但没OOM日志 | 容器内存超限,被K8s/docker杀掉 | 容器内存要包含JVM堆+Metaspace+线程栈+直接内存,留足余量 |
| 导出一大就频繁Full GC | 每批数据在内存中残留太久 | 缩小批次大小,及时释放对象引用,刷盘后clear集合 |
| 多个用户同时导出互相拖累 | 共享线程池无隔离 | 导出单独线程池,限制并发数,排队超出直接拒绝并提示 |
| 本地测试没问题,线上就OOM | 数据量差异+并发差异 | 压测造数到百万级别,调低内存复现 |
| 表头格式五花八门,Excel打不开 | 流式写入中断导致文件损坏 | 确保finish()执行,异常时也清理资源,并用try-with-resources |
5.2 根据经验我还会做的几件事
第一件事:给所有导出接口设一个行数上限并提前提示。不是所有时候都要导百万行,很多业务需求其实是“最近三个月所有订单”,但你不知道用户会不会选五年。我在Service层加了一个校验:超过200万行会明确提示“请缩小时间范围”,避免有人一次性把全表导出去。
第二件事:导出文件尽量放到对象存储或临时目录,不要直接从内存写HTTP流。尤其是大文件,写入过程中一旦客户端断开,服务端会拿到一个broken pipe,文件数据白算。先把文件写完整,再给下载链接,可靠性高很多。
第三件事:GC日志和监控一定要配齐。-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/logs/gc.log,配合Prometheus/Grafana看GC曲线,能提前发现内存异常增长,而不是等OOM了才去翻dump。平时多看一眼GC曲线,比什么都强。
第四件事:每个批量任务都做游标式消耗,不积压大集合。这些不只是导出场景,任何批处理(对账、报表、迁移)都适用。核心心法就是重复那句话:边读边写、分批处理、及时释放。
6. 我的最终体会
数据导出这类功能,看起来简单,就是“查出来、写出去”,但一旦数据量级上来,背后考验的是对整个内存模型、IO模型和并发模型的理解。我从最初的无脑全量查询+POI内存写,到现在全链路流式化,最大的转变不是学会了某个API,而是脑子里始终有一根弦:任何时候都要问一句“这一批数据现在在内存里占了多少,什么时候能被回收”。
如果你现在正被百万级导出的OOM折磨,我的建议是先别急着加内存。把查询改成流式或分批,把写入改成EasyExcel或CSV流式,把导出任务丢到独立线程池,然后观察内存曲线,这一套下来绝大多数场景都能解决。如果还OOM,再动手分析dump,而不是盲目加-Xmx,那只是把爆炸点往后推迟了一点点。
最后分享一个压箱底的小技巧:做导出功能时,把JVM参数临时调成-Xmx256m,再用脚本生成全量数据去压测。如果你的方案在256MB堆里能完成100万行导出,那放到生产环境的4GB堆里,基本不会有任何内存问题。这个方法我用了很多年,比任何理论分析都直观有效。
