☰
SXSSFWorkbook分页查询实现百万行Excel流式导出
2026/10/26 3:29:13 网站建设 项目流程

一次导出请求把服务拖垮,事后排查下来,数据库不慢、网络不抖,问题出在几百兆的单元格对象全堆在堆内存里。SXSSFWorkbook 实现分页查询导出这个组合,就是专门解决这类场景的:流式写出把内存中的行数压到固定窗口,分页查询把数据库结果集拆成可控批次,两者配合,百万行级别的表格导出才真正跑得起来。但它不是拿来就灵的东西,窗口开多大、分页怎么翻、临时文件谁来清,这三件事任何一件没处理好,该 OOM 还是 OOM,该超时还是超时。这篇内容面向正在做报表导出、批量数据下载、后台运营台账落地的后端同学,也照顾刚接手这类需求、只知道XSSFWorkbook怎么写的新手。看完你至少能拿到三样东西:一套能直接搬走的骨架代码、一份参数取值的判断依据、一张踩坑速查表。

1. 先把问题掰开:为什么大数据量导出会翻车

1.1 一次内存雪崩的典型现场

场景很常见:运营要一份全量明细,表里几十万行,字段二三十个。第一版实现通常是查全量List,塞进XSSFWorkbook,一个Cell一个Cell地setCellValue,最后write到响应流。开发环境跑一千行毫无感觉,上线之后第一次导出,堆内存直接飙到几个 G,Full GC 频繁触发,接口响应从两秒变成两分钟,严重的时候整个实例被打挂,连带其他接口一起超时。

这里面有两笔开销叠在一起,很多人只看到了第一笔。第一笔是数据库结果集,select *查几十万行回来,ORM 把它们全部映射成实体对象,这笔内存跑不掉;第二笔更隐蔽,XSSFWorkbook是全内存模型,每一个Row、每一个Cell都是一份实打实的 Java 对象,加上共享字符串表,写完之后你手里握着的其实是一个和 Excel 内容等量的对象树。两笔开销同时压在堆里,峰值就是「结果集对象 + 单元格对象」。分页查询砍掉第一笔,SXSSFWorkbook砍掉第二笔,缺一个都救不回来。

还有一个容易被忽略的点:XSSFWorkbook.write()到最后会一次性把整个工作簿序列化成 zip 流,这一步会再申请一块缓冲区。哪怕你的堆勉强撑住了对象树,最后这一步也可能成为压垮骆驼的最后一根稻草。所以判断一个导出方案行不行,不能只看「写的时候内存多少」,要看「写完准备吐出去的那一刻内存多少」。

1.2 三种导出模型的内存账单对比

我把常见的三条路线放在一起对齐一下,方便你在技术选型会上有话说。这里的数字都是量级估算,具体取决于列数、字段长度、JVM 参数,但相对关系是稳定的。

方案内存模型百万行可行性主要瓶颈适用场景
全量查询 + XSSFWorkbook全内存对象树基本不可行堆内存、GC 停顿万行以内、格式复杂的小报表
分页查询 + SXSSFWorkbook滑动窗口 + 磁盘临时文件可行,需调参磁盘 IO、临时空间十万到百万行、结构规整的明细导出
分页查询 + CSV 流式写纯字节流完全可行无格式、Excel 打开体验差只求数据落地的对账、迁移场景

第三行经常被低估。如果业务方只是要数据去二次加工,CSV 比 Excel 省事太多,内存占用几乎为零,写出去就是一行行拼字符串,连 POI 都不需要引入。很多人上来就奔着 xlsx 去,其实是被「导出必须是 Excel」的惯性思维绑住了。真正需要SXSSFWorkbook的,是那些既要大数据量、又要多 sheet、要列宽、要数字格式、要表头样式、要给非技术同事直接打开看的场景。

顺带说一个判断标准:如果导出的文件打开后,业务方第一件事是「筛选、排序、做透视」,那 xlsx 是刚需;如果他们的下一步动作是「导入到另一个系统」或者「丢给脚本处理」,直接建议 CSV,能省掉后面 80% 的调优工作。这个沟通成本很低,收益很高。

1.3 什么情况下才值得引入流式写出

