ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

百万数据Excel导出OOM?用EasyExcel+流式查询彻底解决

百万数据Excel导出OOM?用EasyExcel+流式查询彻底解决 先说个真实场景。运营同学下午跑过来“订单明细导出一百多万行点击导出直接下载失败。”我看了一眼日志——OutOfMemoryError: Java heap space堆内存直接撑爆。再翻代码老系统用的 Apache POI 一把梭把查出来的数据全部塞进内存生成 workbook数据量从几万涨到百万之后这玩法注定要炸。后来我把这一整套方案重写成 EasyExcel 流式查询压测过百万行导出的内存曲线也沉淀了一个通用增强工具类放到公共包里用了大半年没再出过事。这篇就把原理、完整代码、参数调优、踩坑记录一次讲清楚后端做报表导出、Excel 导入导出的同学可以直接参考尤其是那些数据量动不动上百万、又不想给服务加内存的场景。1. 先从一次线上OOM说起1.1 事故现场还原当时那个导出功能的内部实现很简单Mapper 一次性查出所有订单数据ListOrder塞进 POI 的XSSFWorkbook然后整体写出到 response。50 万行以内勉强能跑虽然慢但不至于崩。100 多万行、50 列一瞬间堆里的对象数量就爆炸了。我看了下当时的监控GC 日志里老年代持续打满Full GC 一次接一次最后直接java.lang.OutOfMemoryError。服务本身堆开了 2GB要是导两个 Sheet、再叠加导出高峰期和其他接口占用这个堆根本兜不住。这类问题很容易演变成“加内存重启”的恶性循环。今天 2G 能扛明天数据涨到 200 万行照样完蛋。治本的方向只有一个别让所有数据同时驻留在内存里。1.2 为什么POI会撑爆内存DOM模型的锅POI 的XSSFWorkbook用的是 DOM 模型可以理解为盖房子先在内存里把整栋楼的地基、框架、每一块砖、每一扇窗全部搭好封顶之后再把整栋楼搬到客户面前。数据量小的时候这种“全量构建”没问题数据量上百万之后你等于是要求 JVM 在内存里同时容纳几千万个对象。一个单元格在 POI 里是独立对象有自己的类型、样式引用、值对象几十个字节起步。100 万行 × 50 列 5000 万个单元格对象每个对象按 80 字节算光裸对象就 4GB再算上 List 结构、字符串驻留、样式表内存不爆才是怪事。这不是代码写法的问题是模型选型的问题。只要还用 DOM 全量构建的方式再怎么优化循环、调 JVM 参数都改变不了“数据全在内存”这个事实。1.3 EasyExcel做对了什么SAX模型与逐行写出EasyExcel 能解决这个问题核心在于它走的是事件驱动模型读文件的时候用 SAX 逐个解析 XML 标签写文件的时候一格格生成 XML 片段边生成边往外输出内存里只保留当前正在处理的那一小批数据。和盖楼类比反过来看它更像流水线一块砖运过来抹上水泥放到墙上继续下一块不囤料。xlsx 本质上是 zip 包里面是多个 XMLEasyExcel 在序列化每一行的时候写完整行就交给输出流自身只维护一小块行缓冲。但这里有个容易误会的点EasyExcel 不是不让 OOM而是把内存开销降到了可以接受的范围。如果代码里做一次全表查询取回百万行对象放到 List再一次性传给 EasyExcel内存照样会炸。组件只是基础数据源怎么取、数据怎么给才是真正的战场。2. 百万导出怎么设计核心思路与数据源2.1 全量查询是万恶之源数据要“流式”地来先说结论百万数据导出的唯一正确思路是让数据“流式地来批量地写”。查询侧不能一次性把所有数据 load 到堆里写入侧不能一次性把整个 Sheet 构建完。我最初踩过一个反面教材只把 POI 换成了 EasyExcel查询还是select * from t_order一把全查数据全塞在 List 里结果照样 OOM。所以说“换 EasyExcel 就不爆内存”是伪命题真正的关键在于两点数据源要做流式或分页处理永远不让一个 List 持有几十万以上的对象写入要做分批 flush写一批释放一批让 EasyExcel 内部的缓存有节奏地刷出去2.2 MyBatis游标查询的正确姿势MyBatis 的Cursor是最合适的流式查询方案。它不一次性把结果集载入内存而是通过 JDBC ResultSet 一行行取配合 MySQL 的流式读取模式能很自然地把“查询”变成“逐条消费”。下面是一个典型写法Transactional(readOnly true) public void scanOrders(ConsumerOrder consumer) { try (CursorOrder cursor orderMapper.scanAllForExport()) { cursor.forEach(order - consumer.accept(order)); } }有几个细节新手特别容易踩第一这个方法必须有事务。Cursor依赖 MyBatis 的 SqlSession 保持打开状态一旦事务结束session 关闭ResultSet 就失效了迭代到一半直接报“ResultSet is closed”。所以要么方法上加Transactional要么整个导出流程包在一个事务模板里。第二要覆盖 MySQL 连接参数的设置。只写上面的代码还不够老版本驱动默认是把结果全部拉到客户端。为了让真正进入流式读取模式需要在 JDBC URL 上做配置jdbc-url: jdbc:mysql://your-host:3306/your-db?useCursorFetchtruedefaultFetchSize10000useCursorFetchtrue开启服务端游标defaultFetchSize控制每次从服务端捞多少行。这样客户端内存里永远只有 1 万个对象而不是百万个。第三Cursor用完必须关闭。它实现了AutoCloseable务必用 try-with-resources。不关的话连接会一直挂着数据库会话数飙升过不了多久连接池就报警。2.3 没有游标也能跑分段分页查询不是所有场景都能开游标。比如用的数据库中间件不支持流式读取、或者 ORM 封装太死、又或者要和现有查询逻辑复用这时候可以退而求其次用“分段分页”代替深分页。核心思路是以主键或唯一有序键作为分段的游标每次取一批小于某个 ID 的数据。long lastId 0L; int batchSize 10000; while (true) { ListOrder page orderMapper.selectPageByLastId(lastId, batchSize); if (page.isEmpty()) { break; } for (Order order : page) { // 转 Map 并写入 Excel 的逻辑 } lastId page.get(page.size() - 1).getId(); }对应的 SQL 大概是这样的SELECT id, order_no, user_name, amount, status, create_time FROM t_order WHERE id #{lastId} ORDER BY id LIMIT #{batchSize}为什么不用LIMIT offset, size因为深分页越往后越慢。取第 50 万条时数据库要扫描并丢弃前面的 50 万行CPU 和 IO 都浪费在“跳过”上。用 ID 游标这种形式每次扫描的只是本批次的数据查询速度全程稳定。注意排序字段必须有索引否则 ORDER BY 全表排序照样慢。3. 增强工具类开箱即用的完整实现3.1 设计目标与接口划分每次直接把 EasyExcel 写进业务代码多少有点重复劳动定义表头、设置列宽、循环切 Sheet、处理格式、最后还要 finish。踩了几次坑之后我把这些细节收敛成一个工具类目标是让调用方只关心两件事一列长什么样key、表头、宽度数据怎么查到一个生产数据的回调工具类内部负责创建 Writer、维护 Sheet 行数上限、自动切换 Sheet、注册列宽策略。整个结构分成四个部分ExcelColumn列定义BatchDataProducer数据生产者函数式接口ColumnWidthWriteHandler一次性列宽策略EasyExcelExporter门面类导出入口3.2 核心代码一览列定义类简单清晰public class ExcelColumn { /** Map 中的 key */ private String key; /** 表头显示名称 */ private String title; /** 列宽单位是字符数默认 20 */ private int width 20; public ExcelColumn(String key, String title) { this.key key; this.title title; } public ExcelColumn(String key, String title, int width) { this.key key; this.title title; this.width width; } // getter/setter 省略 }数据生产者接口函数式接口让调用方在方法内部自行实现流式或分页查询FunctionalInterface public interface BatchDataProducer { /** * 实现方负责查询数据并通过 batchConsumer 分批发出去。 * 每次调用 accept工具类会把这一批数据写入当前 Sheet * 并自动判断是否达到单 Sheet 行数上限决定是否换 Sheet。 */ void produce(ConsumerListMapString, Object batchConsumer); }列宽策略在创建 Sheet 时一次性计算好不在写入过程中反复测量public class ColumnWidthWriteHandler implements WriteHandler { private final int[] columnWidths; // 单位字符数 public ColumnWidthWriteHandler(int[] columnWidths) { this.columnWidths columnWidths; } Override public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) { for (int i 0; i columnWidths.length; i) { writeSheetHolder.getSheet().setColumnWidth(i, columnWidths[i] * 256); } } }核心导出器自动创建 Sheet、自动切换、按批写入public class EasyExcelExporter { public static void export(OutputStream outputStream, ListExcelColumn columns, int maxRowPerSheet, BatchDataProducer dataProducer) { if (columns null || columns.isEmpty()) { throw new IllegalArgumentException(columns must not be empty); } ExcelWriter writer EasyExcel.write(outputStream).build(); try { AtomicInteger sheetIndex new AtomicInteger(0); AtomicInteger rowCount new AtomicInteger(0); WriteSheet currentSheet null; dataProducer.produce(batch - { if (batch null || batch.isEmpty()) { return; } if (batch.size() maxRowPerSheet) { throw new IllegalArgumentException( batch.size() must not exceed maxRowPerSheet, current: batch.size() , maxRowPerSheet: maxRowPerSheet); } if (currentSheet null || rowCount.get() batch.size() maxRowPerSheet) { currentSheet createWriteSheet(sheetIndex.getAndIncrement(), columns); rowCount.set(0); } writer.write(batch, currentSheet); rowCount.getAndAdd(batch.size()); }); } finally { writer.finish(); } } private static WriteSheet createWriteSheet(int index, ListExcelColumn columns) { ListListString head new ArrayList(); for (ExcelColumn column : columns) { head.add(Collections.singletonList(column.getTitle())); } int[] widths new int[columns.size()]; for (int i 0; i columns.size(); i) { widths[i] columns.get(i).getWidth(); } return EasyExcel.writerSheet(index, Sheet (index 1)) .head(head) .registerWriteHandler(new ColumnWidthWriteHandler(widths)) .build(); } }这里有几个设计要点WriteSheet在循环外创建与复用。很多人会让每一批数据都 new 一个WriteSheet那样表头会被重复写多次文件彻底废掉。工具类只在需要切 Sheet 时才创建新的否则一直复用同一个实例。切 Sheet 的判断条件是rowCount.get() batch.size() maxRowPerSheet而不是。因为不知道下一批具体多大留出余量宁可让当前 Sheet 少写几行也不要单 Sheet 冲到上限被 Excel 格式拒收。writer.finish()放在 finally 里。如果 producer 执行过程中抛了异常至少能保证 Writer 的资源被正确释放。这个细节看着小漏了之后连接和临时文件一直占着迟早出问题。3.3 用工具类导出一百万订单给出一个完整的调用示例。这里用的是 MyBatis 游标查询加上订单导出的字段映射。Transactional(readOnly true) public void exportOrders(HttpServletResponse response) throws IOException { ListExcelColumn columns Arrays.asList( new ExcelColumn(orderNo, 订单号, 26), new ExcelColumn(userName, 用户, 16), new ExcelColumn(amount, 金额, 14), new ExcelColumn(status, 状态, 12), new ExcelColumn(createTime, 下单时间, 22) ); response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); String fileName URLEncoder.encode(订单明细, UTF-8).replaceAll(\\, %20); response.setHeader(Content-Disposition, attachment;filename*utf-8 fileName .xlsx); EasyExcelExporter.export(response.getOutputStream(), columns, 800000, batch - { try (CursorOrder cursor orderMapper.scanAllForExport()) { ListMapString, Object rowBatch new ArrayList(10000); for (Order order : cursor) { MapString, Object row new LinkedHashMap(); row.put(orderNo, order.getOrderNo()); row.put(userName, order.getUserName()); row.put(amount, order.getAmount() null ? : order.getAmount().toPlainString()); row.put(status, OrderStatusEnum.desc(order.getStatus())); row.put(createTime, order.getCreateTime() null ? : DateUtils.format(order.getCreateTime(), yyyy-MM-dd HH:mm:ss)); rowBatch.add(row); if (rowBatch.size() 10000) { batch.accept(rowBatch); rowBatch new ArrayList(10000); } } if (!rowBatch.isEmpty()) { batch.accept(rowBatch); } } }); }这里有几个细节刻意处理过金额字段用BigDecimal.toPlainString()而不是直接放BigDecimal或 double。直接放 double 很容易出现科学计数法和精度丢失。金额这种事宁可输出成字符串也别在 Excel 里变成“1.2345678901E8”这种用户看不懂的东西。状态字段转成枚举描述。导出的文件大部分时候是给运营看的不是给程序读的把 0、1、2 翻译成“待支付、已支付、已退款”能少一大堆沟通成本。时间字段统一格式化。Map 模式下 EasyExcel 不会帮你做复杂的日期转换直接传格式化后的字符串最稳也避免了时区和格式不一致的问题。maxRowPerSheet设成 800000 而不是 1048576。这个数字是 Excel 单 Sheet 的行数硬上限留出余量是为了防止边缘数据恰好超出导致整个文件打不开。后面第 5 章还会细说。3.4 使用时的3个强制约定第一行数据必须是LinkedHashMap。EasyExcel 写ListMap时不是按 key 映射表头的而是按 Map 的插入顺序把值填进单元格。用HashMap会导致列顺序随机表头和数据对不上。这个坑我在封装第一次给同事用时立刻踩到后来干脆在注释里加了醒目标记。第二每行 Map 的 key 插入顺序必须和columns列表一致。因为上面说了Map 模式是纯顺序匹配key 只是用来表意的顺序错了就错位。第三单个 batch 的大小绝对不能超过maxRowPerSheet。工具类里已经做了这个防御会直接抛异常但最好在 producer 实现里就控制好批次大小别等运行到一半才报错。4. 列宽、样式和格式细节里藏着性能4.1 自动列宽是个陷阱很多新手图省事直接注册 EasyExcel 的LongestMatchColumnWidthStyleStrategy希望所有列宽自动匹配最长内容。这个策略的原理是写入每一行的时候都去计算这一列出现过的最长字符数然后不断调整。数据量小一切都好。数据量上百万的时候自动列宽意味着每一行数据都要做遍历和比较CPU 消耗成倍增加还会产生大量临时对象推高 GC 频率。我实测过40 万行数据开自动列宽导出时间能慢一倍还多得不偿失。正确做法就是工具类里那样创建 Sheet 时根据业务预设宽度一次性setColumnWidth。宽度值可以根据业务经验拍订单号 26 个字符用户名 16 个字符日期时间 22 个字符基本都能覆盖实际内容。4.2 样式越少导出越快EasyExcel 支持很丰富的样式表头加粗、单元格背景色、边框、字体、对齐方式样样都能配。但每个CellStyle都是一个对象创建和保存都会占内存xlsx 文件里还得维护样式映射表。大数据量导出时如果给每个数据单元格都单独设置样式内存和体积双双爆表和 POI 当年全量构建没有本质区别。我的经验是表头可以加一个统一的样式数据行全部用默认样式。表头一个样式对象覆盖整张表的所有表头单元格开销很小。数据行超过几万行以后默认样式反而是最安全的。合并单元格也要谨慎。需要按某个字段分组加合并效果的如果待合并的单元格数量上万合并计算本身会占用不少内存而且合并与流式写入的天然节奏是冲突的。真要做模板中的复杂样式建议走 EasyExcel 的模板填充方式而不是动态生成时硬做样式。4.3 数字、日期、长ID的格式陷阱大数据导出最常见的三类“看起来没问题用户打开就炸”的格式坑我按踩坑次数排序第一是长 ID 变科学计数法。Excel 对超过 15 位的纯数字字符串会默认转成科学计数法并且从第 16 位开始精度归零。订单号、流水号这类超过 15 位的 ID如果直接放 Long 或数字用户看到的就是8.28123E17。解决办法就是示例里那样把 ID 转成字符串写出。第二是金额精度丢失。double 在 Java 里本身就不是精确的十进制写进 Excel 后用户看到的又是一长串。处理方式要么用BigDecimal.toPlainString()转字符串要么用 EasyExcel 的NumberFormat注解但 Map 模式下注解不方便直接转字符串最省事。第三是时间格式乱套。Date 类型在不同版本的 EasyExcel 里默认格式可能不同有的写出来是时间戳有的是yyyy-MM-dd HH:mm:ss但时区不对。用字符串格式化后写出彻底绕开这些差异。5. 常见问题与排查技巧实录5.1 Excel写穿1048576行Excel 的底层格式决定了单个工作表最多 1048576 行这是xlsx格式本身的硬限制EasyExcel 也没办法突破。百万行听起来离上限还有距离但如果你误算了两批数据、或者在切换 Sheet 时判断条件写错很容易悄悄就超过这个数字。表现的形式是文件生成过程完全正常但写完的 Excel 打开报“文件已损坏”或者“无法打开”。因为行数超过了规范允许的范围XML 内部记录的行索引已经越界。工具类里的自动切换 Sheet 就是为这个设计的。maxRowPerSheet只是一个阈值内部会在这个阈值之下自动建新 Sheet。你甚至可以设成 100 万整最大批次控制在 1 万也能安全跑完 100 万行数据只是没有余量边缘情况容易踩线。务实一点设 80 万到 90 万最稳。5.2 游标数据没查全游标查询经常出现一种假象程序不报错但导出的数据量比总记录少或者数据在迭代过程中断。我排查过的最小众的一个原因是嵌套查询把连接占用了。游标迭代期间ResultSet 底层连接是被占用的。如果在cursor.forEach里又去执行了别的 SQL而连接池正好只剩一个连接那么第三方的查询永远拿不到连接直接超时或阻塞。规避方法就是游标迭代过程中不要做任何数据库二次查询。字段翻译、状态枚举转换这些操作都应该是纯内存计算。另一个常见原因是Transactional没加。前面说过Cursor 依赖 SqlSession 打开状态事务一旦提前结束迭代到一半连接就没了数据自然“只导出一部分”。完整的排查清单我放在下面的速查表里。5.3 导出一半sql报错怎么办这是流式导出的一个“原罪”级问题response 一旦开始写文件流HTTP 状态码就被固定为 200此时如果业务代码抛异常你没办法再把状态码改成 500也没办法返回 JSON 错误信息给前端。用户拿到的就是一个写了一半的损坏文件。我踩过这个坑之后调整了策略核心场景走异步导出先落临时文件全部成功后再把文件路径暴露给下载接口。同步导出只保留给数据量可控、且前置校验充分的场景。如果是异步导出问题就变成“导出任务失败之后怎么告诉用户”这个比较简单任务表里维护一个 status 字段失败时记录错误原因前端轮询看到失败就提示用户重新发起。5.4 导出问题速查表现象原因排查/解法导出文件打不开提示损坏单 Sheet 行数超过 1048576限制 maxRowPerSheet增加自动切 Sheet 逻辑数据只导出一部分不报错游标查询没有事务包裹连接提前释放整个导出流程包进 Transactional列顺序和表头对不上行数据用了 HashMap改用 LinkedHashMap保证插入顺序与 columns 一致导出速度越来越慢深分页 offset 过大改成主键游标分段查询导出过程 CPU 飙高开了自动列宽策略去掉自动列宽创建 Sheet 时一次设置长数字变成科学计数法数字以 Long 类型直接写转字符串后再写出导出一半报错但用户收到 200 状态response 已经写了文件流改为异步落临时文件 下载接口6. 从能用走向好用导出能力的工程化6.1 异步导出别让请求傻等百万数据导出哪怕流式优化已经很到位也至少需要几秒到十几秒才能完成。如果直接在 HTTP 请求里同步等很容易触发网关超时、浏览器超时用户体验很差。异步导出的最小实现就是加一张任务表。表里记录任务 ID、用户 ID、状态、失败原因、文件路径、过期时间。发起导出时先查一下有没有同类型未完成的任务防止重复提交然后往线程池丢一个任务立刻返回任务 ID。前端拿这个 ID 轮询状态状态变成 completed 就给出下载按钮。线程池也要独立于业务线程池单独配置核心线程数。我这边给的是核心 2、最大 4、队列 500拒绝策略是 CallerRuns避免瞬间大量导出请求把线程池打爆。6.2 任务状态与文件回收异步导出的文件是实打实占磁盘的。一个 100 万行的 xlsx体积随便就是几十到一百多 MB如果不做清理几天就能把磁盘写满。最简单的做法是任务表里记一个过期时间定时任务每小时扫一次删除过期记录对应的物理文件再把任务标记成 expired。另外下载接口要对文件路径做鉴权防止用户通过拼接路径下载别人的导出文件。这个细节容易被忽略但真出问题就是数据泄露级别的安全事故。6.3 并发导出的正确姿势EasyExcel 的ExcelWriter不是线程安全的同一个 Writer 不能在不同线程里同时写。所以不要尝试“多线程写同一份 Excel”。我遇到不少人来问“能不能多线程提升导出性能”答案是可以做但要在数据查询阶段做而不是写入阶段。DB 查询往往是整个导出链路里最耗时的环节如果分页区间之间没有交叉可以并发放到多线程里各自查询然后把结果按顺序喂给串行 Writer。具体做法就是把BatchDataProducer.produce里的查询部分拆成并发请求收集回来之后仍然按批次顺序batch.accept。写入保持串行既安全又确实能压缩总时长。一定要避免的思路是多个线程各自创建 Writer写不同的 Sheet最后想合并成同一个 Excel 文件——EasyExcel 不支持多个 Writer 合并输出这个方向走不通。个人实操总结这套方案上线之后我再也没被运营追着问“为什么导出又挂了”。50 万行订单导出从原来 POI 必 OOM、服务重启的情况稳定到十几秒完成堆内存始终平稳512MB 的实例就能稳稳跑完。工具类在公共包里沉淀了快一年换了好几波业务同学使用凡是按照 3.4 节三个约定来写的基本没出过问题凡是出问题的回头一看多半是绕过了约定。如果只让我说一条最值得记住的经验那就是大数据量 Excel 导出的核心不是 Excel 怎么写而是数据怎么查。把“全量加载再加工”的思维换成“边查边写”的流式思维OOM 就已经解决了九成。EasyExcel 只是帮你守住了最后一关而已。顺带一提这套流式思路在 Excel 导入场景同样成立复杂表头导入的解析本质也是一样的 SAX 事件流思路。
返回列表