夜雨聆风学习资料网

ARTICLE · 1125655

导出 50 万条 Excel 把 2G 堆打爆后,我没加内存,只把 XSSFWorkbook 换掉了

导出 50 万条 Excel 把 2G 堆打爆后,我没加内存,只把 XSSFWorkbook 换掉了

上周踩了个特别典型的坑。运营后台有个订单导出功能,平时导几千条、两三万条都挺顺,直到有一次运营把时间范围直接拉到了半年。

点完“导出”之后接口就没动静了。过了一会儿监控上 JVM 堆内存开始一路往上冲:600MB → 900MB → 1.3GB → 1.7GB → 1.9GB,然后 Full GC 开始频繁刷屏,最后服务直接甩出一句 java.lang.OutOfMemoryError: Java heap space。

第一反应是“一次查了 50 万条订单,内存肯定爆”。但看完代码才发现——这个接口同时干了两件吃内存的事,而且是叠在一起吃的。

01 现场:一次导出把服务打挂的过程

先是接口不返回,然后堆内存被一点点吃干:

LOGheap.log
# Grafana 上看到的堆内存曲线(每 10s 采一次)
[14:22:10] heap used = 612 MB    old gen = 388 MB
[14:22:20] heap used = 918 MB    old gen = 671 MB
[14:22:30] heap used = 1324 MB   old gen = 1102 MB   <- GC 开始变慢
[14:22:40] heap used = 1708 MB   old gen = 1521 MB
[14:22:50] heap used = 1932 MB   old gen = 1804 MB   <- 接近 -Xmx2g
[14:22:58] Full GC (Ergonomics) 耗时 1.842s
[14:23:06] Full GC (Ergonomics) 耗时 1.907s   <- 回收效果开始变差
[14:23:11] Full GC (Ergonomics) 耗时 2.113s
Exception in thread "http-nio-8080-exec-9"
java.lang.OutOfMemoryError: Java heap space
    at org.apache.poi.xssf.streaming.SXSSFSheet.createRow(Unknown Source)

注意看堆内存的形态:它不是抖一下,而是持续单向爬升,GC 收不回去。这种形态基本就一个含义——有对象在被长期持有,而且是越攒越多。

02 先找凶手:一个接口干了两件吃内存的事

当时的代码大概是这样:

JavaOrderExportController.java(出事版本)
@GetMapping("/orders/export")
publicvoidexport(OrderQueryquery, HttpServletResponseresponse) throwsIOException {
// 吃内存第一件事: 把符合条件的订单全部捞进 JVM
List<Order> orders = orderService.queryAll(query);
// 吃内存第二件事: 把全部 Excel 行也在 JVM 里构造一遍
XSSFWorkbookworkbook = newXSSFWorkbook();
Sheetsheet = workbook.createSheet("订单");
introwIndex = 0;
Rowheader = sheet.createRow(rowIndex++);
header.createCell(0).setCellValue("订单号");
header.createCell(1).setCellValue("用户");
header.createCell(2).setCellValue("金额");
header.createCell(3).setCellValue("状态");
header.createCell(4).setCellValue("下单时间");
for (Orderorder : orders) {
Rowrow = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(order.getOrderNo());
row.createCell(1).setCellValue(order.getUserName());
row.createCell(2).setCellValue(order.getAmount().doubleValue());
row.createCell(3).setCellValue(order.getStatus().name());
row.createCell(4).setCellValue(order.getCreateTime().toString());
    }
workbook.write(response.getOutputStream());
workbook.close();
}

这段代码本身没有语法错误,逻辑也挑不出毛病。数据只有 5000 条的时候,它甚至算写得挺清楚的。

问题在于它从写下的那一刻就默认了两件事:

  • 数据库结果可以全部放进内存
    ——queryAll() 一把捞;
  • Excel 也可以全部放进内存
    ——XSSFWorkbook 在堆里搭完整个文档才输出。

数据量一上来,这两个假设同时失效,而且不是相加,是相乘。因为一条订单在某一瞬间可能同时存在好几份:

TXTmemory-model.txt
一条订单在堆里同时存在的东西:
    Order Entity        <- 数据库查询结果
      ├─ String  orderNo / userName / status
      ├─ BigDecimal amount
      └─ LocalDateTime createTime
    OrderExportDTO      <- 如果中间还做了转换
    XSSFRow             <- Excel 一行
    XSSFCell × 5        <- 这一行的每个单元格