不是所有导出都该上SXSSFWorkbook。它带来的是磁盘 IO 和临时文件管理成本,行数少的时候纯属负优化。我的经验分界线大概在单次导出两万行以上:两万行以内,XSSFWorkbook稍微控制一下列数完全扛得住,代码还简单;超过这个量级,内存曲线开始变得不好预测,尤其是并发导出两三个请求同时打进来的时候,就得上流式了。

另一个判断维度是并发度。单次导出五万行、但可能同时有十个人在点导出按钮,这种情况下即使单次不大,累积起来也够呛。这时候要么用流式写出把单次内存压下去,要么加信号量限制并发导出数量,两个手段通常一起用。

还有一种情况容易被漏掉:导出任务跑在定时任务里,凌晨批量生成几十份报表文件。这种场景没有用户请求的超时约束,但会对 JVM 造成持续压力,如果和在线业务共用实例,一样要考虑流式方案,或者干脆拆到独立的导出服务节点上,用独立的 JVM 参数和临时目录。分而治之永远比在一个进程里抠参数来得稳。

2. SXSSFWorkbook 的滑窗机制与关键参数

2.1 滑动窗口到底在滑什么

SXSSFWorkbook内部包着一个XSSFWorkbook,但它不把所有行都留在内存里。你每创建一行、写一行,超出窗口范围的老行会被刷写到磁盘上的临时 XML 片段,内存里只保留最近 N 行。等最后调用write(OutputStream)时,它把这些临时片段按顺序拼装成真正的 sheet XML,再打包成 xlsx 的 zip 结构吐出去。

这个设计的关键含义有三个。第一,已经刷走的行不能再读,你写完之后想回头改第 100 行的某个单元格,拿到的会是null,这是很多人第一次用 SXSSF 时最不适应的地方。第二,样式、列宽、sheet 级配置不受影响,因为它们挂在 workbook 和 sheet 上,不在行里。第三,临时文件是真实存在的磁盘文件,写在java.io.tmpdir下面,进程被 kill 或者dispose()没调用,这些文件会残留。

理解这三点之后,很多「为什么我的代码行为诡异」的问题就自解了。比如有人想在写完之后统一给某一列加超链接,或者回头补一个合计行公式,在 SXSSF 里就得提前规划:要么预留行位置最后再写,要么在刷写之前把该算的都算完。流式写出的本质是「一次性、单向、不可回退」,接受这个前提,代码结构会清晰很多。

2.2 rowAccessWindowSize 的取值逻辑

构造SXSSFWorkbook时传的那个整数就是窗口大小,默认是 100。这个值决定了内存里同时保留多少行,直接对应内存占用和刷盘频率。

取值不要拍脑袋,按这个思路估:先算单行内存。一行的开销大致等于「列数 × 单单元格基础开销」,基础开销里Cell对象本身、样式引用、值引用加起来,纯数字单元格大概一两百字节,带字符串的会更高,因为字符串内容要驻留。按 30 列、每格平均 300 字节算,一行约 9KB。窗口开 500 行,内存里就是 4.5MB 左右,这个量级完全可控。

窗口大小内存占用(30 列估算)刷盘次数(百万行)建议
100(默认)约 1MB约 10000 次保守,适合内存极紧张
500约 4.5MB约 2000 次通用推荐值
1000约 9MB约 1000 次列数少时可用
5000约 45MB约 200 次需评估并发数

窗口不是越大越好。窗口大意味着单次刷盘的批次大、刷盘次数少,但内存峰值高;窗口小则相反。我的默认选择是 500,这个值在「内存占用可忽略」和「刷盘频率不夸张」之间比较平衡。如果单次导出允许占用的堆内存有明确上限,用「上限 ÷ 单行内存」反推窗口大小,再往下取整到 100 的倍数,是个很实用的做法。

有个细节值得说:窗口是按 sheet 独立计算的。你在一个 workbook 里建了三个 sheet,轮流往里写,每个 sheet 各自维护自己的窗口。所以多 sheet 导出时,总内存是「窗口 × sheet 数 × 单行内存」,拆 sheet 的时候记得把这笔账算进去。

2.3 临时文件的生命周期与清理

