尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

15万条Excel导出选型:POI、SXSSF、EasyExcel、CSV实测对比

15万条Excel导出选型:POI、SXSSF、EasyExcel、CSV实测对比 做过后台系统的人早晚都会被同一个需求找上门把库里的数据导成 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.23E17那就是白干。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 万条耗时峰值内存文件体积行数上限样式能力适合场景HSSFxls不适用极高大65536强小数据、老格式兼容XSSFxlsx 全内存68s1.6GB11.4MB受堆内存限制最强万条以内、格式复杂SXSSF滑动窗口21s380MB11.6MB基本无上限强需提前设样式十万级同步导出EasyExcel 流式17s260MB11.2MB基本无上限中注解配置十万级代码量最少前端 SheetJS浏览器卡死1.5GB依赖浏览器2000 以内中小数据、列可自定义CSV 流式6s120MB34MB无上限无纯原始数据、越快越好文件服务直出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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;造数我用了 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 时 28msoffset 到 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) { ListOrderRow 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(() - { ListOrderRow 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;charsetUTF-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) { ListOrderRow 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 -Xmx2gMySQL 8.0 同机部署Tomcat 默认线程池Nginx 前置。测试数据就是前面造的 15 万条、20 个字段实际写出 7 列参与计时其余字段只查询不使用用来模拟真实的对象构造开销。计时口径从接口进入方法开始到输出流写完并 flush 结束包含所有数据库查询时间。每个方案预热跑一次然后正式跑三次取中位数。峰值内存通过 JMX 采样的堆使用量和 GC 日志交叉验证同时记录 Full GC 次数。加了一条额外观察项首字节时间也就是从请求发起到客户端收到第一个字节的时间。4.2 五种方案的实测结果方案总耗时首字节峰值堆Full GC文件体积结果XSSF 全内存68.4s68.2s1.62GB4 次11.4MB勉强跑通堆已接近上限SXSSF 窗口 10024.7s0.9s340MB0 次11.6MB稳定SXSSF 窗口 50021.3s0.8s380MB0 次11.6MB稳定最优EasyExcel 流式17.5s0.7s262MB0 次11.2MB稳定最快CSV 流式6.2s0.3s118MB0 次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:ssEasyExcel 用 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反而是最不重要的一环选顺手的就行。最后再分享一个小技巧如果你不确定线上真实数据分布可以先在导出接口里打一条日志记录每次导出的实际行数和耗时跑一两周之后你就有真实的分档依据了比拍脑袋定阈值靠谱得多。
返回列表