500000 行 × 上面这一整套 × 十几列
=> 2G 堆被打爆, 一点都不冤枉

遇到 OOM 时,先看堆内存曲线的形态再动手:如果是锯齿形忽高忽低,多半是短命对象太多、GC 参数或分配速率的问题;如果是这种一路向上、GC 收不回来的,那一定是有东西被长期持有了——重点去找“一次性把什么全都装进了集合或文档对象”。

03 第一刀:把 XSSFWorkbook 换成 SXSSFWorkbook

我没有先去调 JVM 参数,也没有简单粗暴地把 -Xmx2g 改成 -Xmx4g。因为那最多只是把“50 万条挂掉”推迟成“100 万条挂掉”,问题本身一个字都没变。

Apache POI 专门提供了一套用于大文件写入的实现:SXSSFWorkbook(Streaming XSSF)。它和 XSSFWorkbook 最大的区别不是 API,而是内存模型。

  • XSSFWorkbook
    :所有行都在堆里,write() 之前整份文档都在内存中;
  • SXSSFWorkbook
    :维护一个滑动窗口,只保留最近写入的有限行数,超出窗口的旧行会被刷到磁盘上的临时文件,所以堆占用是有上限的。

POI 文档里默认窗口是 100 行,可以自己指定。先改成这样:

JavaSXSSFWorkbook.java
// 500 = 允许保留最近 500 行在内存里, 其余的边写边刷出去
try (SXSSFWorkbookworkbook = newSXSSFWorkbook(500)) {
Sheetsheet = workbook.createSheet("订单");
// ... 写数据
workbook.write(response.getOutputStream());
}

内存模型的变化,用图看最直观:

TXTmemory-model.txt
改造前 (XSSFWorkbook):
    第 1 行   ┐
    第 2 行   │
    第 3 行   │
    ...       ├── 全部留在 JVM 堆里, 直到 write() 结束
    第 499999 │
    第 500000 ┘
    堆占用: 随行数线性增长
改造后 (SXSSFWorkbook(500)):
    写第 1~500 行  ->  旧行刷到临时文件
    继续写         ->  继续刷
    继续写         ->  继续刷
    堆占用: 恒定 ≈ 500 行, 与总行数无关

上线一盘,堆内存确实明显下来了。但问题并没有完全解决——因为代码最上面那句List<Order> orders = orderService.queryAll(query) 还在。

04 第二刀:把那个 List 也删掉

Excel 不一次性放内存了,数据库查询结果还在一次性放内存。50 万个 Order Entity 依然稳稳地趴在堆里。

简单算一笔账:假设每个订单对象实际占用 1KB(这已经很保守,真实 Entity 还有关联对象、持久化上下文引用、字符串常量池等额外开销):

TXTestimate.txt
500000 行 × 1KB ≈ 500MB
再加上 Excel 窗口内的行和 POI 自身的对象,
- Xmx2g 的堆里, 光"数据"就吃掉大半
- 剩下给框架、线程栈、其他请求的空间就没多少了

最简单的解法就是分批:不是一次查 50 万,而是每次查 2000 条,写完一批再去查下一批。

为什么不推荐 LIMIT / OFFSET 翻页?

很多人的第一反应是 page = 0, 1, 2 ... 配 LIMIT 2000 OFFSET 400000。但大数据导出本身是一个持续向后扫描的过程,用 OFFSET 翻到后面时,数据库每次都要先扫过前面那几十万行再丢掉——页码越大越慢,而且数据还在变的情况下会漏行、重复行。

数据既然只往前走,就该用 ID 做游标(keyset pagination):

SQLselect_batch.sql
SELECT
    id, order_no, user_name, amount, status, create_time
FROM orders
WHERE id < :lastId                    -- 游标: 只取比上一批最后一条更小的 id
  AND create_time >= :startTime
  AND create_time <  :endTime
ORDER BY id DESC
LIMIT 2000;                           -- 固定批次大小, 不做 OFFSET

第一批传 lastId = Long.MAX_VALUE,假设拿到的最小 id 是 9832101,下一批就变成 WHERE id < 9832101——每一批都是走索引的一次范围扫描,代价恒定。