临时文件这块是事故高发区。默认情况下,临时文件落在java.io.tmpdir指向的目录,容器环境里通常是/tmp。很多容器给/tmp挂的是内存盘或者小容量卷,百万行导出的临时文件轻松上到几百兆,几个并发一起来,磁盘写满,报错信息还特别不直观。

几个必须做的事:

  • 启动参数里显式指定-Djava.io.tmpdir=/data/tmp/export,指到容量充足的独立数据盘,别用系统默认。
  • 用try-finally或者try-with-resources保证workbook.dispose()一定执行,它负责删掉临时文件。只close()不一定够,dispose()才是明确清理临时文件语义的那个。
  • 加一个定时清理任务,扫掉超过一定时间(比如 2 小时)的残留临时文件,兜住进程被强杀的情况。
  • 监控这个目录的磁盘使用率,超过阈值告警。
SXSSFWorkbook workbook = null; try { workbook = new SXSSFWorkbook(500); // ... 写数据 workbook.write(response.getOutputStream()); } finally { if (workbook != null) { workbook.dispose(); workbook.close(); } }

构造函数还有一个重载可以开启临时文件压缩:new SXSSFWorkbook(null, 500, true, false)。第三个参数compressTmpFiles打开后临时文件会 gzip 压缩,磁盘占用能降到三分之一左右,代价是 CPU 多花一点。数据量大、磁盘紧张的场景建议开。第四个参数useSharedStringsTable保持默认false即可,打开共享字符串表会让所有不重复字符串驻留内存,把流式的优势抵消掉大半。

2.4 样式、列宽、合并单元格这些坑

样式这块必须提前建立「复用」意识。CellStyle是 workbook 级别的对象,xlsx 格式对样式数量有上限(六万多个),每个单元格都createCellStyle()会导致文件异常膨胀甚至写失败。正确做法是提前建好几个样式放在Map里,写单元格时按类型取用。

private final Map<String, CellStyle> styleCache = new HashMap<>(); private CellStyle amountStyle(SXSSFWorkbook wb) { return styleCache.computeIfAbsent("amount", k -> { CellStyle style = wb.createCellStyle(); style.setDataFormat(wb.createDataFormat().getFormat("#,##0.00")); return style; }); }

列宽要特别提醒:autoSizeColumn()在 SXSSF 上基本不能用。它只能根据内存窗口里还留着的行来算宽度,已经刷走的行参与不了计算,而且每一列调一次都会遍历窗口内的所有单元格,几十列跑下来慢得离谱,算出来的宽度还不准。正确姿势是预先setColumnWidth,按字段内容的大致长度给一个固定值,比如金额给 15 个字符宽、备注给 40 个字符宽。这个做法牺牲了一点点美观,换来的是稳定的性能。

日期和数字格式走DataFormat,别自己拼字符串。用真正的日期类型写入,Excel 里才能正常排序和筛选,业务方拿到手体验完全不一样。合并单元格在流式写出里要格外小心,因为合并是对一个区域操作,而区域的行可能已经被刷走了。稳妥做法是把需要合并的数据在内存里先归并好,按批次写出时只合并当前窗口内的行,跨批次合并尽量避开;如果业务必须要跨批次合并,就得自己维护一个「上一个批次末尾未闭合的合并组」状态,复杂度陡增,能推掉就推掉。

3. 分页查询与写出节奏的配合

3.1 分页大小不能随便拍

分页大小和窗口大小是两个独立的参数,但它们必须配合。分页决定「一次从数据库拿多少行到内存」,窗口决定「一次在内存里保留多少行」。理想状态是两者接近,这样数据从数据库到内存到磁盘的流转是一条平稳的管道,不会出现某一环节堆积。

如果分页远大于窗口(比如一页 5000 行、窗口 100),那么每页数据拿回来后,只有 100 行留在内存,其余 4900 行在写的过程中被反复刷盘,SXSSFRow对象创建又回收,GC 压力反而变大。如果分页远小于窗口(一页 50 行、窗口 5000),窗口根本填不满,流式写出的优势没发挥出来,内存里白白留着没用的空间。

我的常用组合是分页 500、窗口 500,一眼就能对上。数据量特别大、单行字段特别多的场景,降到 200 到 300;字段少的宽表,可以拉到 1000。调整的依据是压测结果,不是感觉。

分页大小还要受 SQL 限制影响。LIMIT offset, size里的 size 太大,MySQL 会多分配内存、排序缓冲区也变大;太小则往返次数多,网络和解析开销上去了。500 这个量级在大多数场景下都能接受,是个不容易踩雷的值。

3.2 offset 深分页与游标分页的取舍

分页查询这一层,最容易被忽视的是深分页问题。LIMIT 900000, 500这种写法,数据库要先扫描前 900000 行再丢弃,越翻越慢,翻到后面单页耗时可能是首页的几十倍。导出恰好是最容易触发深分页的场景,因为它必须把所有页都翻一遍。

分页方式语句形态深分页性能约束适用
偏移量分页LIMIT offset, size随 offset 线性劣化无小数据量、允许跳页
游标分页WHERE id > lastId ORDER BY id LIMIT size稳定,走索引排序键必须唯一大数据量导出

导出场景几乎没有「跳页」需求,从第一行翻到最后一行,这正是游标分页的主场。把offset换成WHERE 排序键 > 上一页最后一个值,每次查询都是索引范围扫描,翻到第几页耗时都差不多。这个改动通常能把大导出的数据库耗时砍掉一半以上。

游标分页有个必须注意的前提:排序键必须唯一且有序。用主键 id 最稳,如果没有自增主键,就用「业务唯一键 + id」的组合排序,并且要保证组合值严格递增。排序键不唯一的时候,翻页会出现漏行或者重复行,而这类 bug 特别隐蔽——导出结果看起来没问题,实际少了几条,等到业务方对账才发现。测试阶段可以用「导出行数 vs 表内 count」做校验,简单有效。

另外,导出前那次count(*)查询能省就省。如果只是为了让前端显示进度百分比,代价是一次全表扫描,非常不划算。可以改成按已导出行数做估算进度,或者干脆不做进度,前端显示一个转圈。真的需要精确总数,放到异步导出任务里,让它和主流程解耦。

3.3 边查边写:让三个环节对齐

把数据库游标、内存窗口、网络输出看成三个齿轮,导出的流畅度取决于它们咬合得好不好。通行的做法是一个while循环:查一页,写一页,判断这一页是否满页,不满则结束。

long lastId = 0L; int pageSize = 500; int written = 0; while (true) { List<OrderRow> rows = orderMapper.pageById(lastId, pageSize); if (rows.isEmpty()) { break; } for (OrderRow row : rows) { writeRow(sheet, row, ++written); } lastId = rows.get(rows.size() - 1).getId(); if (rows.size() < pageSize) { break; } }

这段代码里有几个细节值得说。终止条件用「返回条数小于页大小」比用「total 计数」更可靠,因为数据在导出过程中可能被并发修改,总数会漂移。lastId从结果集最后一条取,而不是从第一条加上页大小推算,避免排序键有空隙时算错。written单独计数,用来控制 sheet 拆分——单 sheet 上限是 1048576 行,留出表头余量,到 100 万行就createSheet换一张。

还有一个容易被忽略的点:每页写完后手动 flush。虽然窗口满了会自动刷,但显式sheet.flushRows(pageSize)让刷盘时机和数据批次对齐,行为更可预测,也便于你在日志里打点观察进度。默认情况下如果窗口没满,行就一直留在内存,直到最后 write 才一次性处理,这在数据量刚好介于「一页」和「窗口」之间时会让内存曲线看起来很奇怪。

4. 完整落地实现

4.1 工程结构与依赖

依赖只需要两个,不要引入一堆封装库,出问题时你会感谢自己用的是原生的 POI。

<dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.5</version> </dependency> <dependency> <groupId>commons-io</groupId> <artifactId>commons-io</artifactId> <version>2.15.1</version> </dependency>

工程结构建议按职责切三层:ExportController负责响应头、流、异常兜底;ExportService负责分页循环和业务字段映射;ExcelRowWriter负责单元格级别的写入和样式。把「写单元格」单独抽出来,是为了后续加字段只改一处,也方便针对不同导出做复用。

一个提前想清楚的设计问题:导出列是配置化的还是硬编码的。如果业务会频繁加列,建议用「列定义 + 反射/映射器」的方式,列定义里描述表头名、取值函数、宽度、格式,写循环时按定义遍历。多花半天时间抽象,后面加十次列能省好几天。

4.2 数据访问层:稳定可控的分页读

Mapper 层给一个游标分页的方法,注意只用必要字段,别select *。导出往往只用到十几个字段,查全字段回来会让每行的对象大一圈,内存和网络都浪费。

SELECT id, order_no, customer_name, amount, status, create_time FROM t_order WHERE id > #{lastId} ORDER BY id LIMIT #{pageSize}

索引要覆盖到id上的主键,这个查询天然走主键索引。如果排序键不是主键,记得建对应的联合索引,否则游标分页的效果会大打折扣。可以在压测时用EXPLAIN确认一下执行计划走的是 range 还是 index。

查询结果不要用 ORM 的二级缓存、也不要用会「全量预加载」的查询方式。有些框架在分页查询时会偷偷执行 count 或者追加排序,导出场景下这些都是额外开销,配置里该关就关。还有一种坑是连接池的fetchSize没设置,某些驱动默认把整个结果集拉进内存,分页就白做了——MySQL 需要useCursorFetch=true配合setFetchSize(Integer.MIN_VALUE)才能真流式,但只要你的分页每页只有 500 行,这个坑不会触发。

4.3 写出层:把 ExcelRowWriter 封装好

核心的写出封装大概长这样,重点关注样式复用、sheet 拆分、窗口控制和异常安全。

public class PagedExcelExporter implements AutoCloseable { private static final int MAX_ROWS_PER_SHEET = 1_000_000; private final SXSSFWorkbook workbook; private final String[] headers; private final int[] widths; private SXSSFSheet sheet; private int rowIndex; private int sheetIndex; private final Map<String, CellStyle> styles = new HashMap<>(); public PagedExcelExporter(String[] headers, int[] widths, int windowSize) { this.workbook = new SXSSFWorkbook(null, windowSize, true, false); this.headers = headers; this.widths = widths; newSheet(); } private void newSheet() { sheetIndex++; sheet = workbook.createSheet("数据" + sheetIndex); for (int i = 0; i < widths.length; i++) { sheet.setColumnWidth(i, widths[i] * 256); } sheet.createFreezePane(0, 1); Row headerRow = sheet.createRow(0); for (int i = 0; i < headers.length; i++) { Cell cell = headerRow.createCell(i); cell.setCellValue(headers[i]); cell.setCellStyle(headerStyle()); } rowIndex = 1; } public void writeRow(OrderRow data) { if (rowIndex >= MAX_ROWS_PER_SHEET) { newSheet(); } Row row = sheet.createRow(rowIndex++); fill(row, data); } private void fill(Row row, OrderRow data) { row.createCell(0).setCellValue(data.getOrderNo()); row.createCell(1).setCellValue(data.getCustomerName()); Cell amount = row.createCell(2); amount.setCellValue(data.getAmount() == null ? 0D : data.getAmount().doubleValue()); amount.setCellStyle(amountStyle()); row.createCell(3).setCellValue(data.getStatusText()); Cell time = row.createCell(4); time.setCellValue(data.getCreateTime() == null ? "" : new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(data.getCreateTime())); } public void finish(OutputStream out) throws IOException { workbook.write(out); out.flush(); } @Override public void close() { workbook.dispose(); try { workbook.close(); } catch (IOException ignored) { } } }

几个点展开说。setColumnWidth的单位是 1/256 字符宽,所以传值要乘 256,这个换算经常被忘。createFreezePane(0, 1)冻结首行表头,用户滚动时表头不动,这个小细节对体验提升很明显。日期我直接格式化成字符串写入,牺牲了 Excel 里的日期类型,换来的是免去时区和格式的折腾;如果需要业务方在 Excel 里按日期排序,那就老老实实用setCellValue(Date)加CellStyle的日期格式。

样式缓存放在 exporter 实例里,而不是静态的,因为CellStyle绑定在具体的 workbook 上,跨 workbook 复用会报错。这个坑不少人踩过——把样式做成静态常量,单次导出没问题,并发导出时直接抛异常。

4.4 控制层:流式下载与异常兜底

@GetMapping("/export/orders") public void export(HttpServletResponse response) { String fileName = URLEncoder.encode("订单明细.xlsx", StandardCharsets.UTF_8); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=" + fileName); response.setHeader("Cache-Control", "no-store"); try (PagedExcelExporter exporter = new PagedExcelExporter(HEADERS, WIDTHS, 500)) { long lastId = 0L; while (true) { List<OrderRow> rows = orderMapper.pageById(lastId, 500); if (rows.isEmpty()) { break; } rows.forEach(exporter::writeRow); lastId = rows.get(rows.size() - 1).getId(); if (rows.size() < 500) { break; } } exporter.finish(response.getOutputStream()); } catch (Exception e) { // 此时响应可能已提交,无法再返回 JSON log.error("导出失败, lastId={}", lastId, e); } }

这里面最需要说清楚的是异常处理。一旦exporter.finish()开始往输出流写字节,HTTP 响应头就已经发出去了,后面再抛异常,你没办法把状态码改成 500,也没办法返回一个 JSON 错误体——浏览器只会得到一个损坏的文件。所以工程上要做两件事:一是在开始写流之前把能校验的都校验完(权限、参数、数据是否存在),二是前端对下载失败要有兜底,比如判断响应头里有没有Content-Type: application/json,如果有就说明后端在写流之前就报错了,可以解析错误信息。

还有个实践中的小技巧:如果导出量不大(比如能控制在几十万行以内),可以先写到临时文件,统计好大小,再设置Content-Length返回,这样浏览器能显示下载进度条,用户体验好很多。纯流式写出是分块传输,浏览器不知道总大小,只能一直转圈。这两种模式的取舍,取决于你对数据量的预估和用户对进度条的诉求。

5. 常见问题排查实录

5.1 内存不降与临时文件堆积

最典型的症状是导出完成后堆内存不回落。先确认dispose()有没有执行——放在finally里、用try-with-resources,两个都做上更保险。如果确认执行了还是高,检查一下是不是把查询结果List在某个地方缓存了,比如在 service 里加了个局部缓存 Map,或者把每页数据塞进了一个全局集合里做「后处理」。

临时文件堆积则通常是进程被 kill、或者dispose()抛出异常没兜住。查一下/data/tmp/export下面有没有大量poifiles开头的目录,有的话就是没清干净。补一个定时清理任务,按修改时间删超过两小时的目录,成本很低,能避免磁盘被慢慢吃满。同时把这类残留文件的数量做成监控指标,涨起来就是信号。

5.2 下载中断、乱码与内容错位

下载到一半中断,常见原因是网关或反向代理有响应超时、或者缓冲区限制。百万行导出跑几分钟很正常,中间层如果配置了 60 秒超时,连接就会被掐断。解决方向要么调大超时,要么改成异步导出——前端发起任务后立刻返回,后台慢慢生成,完成后通知下载。后者是更彻底的方案,后面会展开。

中文文件名乱码,基本都是Content-Disposition没做 URL 编码,或者编码用了 ISO-8859-1。用 UTF-8 编码后再拼进去,同时注意filename*的写法在不同浏览器上的兼容性,实践中filename=加上 URL 编码就能覆盖绝大多数情况。

内容错位一般是列索引写错,或者表头和数据的列顺序不一致。这类问题在字段多的宽表里特别容易发生,尤其是中途有人插了一列。用列定义配置化的方式能从根上避免,因为表头和数据都从同一份定义生成,改配置就同步改了。如果坚持硬编码,至少给每列索引定义成常量,别在代码里散落魔法数字。

5.3 问题速查表

现象高概率原因排查动作处理方式
导出中 OOM窗口过大或结果集全量加载看堆快照里SXSSFRow和实体对象占比降窗口、改游标分页
堆内存不回落dispose()未执行日志打点确认 finally 分支try-with-resources 兜底
磁盘写满临时文件残留查 tmpdir 下 poifiles 目录定时清理 + 独立目录
导出极慢深分页 offset看慢查询日志改游标分页加索引
下载中途断开代理超时查网关超时配置调超时或改异步
文件打开报错样式数量爆炸检查是否每格建样式样式缓存复用
少数据行排序键不唯一对比导出数 vs 表内数用唯一键排序
列宽很怪用了 autoSizeColumn代码搜索该方法改 setColumnWidth

6. 性能压测与规模化改造

6.1 怎么压、看什么指标

导出接口的压测和普通接口不一样,重点不是 QPS,而是单次导出的资源轨迹。准备一份 50 万行左右的测试数据,跑一次单请求,用jstat -gc或者 APM 工具观察整个过程中的堆占用曲线、Young GC 频率、耗时分布。理想曲线是一条平缓的锯齿,峰值不超过你设定的预算;如果看到持续爬升直到 OOM,那就是有对象没被释放。

然后做并发压测,两个请求、四个请求、八个请求往上加,观察内存和磁盘的叠加效应。通常会有一个明显的拐点,超过之后开始互相拖慢。这个拐点就是你需要用信号量卡住的并发上限。

private final Semaphore exportPermit = new Semaphore(3); public void export(...) { if (!exportPermit.tryAcquire()) { throw new BizException("当前导出任务较多,请稍后再试"); } try { // 导出逻辑 } finally { exportPermit.release(); } }

限流比无限扩容更务实。导出是个重资源操作,让用户排队等三十秒,比让整个服务卡死三分钟要好得多。

6.2 异步导出:大规模场景的必经之路

当单次导出稳定超过一到两分钟,同步接口就基本不可用了。网关超时、用户手动关页面、连接中断导致任务白跑,这些问题同步模式解决不了。异步导出的思路是把「生成文件」和「下载文件」拆开:

  1. 前端提交导出请求,后端写一条任务记录,状态为「处理中」,立即返回任务 ID。
  2. 后台线程(独立线程池,别用业务线程池)执行导出,把文件写到本地目录或对象存储。
  3. 前端轮询任务状态,或者用进度接口查已导出行数。
  4. 状态变为「完成」后,前端拿到下载链接去下载。

这套东西的关键在于线程池隔离和任务表设计。导出线程池要单独配置,核心线程数控制在个位数,队列有界,满了直接拒绝并给出友好提示,绝不能让它把业务线程池占满。任务表要有创建时间、状态、文件路径、行数、错误信息这几个字段,方便排查。文件要有过期清理策略,比如保留 24 小时。

进度反馈这块,最简单可靠的做法是「已处理行数」写进任务表,每完成一页更新一次。这个更新频率不高(每页一次),不会给数据库造成压力,前端展示成「已处理 32 万行」比一个假百分比更让人安心。

6.3 参数清单与调优建议

把上面所有建议收拢成一份可以直接照着改的清单:

项目建议值说明
窗口大小500按内存预算反推,100 的倍数
分页大小500与窗口保持一致
临时文件压缩开启省磁盘,CPU 代价可接受
共享字符串表关闭保持默认 false
临时目录独立数据盘别用系统默认 /tmp
并发上限3 到 5信号量控制,超出快速失败
单 sheet 行数100 万留出表头余量
列宽预设禁用 autoSizeColumn
样式缓存复用按类型建少量样式
过期文件24 小时清理定时任务扫目录

这些值不是教条,是起点。真正落地时按你的字段数、数据量、机器配置微调,调完压一遍确认效果。最怕的是照抄一堆参数,却不知道每个参数在控制什么,出了问题完全无从下手。

我个人在实际操作中的体会是,这类导出的坑,八成不在 POI 本身,而在「以为它是个黑盒」。把它当成一条有明确边界的流水线来看——数据从库里出来是一段、在内存窗口里停留是一段、落到磁盘临时文件是一段、最后组装成 zip 吐给浏览器又是一段——每一段的资源上限都心里有数,问题基本都能在发生之前就被预判到。真出了事故,也往往是某一段的上限被人无意中突破了,比如有人为了「看起来快点」把窗口调到五万,或者有人在循环里加了个createCellStyle()。所以比起记住参数,更值钱的是记住这条流水线每个环节在干什么。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询