上周踩了个特别典型的坑。运营后台有个订单导出功能,平时导几千条、两三万条都挺顺,直到有一次运营把时间范围直接拉到了半年。
点完“导出”之后接口就没动静了。过了一会儿监控上 JVM 堆内存开始一路往上冲:600MB → 900MB → 1.3GB → 1.7GB → 1.9GB,然后 Full GC 开始频繁刷屏,最后服务直接甩出一句 java.lang.OutOfMemoryError: Java heap space。
第一反应是“一次查了 50 万条订单,内存肯定爆”。但看完代码才发现——这个接口同时干了两件吃内存的事,而且是叠在一起吃的。
01 现场:一次导出把服务打挂的过程
先是接口不返回,然后堆内存被一点点吃干:
# 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.113sException 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 {// 吃内存第一件事: 把符合条件的订单全部捞进 JVMList<Order> orders = orderService.queryAll(query);// 吃内存第二件事: 把全部 Excel 行也在 JVM 里构造一遍XSSFWorkbookworkbook = newXSSFWorkbook();Sheetsheet = workbook.createSheet("订单");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());这段代码本身没有语法错误,逻辑也挑不出毛病。数据只有 5000 条的时候,它甚至算写得挺清楚的。
问题在于它从写下的那一刻就默认了两件事:
- 数据库结果可以全部放进内存
- Excel 也可以全部放进内存——
XSSFWorkbook 在堆里搭完整个文档才输出。
数据量一上来,这两个假设同时失效,而且不是相加,是相乘。因为一条订单在某一瞬间可能同时存在好几份:
├─ String orderNo / userName / status └─ LocalDateTime createTime OrderExportDTO <- 如果中间还做了转换 XSSFCell × 5 <- 这一行的每个单元格遇到 OOM 时,先看堆内存曲线的形态再动手:如果是锯齿形忽高忽低,多半是短命对象太多、GC 参数或分配速率的问题;如果是这种一路向上、GC 收不回来的,那一定是有东西被长期持有了——重点去找“一次性把什么全都装进了集合或文档对象”。
03 第一刀:把 XSSFWorkbook 换成 SXSSFWorkbook
我没有先去调 JVM 参数,也没有简单粗暴地把 -Xmx2g 改成 -Xmx4g。因为那最多只是把“50 万条挂掉”推迟成“100 万条挂掉”,问题本身一个字都没变。
Apache POI 专门提供了一套用于大文件写入的实现:SXSSFWorkbook(Streaming XSSF)。它和 XSSFWorkbook 最大的区别不是 API,而是内存模型。
XSSFWorkbook:所有行都在堆里,write() 之前整份文档都在内存中;SXSSFWorkbook:维护一个滑动窗口,只保留最近写入的有限行数,超出窗口的旧行会被刷到磁盘上的临时文件,所以堆占用是有上限的。
POI 文档里默认窗口是 100 行,可以自己指定。先改成这样:
// 500 = 允许保留最近 500 行在内存里, 其余的边写边刷出去try (SXSSFWorkbookworkbook = newSXSSFWorkbook(500)) {Sheetsheet = workbook.createSheet("订单");workbook.write(response.getOutputStream());内存模型的变化,用图看最直观:
... ├── 全部留在 JVM 堆里, 直到 write() 结束改造后 (SXSSFWorkbook(500)):上线一盘,堆内存确实明显下来了。但问题并没有完全解决——因为代码最上面那句List<Order> orders = orderService.queryAll(query) 还在。
04 第二刀:把那个 List 也删掉
Excel 不一次性放内存了,数据库查询结果还在一次性放内存。50 万个 Order Entity 依然稳稳地趴在堆里。
简单算一笔账:假设每个订单对象实际占用 1KB(这已经很保守,真实 Entity 还有关联对象、持久化上下文引用、字符串常量池等额外开销):
再加上 Excel 窗口内的行和 POI 自身的对象,最简单的解法就是分批:不是一次查 50 万,而是每次查 2000 条,写完一批再去查下一批。
为什么不推荐 LIMIT / OFFSET 翻页?
很多人的第一反应是 page = 0, 1, 2 ... 配 LIMIT 2000 OFFSET 400000。但大数据导出本身是一个持续向后扫描的过程,用 OFFSET 翻到后面时,数据库每次都要先扫过前面那几十万行再丢掉——页码越大越慢,而且数据还在变的情况下会漏行、重复行。
数据既然只往前走,就该用 ID 做游标(keyset pagination):
id, order_no, user_name, amount, status, create_timeWHERE id < :lastId -- 游标: 只取比上一批最后一条更小的 id AND create_time >= :startTime AND create_time < :endTimeLIMIT 2000; -- 固定批次大小, 不做 OFFSET第一批传 lastId = Long.MAX_VALUE,假设拿到的最小 id 是 9832101,下一批就变成 WHERE id < 9832101——每一批都是走索引的一次范围扫描,代价恒定。
循环写进 Excel 就是这样:
JavaOrderExportService.java(分批循环)privatestaticfinalintBATCH_SIZE = 2000;longlastId = Long.MAX_VALUE;List<OrderExportDTO> batch = repository.findExportBatch(query, lastId, BATCH_SIZE);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 最省事:
publicrecordOrderExportDTO(LocalDateTimecreateTime) {Repository 里用 JPQL 的构造器表达式直接投影,数据库只查这几列,应用层也不再生成完整实体:
selectnewcom.demo.export.OrderExportDTO(o.id, o.orderNo, o.userName, o.amount, o.status, o.createTime)ando.createTime >= :startTimeando.createTime < :endTimeList<OrderExportDTO> findExportBatch(@Param("lastId") longlastId,@Param("startTime") LocalDateTimestartTime,@Param("endTime") LocalDateTimeendTime,**不要用 select * + 实体映射来做导出。导出场景下,字段越多,每批 2000 条的内存和网络开销就越大;而且实体一旦进了持久化上下文,还会带来脏检查、一级缓存等额外成本。导出用只读 DTO 投影,是性价比最高的一刀。**
06 第三个坑:有些写法会让“流式 Excel”重新变重
换完 SXSSF 之后不要以为就万事大吉了。很多代码为了让表格好看,会写一些在几千行时完全没问题、在 50 万行时非常致命的东西。
坑一:对每一列都调 autoSizeColumn
// 数据小的时候没什么, 50 万行之后最好别这么干for (inti = 0; i < 20; i++) {autoSizeColumn 需要扫描这一列所有已写入的行来算最大宽度。在流式写入模式下,已经刷到临时文件的行要么算不到、要么得重新读回来,结果是既慢又可能算错。固定列宽就够了:
// 固定列宽, 一次性设置, 单位是 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
for (OrderExportDTOorder : batch) {Rowrow = sheet.createRow(rowIndex++);CellStylestyle = workbook.createCellStyle(); // 50 万次!style.setDataFormat(FORMAT_AMOUNT);row.createCell(2).setCellStyle(style);Excel 里样式是按数量存的对象,CellStyle 数量超过上限会直接报错,而且每个样式对象都实实在在占内存。正确做法是提前建好几个、反复复用:
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();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
这是整个改造里最关键的一点:50 万条数据不再意味着“JVM 同时拥有 50 万条数据”。总量从 10 万涨到 50 万,甚至 200 万,堆内存曲线都该是平的。
08 但产品又来了一句:能不能导出 100 万条?
这时候问题已经不是“会不会 OOM”了,而是用户要一直等在浏览器前面。数据库查询加上 Excel 生成要几十秒甚至几分钟,让一个 HTTP 请求一直挂在那里就不合适了:
- Nginx 有
proxy_read_timeout,网关也有自己的超时; - 更常见的是连点三次导出,服务端同时跑三个百万行 Excel,数据库直接被打满。
所以最后给大数据导出定了一条规则:小导出同步下载,大导出走异步任务。
< 5 万条 : HTTP 请求 -> Excel -> Response (同步下载, 用户无感)>= 5 万条 : 创建导出任务 -> 返回 taskId -> 后台生成 -> 上传对象存储 -> 状态置为 SUCCESS -> 前端凭 url 下载任务表简单到不能再简单:
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 '完成时间', 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());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():
SXSSFWorkbookworkbook = newSXSSFWorkbook(500);workbook.setCompressTempFiles(true);Sheetsheet = workbook.createSheet("订单");workbook.write(outputStream);// 关键: 删除临时文件, 否则磁盘会被慢慢吃满配套还要看两件事:
- 给临时目录留足空间: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 堆打爆的接口,就再也没靠堆大小硬扛过。
最后留一句我自己一直在用的话:如果一个业务的数据量增长十倍,内存占用也必须跟着增长十倍,那代码里大概率还有东西不该一次性放进内存。
如果这篇帮你把“导出”这个按钮从定时炸弹改成了安全件,右下角点个在看、给个星标,下次更新你能第一时间收到。