循环写进 Excel 就是这样:

JavaOrderExportService.java(分批循环)
privatestaticfinalintBATCH_SIZE = 2000;
longlastId = Long.MAX_VALUE;
while (true) {
List<OrderExportDTO> batch = repository.findExportBatch(
query, lastId, BATCH_SIZE);
if (batch.isEmpty()) {
break;                       // 没有数据了, 结束
    }
for (OrderExportDTOorder : batch) {
writeRow(sheet, rowIndex++, order, styles);
    }
// 游标前移: 用这一批的最后一条 id 继续往小里找
lastId = batch.get(batch.size() - 1).id();
}

05 顺便把查询字段也删了:只取导出真正需要的列

既然都改到这一层了,顺手把 Order 实体换成导出专用的 DTO。因为 Excel 需要的只有订单号、用户、金额、状态、时间——完全没必要把 remark、address、version、updateTime、关联实体全部加载进来。

用 Java 的 record 最省事:

JavaOrderExportDTO.java
publicrecordOrderExportDTO(
Longid,
StringorderNo,
StringuserName,
BigDecimalamount,
Stringstatus,
LocalDateTimecreateTime) {
}

Repository 里用 JPQL 的构造器表达式直接投影,数据库只查这几列,应用层也不再生成完整实体:

JavaOrderRepository.java
@Query("""
selectnewcom.demo.export.OrderExportDTO(
o.id, o.orderNo, o.userName, o.amount, o.status, o.createTime)
fromOrdero
whereo.id < :lastId
ando.createTime >= :startTime
ando.createTime <  :endTime
orderbyo.iddesc
""")
List<OrderExportDTO> findExportBatch(
@Param("lastId") longlastId,
@Param("startTime") LocalDateTimestartTime,
@Param("endTime") LocalDateTimeendTime,
Pageablepageable);

**不要用 select * + 实体映射来做导出。导出场景下,字段越多,每批 2000 条的内存和网络开销就越大;而且实体一旦进了持久化上下文,还会带来脏检查、一级缓存等额外成本。导出用只读 DTO 投影,是性价比最高的一刀。**

06 第三个坑:有些写法会让“流式 Excel”重新变重

换完 SXSSF 之后不要以为就万事大吉了。很多代码为了让表格好看,会写一些在几千行时完全没问题、在 50 万行时非常致命的东西。

坑一:对每一列都调 autoSizeColumn

JavaAntiPattern.java
// 数据小的时候没什么, 50 万行之后最好别这么干
for (inti = 0; i < 20; i++) {
sheet.autoSizeColumn(i);
}

autoSizeColumn 需要扫描这一列所有已写入的行来算最大宽度。在流式写入模式下,已经刷到临时文件的行要么算不到、要么得重新读回来,结果是既慢又可能算错。固定列宽就够了:

JavaColumnWidth.java
// 固定列宽, 一次性设置, 单位是 1/256 个字符宽
sheet.setColumnWidth(0, 22 * 256);   // 订单号
sheet.setColumnWidth(1, 18 * 256);   // 用户
sheet.setColumnWidth(2, 15 * 256);   // 金额
sheet.setColumnWidth(3, 12 * 256);   // 状态
sheet.setColumnWidth(4, 20 * 256);   // 下单时间

坑二:在循环里创建 CellStyle

JavaAntiPattern.java
// 最危险的写法: 每一行都 new 一个样式
for (OrderExportDTOorder : batch) {
Rowrow = sheet.createRow(rowIndex++);
CellStylestyle = workbook.createCellStyle();   // 50 万次!
style.setDataFormat(FORMAT_AMOUNT);
row.createCell(2).setCellStyle(style);
}

Excel 里样式是按数量存的对象,CellStyle 数量超过上限会直接报错,而且每个样式对象都实实在在占内存。正确做法是提前建好几个、反复复用:

