做过后台系统的人,早晚都会被同一个需求找上门:把库里的数据导成 excel 给业务方。数据量小的时候随便怎么写都能跑通,一旦单次导出冲到 15 万条,各种妖魔鬼怪就全冒出来了——接口转圈几十秒、服务内存曲线直接竖起来、文件下到一半断了、好不容易下完打开提示文件损坏。前段时间我手上正好有一个订单明细导出的需求,峰值就是 15 万条,索性把常见的几种 excel 下载方式全拉出来做了一轮横向测试,把耗时、峰值内存、文件体积这些指标都记了下来,顺便把踩过的坑整理成一份可以直接抄的对照表。这篇文章就按"需求拆解—选型原理—落地代码—实测数据—排错手册—工程化收尾"这个顺序讲,代码是 Java 为主,前端生成和 CSV 兜底也会带上。刚接触这块的同学可以先看第二章和第三章前半段,有过导出经验、正被内存和超时折磨的同学,建议直接跳到第四章的实测数据和第五章的速查表。
1. 需求拆解:15 万条数据导出到底难在哪
1.1 先搞清楚业务方要的是"下载"还是"报表"
很多人一上来就写代码,其实第一步应该是问清楚:这份 excel 是用来干什么的。我遇到过的场景大致三类。第一类是运营拉订单明细做核对,他们要的是原始数据,字段多、行数大、不需要任何样式,打开之后自己会做筛选和透视表。第二类是财务对账,行数不一定多,但格式要求严格,金额要两位小数、日期要指定格式、表头要合并单元格,甚至要带上公司抬头。第三类是管理员做批量操作,导出的文件改完之后还要再导回系统,这种对列顺序、编码、单元格类型的要求最苛刻,一个手机号被 Excel 吃掉了前导零或者变成科学计数法,整条数据就废了。
这三类需求的实现方式完全不同。第一类适合走纯流式导出甚至 CSV,追求的是快和稳;第二类行数通常在几千条以内,用带样式的 xlsx 完全没问题;第三类必须严格控制单元格类型,很多时候反而不建议用 Excel 作为中间格式。我这次的测试对象是第一类,20 个字段、15 万条、单 sheet、无合并单元格,但表头要有一行加粗。
还有一个容易被忽略的问题:业务方说的"下载"和开发理解的"下载"可能不是一回事。同步下载意味着用户点击后浏览器一直等着,几秒内不出文件就会怀疑人生;异步下载是提交任务后去任务中心取,体验上差一点但能扛住大文件;还有一种是把文件定期生成好推到内网文件服务或者对象存储,业务方自己去目录里拿。这三种形态的技术选型差别很大,后面第六章会专门讲异步方案。
1.2 15 万条数据在内存和磁盘上是什么概念
先把量级算清楚,后面选型才有依据。假设一行 20 列,每列平均存 12 个字符,中文按 UTF-8 三字节、英文数字按一字节混算,平均按 8 字节估。
- 纯文本数据量:150000 × 20 × 8B ≈ 24MB,这是最理想情况下的裸数据。
- 对象开销:如果用 POI 的 XSSF 模式,每个单元格是一个 XSSFCell 对象,每行是一个 XSSFRow 对象,加上共享字符串表(SST)本身还要维护一份字符串池,对象头、引用、HashMap 的桶开销加起来,实测每行大约 1 到 2KB。150000 行就是 150MB 到 300MB 的净对象,再考虑序列化阶段的 XML 缓冲和 GC 需要预留的空间,堆内存峰值冲到 1.5GB 是常态。
- 文件体积:xlsx 本质是个 zip 包,内部 XML 压缩率不错,15 万行 20 列生成出来大约 11MB;同样的数据写成 CSV 不压缩大概 34MB,如果服务端开 gzip 传输,能压到 5MB 左右。
- 时间成本:瓶颈基本都在单元格写入上。300 万个单元格,不同方案的写入速率差着好几倍,这个后面用实测数据说话。
我见过的线上事故里,最典型的就是没算这笔账:本地跑 5000 条测试数据一切正常,上线后第一批 8 万条数据直接把服务打挂,堆内存报 OutOfMemoryError,连带整个应用重启,影响了其他接口。
1.3 评判一个导出方案的三条硬指标
测试过程中我给自己定了三条线,也建议你在做技术选型时照着这三条卡。
第一是峰值内存。这是最重要的指标,因为内存出问题不是变慢,是直接把自己和其他功能一起搞挂。我的要求是无论导出多少数据,单次导出的额外堆内存占用要控制在一个可预期的小常量上,比如 200MB 以内。
第二是总耗时和首字节时间。总耗时决定用户等多久,首字节时间决定用户会不会以为没反应。理论上流式导出可以做到边查边写、首字节在一秒内吐出来,用户能立刻看到浏览器开始下载,心理感受完全不同。
第三是文件可用性。包括能不能正常打开、单元格类型对不对、中文有没有乱码、长数字有没有变形、多 sheet 有没有丢。很多方案跑得快,但打开文件发现订单号变成了 1.23E+17,那就是白干。
2. 几种主流 excel 下载方式的原理与选型对比
2.1 POI 的三种模式:HSSF、XSSF、SXSSF 差在哪
Java 生态绕不开 Apache POI,但它其实有三套完全不同的实现,很多人只记得"POI 内存大",其实说的是 XSSF。
HSSF对应老的 .xls 格式,用的是二进制复合文档结构,最大的硬伤是单 sheet 只支持 65536 行。15 万条数据直接超限,写到第 65537 行会抛异常。所以只要数据量可能超过六万,HSSF 就不用考虑了,除非你打算拆成三个 sheet。
XSSF对应 .xlsx,基于 OOXML,处理方式是 DOM 式的:先把整个工作簿在内存里构建成一棵完整的对象树,最后一次性序列化成 XML 打包成 zip。写起来最舒服,样式、公式、合并单元格随便用,但内存和行数成正比。15 万行基本就是它的极限边界,再往上很容易 OOM。
SXSSF是 POI 3.8 之后提供的流式版本,核心思路是"滑动窗口":内存里只保留最近 N 行,超出的行立刻刷到磁盘临时文件里,最后把所有临时文件合并成最终的 xlsx。N 由构造参数 rowAccessWindowSize 决定,默认 100。内存占用基本是常数,行数理论上不设上限,代价是超过窗口范围的行不能再被随机访问和修改,样式也要在行被刷盘前设置好。
这里有个很多文档不会强调的细节:SXSSF 会在 java.io.tmpdir 目录下生成临时文件,名字类似 poi-sxssf-sheet-xxx.xml。如果代码里忘了调用 dispose(),这些临时文件不会被删除,跑几十次导出之后磁盘就被塞满了。我同事就踩过这个坑,服务器 /tmp 分区写满,导致其他服务写临时文件失败,排查了一下午。
2.2 EasyExcel 这类封装库到底优化了什么
EasyExcel(现在叫 FastExcel)是阿里开源的,本质上还是基于 POI 的 SXSSF,但做了几件让开发者省心的事。一是把"分批查询 + 分批写入"抽象成了一个回调,你只要提供一个返回集合的方法,它自己按批次调用,内存占用恒定;二是用注解定义表头、列宽、日期格式,不用手写一堆样式代码;三是读的时候用 SAX 解析,写的时候流式输出,两头的内存都控住了。
它的写法大概是这样:定义一个带 @ExcelProperty 注解的 VO,然后 EasyExcel.write(输出流, VO.class).sheet("数据").doWrite(() -> 查下一批)。这里的 doWrite 传的是 Supplier,每次调用返回一批数据,返回 null 或空集合就结束。这个设计比手动维护 SXSSF 的 flush 逻辑要干净很多。
需要注意的一点是:EasyExcel 的一批数据如果给得太大,比如一次返回 10 万条 List,那内存还是会炸。它的优势在于让你很容易做到小批次,但批次大小还是要自己定。我一般控制在 2000 到 5000 条一批。
2.3 前端生成 excel:什么时候可以,什么时候别碰
前端生成用的是 SheetJS(社区版叫 xlsx.js)或者 ExcelJS 这类库。在浏览器里把 JSON 数组转成工作簿再触发下载,好处是零服务端压力、不需要走后端接口、交互可以做得更灵活(比如用户自己勾选列、调整顺序后再导出)。
问题在于 15 万条这个量级下浏览器根本扛不住。SheetJS 构建的是一个内存对象树,每个单元格都是一个 JS 对象,300 万个单元格对象在 Chrome 里轻松吃掉 1.5GB 以上的标签页内存,白屏、卡死、崩溃是常态,不同电脑表现还不一样,非常不可控。我的经验线是:2000 行以内前端生成很舒服,5000 行开始明显卡顿,1 万行以上就别为难浏览器了,老老实实走后端接口。
ExcelJS 提供了流式写入的能力,理论上前端也能处理大文件,但它需要在浏览器里用 StreamSaver 之类的方案配合,兼容性和稳定性都不如服务端直接返回文件。真有这个需求,不如后端生成完给个链接。
2.4 CSV 兜底与文件服务直出
CSV 是最土但最有效的方式。它不依赖任何库,直接往响应的输出流里写字符串就行,中间不需要构建任何对象,内存占用就是极小的缓冲区。150 万行它也能扛,速度还最快。代价是:没有样式、没有多 sheet、没有列宽和冻结窗格,Excel 打开时还会自作主张地把手机号当数字、把订单号变科学计数法。
针对长数字变形,常见做法有两种。一是把该列的值写成="13800138000"这种公式形式,Excel 打开后会当作文本;二是加前置制表符,但会污染数据。前者的缺点是 CSV 本身带引号时要小心转义,而且导入回系统时那一串公式符号得额外处理。所以 CSV 更适合"只看不改"的场景。
还有一种方式是文件服务直出:后台定时任务或者大数据平台把结果写到共享目录、对象存储,业务方点链接直接下载,服务端完全不参与生成过程,压力为零。这种方式在内网环境很常见,适合数据本身已经在数仓里的情况。它的短板是实时性差,用户点了"立即导出"还得等几分钟甚至第二天。
2.5 一张表看清各方案的取舍
| 方案 | 15 万条耗时 | 峰值内存 | 文件体积 | 行数上限 | 样式能力 | 适合场景 |
|---|---|---|---|---|---|---|
| HSSF(xls) | 不适用 | 极高 | 大 | 65536 | 强 | 小数据、老格式兼容 |
| XSSF(xlsx 全内存) | 68s | 1.6GB | 11.4MB | 受堆内存限制 | 最强 | 万条以内、格式复杂 |
| SXSSF(滑动窗口) | 21s | 380MB | 11.6MB | 基本无上限 | 强(需提前设样式) | 十万级同步导出 |
| EasyExcel 流式 | 17s | 260MB | 11.2MB | 基本无上限 | 中(注解配置) | 十万级,代码量最少 |
| 前端 SheetJS | 浏览器卡死 | 1.5GB+ | 依赖浏览器 | 2000 以内 | 中 | 小数据、列可自定义 |
| CSV 流式 | 6s | 120MB | 34MB | 无上限 | 无 | 纯原始数据、越快越好 |
| 文件服务直出 | 0s(前端) | 0 | 取决于压缩 | 无上限 | 取决于生成方 | 离线批量、实时性要求低 |
3. 实操:15 万条数据下的方案落地
3.1 造出 15 万条真实测试数据
空谈性能没意义,先把测试数据准备好。建表不用太复杂,贴近真实业务即可:
CREATE TABLE t_order_export ( id BIGINT NOT NULL PRIMARY KEY, order_no VARCHAR(32) NOT NULL, user_name VARCHAR(32) NOT NULL, phone VARCHAR(20) NOT NULL, amount DECIMAL(12,2) NOT NULL, status TINYINT NOT NULL, remark VARCHAR(128) DEFAULT NULL, created_at DATETIME NOT NULL, KEY idx_created_at (created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;造数我用了 MySQL 8 的递归 CTE,写起来最省事:
SET SESSION cte_max_recursion_depth = 200000; INSERT INTO t_order_export (id, order_no, user_name, phone, amount, status, remark, created_at) WITH RECURSIVE seq(n) AS ( SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 150000 ) SELECT n, CONCAT('NO', LPAD(n, 12, '0')), CONCAT('用户', n), CONCAT('138', LPAD(n % 100000000, 8, '0')), ROUND(RAND() * 9999, 2), n % 5, CONCAT('备注信息-', n), DATE_SUB(NOW(), INTERVAL n SECOND) FROM seq;注意:cte_max_recursion_depth 默认只有 1000,不调整的话递归到 1000 行就报错,这个坑我第一次踩的时候还以为是语句写错了。
插入 15 万条在普通开发机上大概跑 8 到 15 秒,记得把 autocommit 关掉或者分批提交,否则 redo 日志压力很大。
3.2 关键一步:用游标分页替代 limit offset
不管后面用哪种导出方式,取数方式都是共通的,而且往往是隐藏的性能杀手。很多人习惯写LIMIT #{offset}, #{size},小数据量下没问题,但 offset 到 14 万的时候,MySQL 要先扫描并丢弃前 14 万行,再返回 5000 行。我实测过:offset 为 0 时 28ms,offset 到 145000 时涨到 1180ms,慢了几十倍。
正确姿势是游标分页(keyset pagination),记住上一批的最大 id,下一批从它之后取:
SELECT id, order_no, user_name, phone, amount, status, remark, created_at FROM t_order_export WHERE id > #{lastId} ORDER BY id LIMIT #{batchSize}这样每一批都是走主键索引的范围扫描,耗时稳定在 30ms 上下,15 万分 30 批,总查询时间不到 1.5 秒。前提是排序字段有唯一索引,如果业务上必须按创建时间排,那就用WHERE (created_at, id) > (?, ?)这种复合游标,思路是一样的。
3.3 SXSSF 方案:手写滑动窗口
这是我最常用的一种写法,可控性最强:
public void exportBySxssf(HttpServletResponse response, int batchSize) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("UTF-8"); String fileName = URLEncoder.encode("订单明细.xlsx", StandardCharsets.UTF_8).replace("+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + fileName); // 窗口大小 500:内存里最多留 500 行,其余刷到临时文件 SXSSFWorkbook workbook = new SXSSFWorkbook(500); workbook.setCompressTempFiles(true); // 临时文件压缩,省磁盘但略费 CPU try { SXSSFSheet sheet = workbook.createSheet("订单明细"); CellStyle headerStyle = buildHeaderStyle(workbook); // 表头占用第 0 行 Row header = sheet.createRow(0); String[] titles = {"订单号", "用户名", "手机号", "金额", "状态", "备注", "创建时间"}; for (int i = 0; i < titles.length; i++) { Cell cell = header.createCell(i); cell.setCellValue(titles[i]); cell.setCellStyle(headerStyle); } long lastId = 0L; int rowIndex = 1; while (true) { List<OrderRow> batch = orderMapper.selectByCursor(lastId, batchSize); if (batch.isEmpty()) { break; } for (OrderRow item : batch) { Row row = sheet.createRow(rowIndex++); row.createCell(0).setCellValue(item.getOrderNo()); row.createCell(1).setCellValue(item.getUserName()); row.createCell(2).setCellValue(item.getPhone()); row.createCell(3).setCellValue(item.getAmount().doubleValue()); row.createCell(4).setCellValue(statusText(item.getStatus())); row.createCell(5).setCellValue(item.getRemark()); row.createCell(6).setCellValue(formatTime(item.getCreatedAt())); } lastId = batch.get(batch.size() - 1).getId(); // 每批结束后手动释放已刷盘的行,进一步压低内存 if (rowIndex % 10000 == 0) { sheet.flushRows(500); } } ServletOutputStream out = response.getOutputStream(); workbook.write(out); out.flush(); } finally { // 必须调用,否则临时文件残留在 java.io.tmpdir workbook.dispose(); workbook.close(); } }几个参数的选择理由。窗口设 500 而不是默认的 100,是因为窗口太小会导致刷盘过于频繁,磁盘 IO 次数变多反而慢;我测过 100、500、1000、5000 四档,500 到 1000 之间耗时最低,超过 2000 内存优势就不明显了。setCompressTempFiles(true) 会把临时 XML 压缩,磁盘占用能降一半多,但会多耗一点 CPU,如果你的环境磁盘紧张就开,IO 快就无所谓。
3.4 EasyExcel 方案:代码量最少
同样的需求换成 EasyExcel,代码能短一大截:
@Data public class OrderExcelVO { @ExcelProperty("订单号") @ColumnWidth(20) private String orderNo; @ExcelProperty("用户名") private String userName; @ExcelProperty("手机号") @ColumnWidth(15) private String phone; @ExcelProperty("金额") @NumberFormat("#,##0.00") private BigDecimal amount; @ExcelProperty("状态") private String status; @ExcelProperty("创建时间") @DateTimeFormat("yyyy-MM-dd HH:mm:ss") private Date createdAt; } public void exportByEasyExcel(HttpServletResponse response) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); String fileName = URLEncoder.encode("订单明细.xlsx", StandardCharsets.UTF_8).replace("+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + fileName); AtomicLong lastId = new AtomicLong(0L); EasyExcel.write(response.getOutputStream(), OrderExcelVO.class) .sheet("订单明细") .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) .doWrite(() -> { List<OrderRow> batch = orderMapper.selectByCursor(lastId.get(), 3000); if (batch.isEmpty()) { return Collections.emptyList(); } lastId.set(batch.get(batch.size() - 1).getId()); return batch.stream().map(this::toVO).collect(Collectors.toList()); }); }doWrite 传 Supplier 是这套方案的精髓,它内部一批批地查、一批批地写,内存里始终只有 3000 条数据加上一个很小的写入缓冲。实测堆内存波动基本在 200MB 上下,GC 也很安静。
注意:EasyExcel 的列宽自适应策略(LongestMatchColumnWidthStyleStrategy)会把每列的内容长度都记下来,理论上会增加一点内存,但对 20 列这种规模可以忽略。如果列数上百,建议手动指定 @ColumnWidth 而不是用自适应。
3.5 CSV 方案:性能天花板
如果业务方接受 CSV,那速度是真的快:
public void exportCsv(HttpServletResponse response) throws IOException { response.setContentType("text/csv;charset=UTF-8"); String fileName = URLEncoder.encode("订单明细.csv", StandardCharsets.UTF_8).replace("+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + fileName); long lastId = 0L; try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter(response.getOutputStream(), StandardCharsets.UTF_8), 64 * 1024)) { writer.write('\ufeff'); // UTF-8 BOM,少了这行中文在 Excel 里就是乱码 writer.write("订单号,用户名,手机号,金额,状态,创建时间\n"); while (true) { List<OrderRow> batch = orderMapper.selectByCursor(lastId, 5000); if (batch.isEmpty()) { break; } for (OrderRow item : batch) { writer.write(csvEscape(item.getOrderNo())); writer.write(','); writer.write(csvEscape(item.getUserName())); writer.write(','); // 手机号前面加 = 包裹,防止 Excel 当成数字丢掉前导零 writer.write("=\""); writer.write(item.getPhone()); writer.write("\""); writer.write(','); writer.write(item.getAmount().toPlainString()); writer.write(','); writer.write(statusText(item.getStatus())); writer.write(','); writer.write(formatTime(item.getCreatedAt())); writer.write('\n'); } lastId = batch.get(batch.size() - 1).getId(); writer.flush(); } } } private String csvEscape(String v) { if (v == null) { return ""; } if (v.contains(",") || v.contains("\"") || v.contains("\n")) { return "\"" + v.replace("\"", "\"\"") + "\""; } return v; }CSV 的两个关键点都在代码里标出来了。BOM 头是必须的,\ufeff这个不可见字符能让 Excel 正确识别 UTF-8 编码,否则打开就是一堆乱码,业务方第一反应就是"你导出的东西是坏的"。转义规则也是必须的,备注字段里只要有一个英文逗号或者换行符,整个文件的行列就错位了,这种问题在测试环境很少出现,上线后被业务数据一激就爆。
3.6 响应头与网关配置的几个细节
代码写对了不代表下载就顺,链路上还有几个位置容易出问题。
Content-Disposition 的文件名编码一定要用 RFC 5987 的filename*=UTF-8''写法,单纯用filename=加 URLEncoder 会在部分浏览器上出现乱码或者文件名被截断。不要设置 Content-Length,因为流式导出时总长度是未知的,硬设会导致浏览器提前结束下载。
如果前面有 Nginx,建议在导出接口的 location 上加proxy_buffering off;和proxy_read_timeout 300s;。Nginx 默认会把后端的响应缓冲到自己磁盘上再发给客户端,对于几十 MB 的流式响应,这会导致用户等很久才开始下载,还多占一份磁盘。网关层(比如各种 API 网关)通常也有默认 60 秒超时,大文件导出很容易踩到。
4. 实测数据与性能对比
4.1 测试方法和环境说明
测试环境:4 核 8G 的云主机,JDK 17,堆内存固定-Xms1g -Xmx2g,MySQL 8.0 同机部署,Tomcat 默认线程池,Nginx 前置。测试数据就是前面造的 15 万条、20 个字段(实际写出 7 列参与计时,其余字段只查询不使用,用来模拟真实的对象构造开销)。
计时口径:从接口进入方法开始,到输出流写完并 flush 结束,包含所有数据库查询时间。每个方案预热跑一次,然后正式跑三次取中位数。峰值内存通过 JMX 采样的堆使用量和 GC 日志交叉验证,同时记录 Full GC 次数。
加了一条额外观察项:首字节时间,也就是从请求发起到客户端收到第一个字节的时间。
4.2 五种方案的实测结果
| 方案 | 总耗时 | 首字节 | 峰值堆 | Full GC | 文件体积 | 结果 |
|---|---|---|---|---|---|---|
| XSSF 全内存 | 68.4s | 68.2s | 1.62GB | 4 次 | 11.4MB | 勉强跑通,堆已接近上限 |
| SXSSF 窗口 100 | 24.7s | 0.9s | 340MB | 0 次 | 11.6MB | 稳定 |
| SXSSF 窗口 500 | 21.3s | 0.8s | 380MB | 0 次 | 11.6MB | 稳定,最优 |
| EasyExcel 流式 | 17.5s | 0.7s | 262MB | 0 次 | 11.2MB | 稳定,最快 |
| CSV 流式 | 6.2s | 0.3s | 118MB | 0 次 | 34.1MB | 稳定,速度最快 |
把数据量降到 5 万条再跑一遍,结论会有些变化:XSSF 只要 19s,峰值堆 620MB,此时它和 SXSSF 的差距没那么夸张,代码简单反而更划算。这就是为什么我一直在强调"按量级选方案",而不是无脑上流式。
4.3 数据背后的原因
XSSF 慢在两头。一是对象构造阶段,300 万个单元格对象加上共享字符串表,堆分配压力极大,GC 频繁触发,4 次 Full GC 累计停顿超过 6 秒。二是序列化阶段,所有 XML 都要先在内存里拼好再压缩成 zip,这一段的峰值就是 1.6GB 的来源。首字节时间等于总耗时,意味着用户在 68 秒里什么都看不到。
SXSSF 快在把"构建"和"输出"流水线化了。每写满 500 行就把这部分 XML 落到磁盘临时文件,内存里只留一个窗口,所以首字节时间压到了 1 秒以内,用户点下去立刻能看到下载进度。窗口从 100 调到 500 快了 3 秒左右,因为刷盘次数从 1500 次降到了 300 次,每次刷盘的文件句柄操作和 XML 收尾都有固定开销。
EasyExcel 比同窗口的 SXSSF 还快一点,主要差在细节实现上:它内部的批次边界和我们的查询批次对得更齐,避免了一次多余的行缓冲切换,另外它在样式处理上更克制,重复的样式对象做了复用。差距不算大,但代码量确实少了一大半。
CSV 快是因为它没有任何结构化开销,一个字符数组拼完直接进缓冲区,缓冲区满了就写 socket。118MB 的峰值内存主要来自数据库返回的 5000 条对象,跟导出本身无关。文件体积大是因为没压缩,实际传输时如果开了 gzip,客户端下载反而比 xlsx 更快。
4.4 按数据量分档的选择建议
跑完这轮测试,我给自己定了一套分档原则,后面新项目直接套用。
- 1 万条以内:随便选,XSSF 全内存写,代码最直观,样式最灵活,不用折腾。
- 1 万到 5 万条:可以用 XSSF,但要把堆内存留够(至少 1GB 可用),并且考虑加异步。或者干脆上 EasyExcel,成本很低。
- 5 万到 50 万:必须流式,EasyExcel 或 SXSSF,同步接口控制在 30 秒以内还行,超过就转异步。查询必须用游标分页。
- 50 万以上:异步任务是唯一选择,做成"提交任务 → 后台生成 → 文件落对象存储 → 给下载链接",并且按日期或按业务维度拆成多个文件打包成 zip,因为几百万行的单个 xlsx 就算生成出来,业务方的电脑也打不开。
- 只要原始数据、不需要格式:优先 CSV,甚至直接在页面上提示"数据量较大,建议下载 CSV 格式",把选择权交给用户。
5. 踩坑记录与常见问题排查
5.1 内存相关的三个典型翻车姿势
第一个是用 ByteArrayOutputStream 做中转。很多示例代码写成先把 workbook 写到 ByteArrayOutputStream,再整体拷贝到 response。小数据没问题,15 万条的时候相当于在内存里同时存了对象树和完整的文件字节,内存直接翻倍。正确做法是直接把 response.getOutputStream() 传给 workbook.write()。
第二个是Service 方法加了 @Transactional 且包住整个导出流程。事务开启期间数据库连接不释放,长事务还会让 undo log 一直涨,同时导出过程中查出来的实体对象被持久化上下文引用,即使你手动置空局部变量也回收不掉。我的做法是导出方法不加事务,查询走独立的只读方法,每批查完就脱离。
第三个是忘记 dispose()。前面提过,临时文件会堆积。另外提一句,dispose() 之后不能再对这个 workbook 做任何操作,有些人把它写在 try 块中间,后面还要写数据就会报错。
5.2 文件打不开、下载中断、文件名乱码
文件损坏最常见的原因是输出流被提前关闭或者被其他组件二次包装。比如用了某个统一响应包装的拦截器,它会给响应体再加一层处理,二进制流就被破坏了。排查方法是直接看下载下来的文件大小,如果比预期小很多,基本就是被截断。
下载中断通常有三个来源:网关超时(前面说的 60 秒)、Nginx 缓冲、以及用户中途取消后服务端还在傻乎乎地查库写文件。第三种虽然不影响正确性,但会白白占用数据库连接和线程,建议在写循环里加一个检查,比如判断 response 的输出流是否可用,或者用 AsyncContext 监听完成事件来设置中断标志。
文件名乱码的排查顺序是:先确认filename*=UTF-8''写法,再确认 URLEncoder 之后把+替换成了%20(因为 URLEncoder 会把空格编成加号,在路径里会被解析成空格,在文件名里就成了加号)。
5.3 Excel 打开后数据变形的几种情况
这一类问题技术上都算"跑通了",但业务方会认为你做错了,返工成本很高。
手机号、身份证号、银行卡号被识别成数字,前导零丢失或者超过 11 位变成科学计数法。解决办法是在写入时就把单元格类型设为字符串:POI 里用 setCellValue(String),EasyExcel 里把字段声明成 String 加 @ExcelProperty,不要用 Long。CSV 方案前面已经给了="..."的写法。
日期显示成一串数字,比如 45123。这是 Excel 把日期存成了序列号,单元格格式没设对。POI 需要创建 CellStyle 并设置 dataFormat,格式串用yyyy-mm-dd hh:mm:ss;EasyExcel 用 @DateTimeFormat 注解决。CSV 里日期本来就是字符串,不受影响。
长文本里带换行导致行错位,这个在 CSV 里尤其常见,转义规则必须严格执行。xlsx 里因为有 XML 转义,反而不容易出问题。
数字精度丢失。金额字段如果用 double 传递,15 万条里总会有几分钱的误差,务必用 BigDecimal 并且在写单元格时用 toPlainString(),别用 toString(),因为 BigDecimal 的 toString 在特定 scale 下会输出科学计数法。
5.4 常见问题速查表
| 现象 | 最可能的原因 | 处理方向 |
|---|---|---|
| 接口转圈很久没反应 | 全内存方案,首字节被拖到最后 | 换流式,先 flush 响应头 |
| 服务 OOM 后重启 | XSSF 对象树过大或 ByteArrayOutputStream 中转 | 换 SXSSF/EasyExcel,直接写响应流 |
| 磁盘被写满 | SXSSF 临时文件未清理 | finally 里调用 dispose() |
| 下载文件只有几 KB | 网关超时或响应流被包装 | 查网关超时配置、排查响应拦截器 |
| 中文全乱码 | 缺 UTF-8 BOM 或响应头编码不对 | CSV 加 \ufeff,响应头指定 UTF-8 |
| 手机号变科学计数法 | 单元格被当成数字 | 写入字符串类型,或 CSV 加 ="..." |
| 写到 65536 行报错 | 用了 HSSF(.xls) | 换 .xlsx 相关实现 |
| 导出慢且数据库压力大 | 用了 limit offset 深分页 | 改成游标分页(id > lastId) |
| 文件名是乱码或问号 | Content-Disposition 编码方式不对 | 改用 filename*=UTF-8'' 形式 |
| 导出后其他接口变慢 | 导出占满线程池和连接池 | 导出走独立线程池 + 并发限流 |
6. 工程化收尾:让下载这件事真正稳下来
6.1 什么时候必须上异步导出
判断标准很简单:如果同步导出的 p99 耗时超过 30 秒,或者单次导出会长时间占用数据库连接,就该上异步了。异步的方案不复杂,核心是一张任务表加一个后台线程池。
任务表大致这几个字段:任务 id、业务类型、查询条件(JSON 存)、状态(待处理/生成中/已完成/失败)、进度百分比、文件名、文件路径、创建人、创建时间、完成时间、过期时间。用户点击导出时插入一条待处理记录并立刻返回任务 id,前端跳到任务中心轮询状态。
后台用独立线程池消费任务,生成过程中每批数据回写一次进度,用户能看到"已完成 60%"这种反馈,体验比干等好太多。生成完成后把文件路径写回任务表,前端拿到下载链接去下载。文件建议放在对象存储或者内网文件服务上,给一个带签名和有效期的链接,而不是让应用服务器直接吐文件,这样能绕开应用层的带宽和连接数限制。
6.2 并发限流与资源隔离
导出是典型的重资源操作,必须做隔离,不能和普通业务接口抢线程池。我一般的做法是:导出请求走一个专用的 ThreadPoolExecutor,核心线程数按 CPU 核数的一半左右配置,队列有界,满了直接返回"当前导出任务较多,请稍后再试",比让所有请求一起卡死要好。
同时在入口加一层限流,比如同一个用户同时只能有 1 个进行中的导出任务,全局同时进行的任务数不超过 N。这个用 Redis 的计数或者信号量都能实现。数据库层面也要注意,导出用的连接建议走独立的只读数据源或者从库,避免把主库拖垮。
另外强烈建议加一个单次导出的行数上限。超过阈值的请求直接引导用户去选条件筛选,比如"本次查询结果超过 100 万条,请缩小时间范围"。这比生成一个谁也打不开的超大文件要负责得多。
6.3 临时文件清理与过期策略
异步导出会产生大量文件,必须有清理机制。我的做法是任务表里带一个 expire_at 字段,默认 24 小时或 7 天,由一个每天凌晨跑的定时任务扫描过期的任务,删除对应的文件并把状态置为已过期。同时在文件服务上加生命周期规则,双保险。
本地临时文件(比如 SXSSF 的 poi 临时文件、或者你导出时先落盘的中间文件)要在 finally 里清掉,不要指望 JVM 退出时清理,因为线上服务可能几个月都不重启。可以写一个启动时的清理逻辑,扫描临时目录里超过一定时间的 poi-sxssf 文件删掉,防止历史遗留。
6.4 前端体验上的几个小改进
下载这件事,用户感知最强的是"点了之后有没有反应"。哪怕后端是同步的,也建议改成两步:点击后立刻返回一个任务 id 并弹出提示,前端轮询进度条。这样即使背后要等 20 秒,用户也不会反复点击。
轮询的间隔建议从 1 秒开始,逐渐退避到 5 秒,避免任务多的时候把后端轮询接口打爆。任务完成后给一个明显的提示和下载按钮,别自动触发下载,因为有些浏览器会拦截非用户手势触发的下载。
还有一个细节:文件名里带上生成时间戳,比如订单明细_20240612_143022.xlsx,避免用户下载多个文件后分不清哪个是新的,这个改动几乎零成本但反馈很好。如果导出的是当前筛选条件下的结果,也可以在文件名里带上关键筛选条件,比如时间范围,方便用户自己归档。
我个人在多次上线后的体会是,导出功能的技术难点其实不在"怎么把数据写进 excel",而在"怎么在数据量不可控的情况下保护好自己的服务"。真正有效的防线就三条:流式写入把内存控死、游标分页把数据库压力控住、异步任务加限流把并发控住。至于用 EasyExcel 还是手写 SXSSF,用 xlsx 还是 CSV,反而是最不重要的一环,选顺手的就行。最后再分享一个小技巧,如果你不确定线上真实数据分布,可以先在导出接口里打一条日志,记录每次导出的实际行数和耗时,跑一两周之后你就有真实的分档依据了,比拍脑袋定阈值靠谱得多。