JavaCellStyles.java
// 一次性建好样式, 后续所有行复用同一批对象
privateCellStylescreateStyles(Workbookworkbook) {
CellStyleheaderStyle = workbook.createCellStyle();
FontheaderFont = workbook.createFont();
headerFont.setBold(true);
headerStyle.setFont(headerFont);
CellStyleamountStyle = workbook.createCellStyle();
amountStyle.setDataFormat(
workbook.createDataFormat().getFormat("#,##0.00"));
CellStyledateStyle = workbook.createCellStyle();
dateStyle.setDataFormat(
workbook.createDataFormat().getFormat("yyyy-mm-dd hh:mm:ss"));
returnnewCellStyles(headerStyle, amountStyle, dateStyle);
}
// 写数据时直接复用
CellamountCell = row.createCell(2);
amountCell.setCellValue(order.amount().doubleValue());
amountCell.setCellStyle(styles.amount());

顺便说一句,合并单元格、批注、图片、公式这些也一样:POI 文档明确提醒,即便用了 SXSSF,大量 merged regions 和 comments 仍然会保存在内存里。所以导出模板尽量做得克制——加粗标题 + 日期格式 + 金额格式 + 固定列宽,足够了。

07 改造后的内存模型:整份数据不再进 JVM

TXTmemory-model.txt
改造前:
    数据库
      ↓
    50 万 Entity      <- 全量驻留
      ↓
    50 万 DTO         <- 全量驻留
      ↓
    50 万 Excel Row   <- 全量驻留
      ↓
    OutputStream
    峰值内存 ≈ 与总行数线性相关
改造后:
    数据库
      ↓
    2000 条 DTO       <- 只驻留这一批
      ↓
    写入 Excel 窗口   <- 只驻留 500 行
      ↓
    释放这一批
      ↓
    下一批 2000 条
    峰值内存 ≈ 常数, 与总行数无关

这是整个改造里最关键的一点:50 万条数据不再意味着“JVM 同时拥有 50 万条数据”。总量从 10 万涨到 50 万,甚至 200 万,堆内存曲线都该是平的。

08 但产品又来了一句:能不能导出 100 万条?

这时候问题已经不是“会不会 OOM”了,而是用户要一直等在浏览器前面。数据库查询加上 Excel 生成要几十秒甚至几分钟,让一个 HTTP 请求一直挂在那里就不合适了:

  • Nginx 有 proxy_read_timeout,网关也有自己的超时;
  • 用户手一抖刷新一下页面,整个导出就白跑了;
  • 更常见的是连点三次导出,服务端同时跑三个百万行 Excel,数据库直接被打满。

所以最后给大数据导出定了一条规则:小导出同步下载,大导出走异步任务。

TXTrule.txt
< 5 万条    : HTTP 请求 -> Excel -> Response         (同步下载, 用户无感)
>= 5 万条   : 创建导出任务 -> 返回 taskId -> 后台生成
             -> 上传对象存储 -> 状态置为 SUCCESS -> 前端凭 url 下载

任务表简单到不能再简单:

SQLexport_task.sql
CREATE TABLE export_task (
    id              BIGINT       NOT NULL AUTO_INCREMENT COMMENT '任务ID',
    task_type       VARCHAR(50)  NOT NULL                COMMENT '导出类型',
    status          VARCHAR(20)  NOT NULL                COMMENT 'PENDING/PROCESSING/SUCCESS/FAILED',
    file_url        VARCHAR(500)                          COMMENT '生成后的文件地址',
    total_count     BIGINT       NOT NULL DEFAULT 0       COMMENT '总条数',
    processed_count BIGINT       NOT NULL DEFAULT 0       COMMENT '已处理条数',
    create_time     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    finish_time     DATETIME                              COMMENT '完成时间',
    PRIMARY KEY (id),
    KEY idx_status_create (status, create_time)
) ENGINE = InnoDB COMMENT = '导出任务表';

接口只负责建任务、立刻返回:

JavaOrderExportController.java
@PostMapping("/orders/export/task")
publicResult<ExportTaskVO> createExportTask(@RequestBodyOrderQueryquery) {
// 同一个用户同时最多 2 个进行中的导出任务, 防止连点把数据库拖死
longrunning = exportTaskMapper.countRunning(currentUserId());
if (running >= 2) {
returnResult.fail(429, "已有导出任务正在处理, 请稍后再试");
    }
ExportTasktask = exportTaskService.create(query, currentUserId());
returnResult.success(newExportTaskVO(task.getId(), task.getStatus()));
}
// 前端拿到 taskId 之后轮询(或走 SSE 推送)
// { "taskId": 98127, "status": "PROCESSING" }
// { "taskId": 98127, "status": "SUCCESS", "downloadUrl": "https://oss.xxx/orders-98127.xlsx" }

这样大导出就和 Web 请求的生命周期彻底解耦了。服务重启、页面关闭、浏览器刷新,都不会把整个导出过程一起带走。而且 processed_count 让前端能显示进度条,用户也不会以为页面卡死了。

09 换 SXSSF 之后必须补的两件事:临时文件和磁盘

这是我最想提醒的一点,很多人换完 SXSSF 就上线了,然后过一阵发现服务器磁盘满了。

SXSSFWorkbook 是把溢出的行写到临时文件里的,默认落在 java.io.tmpdir 下。而 close() 只关闭流,不会清理那些临时文件——必须显式调 dispose():

JavaSXSSFTempFiles.java
SXSSFWorkbookworkbook = newSXSSFWorkbook(500);
// 临时文件压缩, 省磁盘但多一点 CPU
workbook.setCompressTempFiles(true);
try {
Sheetsheet = workbook.createSheet("订单");
// ... 分批写入
workbook.write(outputStream);
} finally {
// 关键: 删除临时文件, 否则磁盘会被慢慢吃满
workbook.dispose();
workbook.close();
}

配套还要看两件事:

  • 给临时目录留足空间
    :50 万行 xlsx 的临时文件可能几百 MB 到 1 GB 以上,容器里 /tmp 常常挂的是很小的 emptyDir 或内存盘,很容易先把磁盘写满;建议启动时用 -Djava.io.tmpdir=/data/tmp/excel 指到数据盘;
  • 并发导出要限流
    :一个导出任务占一份临时文件,10 个人同时导就是 10 份。这也是上面那条“同一用户最多 2 个任务”之外的全局限制。

如果你发现换完 SXSSF 之后堆内存没事了,但磁盘报警了,先查 java.io.tmpdir。这个坑不看文档几乎遇不到提示,因为 close() 不会报错,它只是“安静地没删文件”。

10 上线前的自检清单

  • 确认过这个导出最大允许多少条吗?如果没人能回答,就默认以后一定有人会点“全部”;
  • Excel 写入用的是 SXSSFWorkbook(带窗口)而不是 XSSFWorkbook 吗?
  • 数据库查询是游标分批(WHERE id < lastId)还是 queryAll()?后者迟早要出事;
  • 用的是导出专用 DTO 投影,还是把整个实体捞出来?
  • CellStyle
     是提前建好复用的,还是循环里 new 的?列宽是固定值还是 autoSizeColumn?
  • SXSSFWorkbook
     用完调 dispose() 清临时文件了吗?java.io.tmpdir 空间够吗?
  • 超大导出(> 5 万条)和 HTTP 请求解耦了吗?有没有 taskId、进度、并发限制?
  • 埋了导出耗时、峰值堆内存、临时文件大小、同时进行中的任务数这几个指标吗?

11 总结

回头看最开始那段代码,它其实并没有“写错”。如果业务明确规定“最多导出 5000 条”,我甚至觉得完全没必要折腾这些。

问题在于很多后台系统刚上线的时候都只有几千条数据,于是我们自然按几千条去写。两三年以后表里已经 500 万、1000 万行了,页面上那个“导出”按钮,用的还是当初那套代码。

所以现在我做这类功能,都会先问一句:“这个导出最大允许多少条?”如果没人能回答,我基本就默认以后一定有人会选“全部”。而一旦允许“全部”,思路就不能再是“把全部数据拿出来再生成一个文件”,而要变成:数据库分批读、Excel 分批写、JVM 只保留当前这一小段、超大任务和 HTTP 请求解耦。

我们这次最后甚至没有增加服务器内存,也没动任何 JVM 参数。只是把 XSSFWorkbook 换成了 SXSSFWorkbook,把 List<Order> queryAll() 换成了“2000 条一批”,再把列宽和样式改成了复用——那条把 2G 堆打爆的接口,就再也没靠堆大小硬扛过。

最后留一句我自己一直在用的话:如果一个业务的数据量增长十倍,内存占用也必须跟着增长十倍,那代码里大概率还有东西不该一次性放进内存。

如果这篇帮你把“导出”这个按钮从定时炸弹改成了安全件,右下角点个在看、给个星标,下次更新你能第一时间收到。

相关学习资料