去年做多商户商城后台的时候,我接到一个导出需求:把订单表一个月的数据导成 Excel,量级大概三十万行。一开始我用老办法直接上 Apache POI,结果内存一路飙到两个多 G,导到一半直接 OOM。后来在 Spring Boot 项目里换成了 EasyExcel 重写,才把这件事彻底理顺。这篇东西不是官方文档的翻译,是我把导出功能从"能导"做到"敢导几十万行"的完整记录:包括为什么选 EasyExcel、最基础的导出怎么写、大数据量怎么分页写、复杂表头和动态列怎么处理,以及 Web 下载场景里那些容易翻车的小细节。无论你是刚接触 Spring Boot 的新人,还是被大导出搞到焦头烂额的老手,都可以参考一下。
1. 为什么后台导出功能我没继续用 POI,而是换成了 EasyExcel
1.1 先说一个我真实的翻车现场
当时的页面长得很简单:订单列表页右上角一个"导出 Excel"按钮,用户点了之后,后端查询数据库、生成文件、返回下载流。这个逻辑听起来人畜无害,但三十万行订单数据,每行十几个字段,在内存里一次性构建 Workbook,数据量大概是:
300000 行 × 15 列 × 平均 80 字节 ≈ 360MB 字符串数据 加上 Workbook 内部结构、样式对象、单元格对象,实际占用远超这个数我用 POI 的 XSSFWorkbook 跑了一次,dump 出来的堆足足有 2.3GB,CentOS 上默认堆才 1.5GB,结果自然是 OOM。后来临时用了 SXSSFWorkbook 滑动窗口模式,虽然不爆内存了,但导出的文件打开时又遇到兼容性问题,而且 SXSSF 对样式的支持有限、用起来到处是限制。
这不是 POI 不行,而是我在错误的场景用了错误的工具。POI 的 XSSFWorkbook 强项是"完全控制":样式、公式、图表、复杂单元格操作都可以做;但代价是所有数据都常驻内存。对于"查询一堆数据、导出成一个表格"这种再普通不过的需求,我需要的是一个能边查边写、占内存极小的方案,EasyExcel 正好就是干这个的。
1.2 EasyExcel 和 POI 本质区别在哪
EasyExcel 是阿里开源的项目,底层仍然依赖 POI 的解析包,但它做了两件关键的事:
- 读取时采用 SAX 事件驱动模式,一行一行解析,不需要把整个文件加载进内存。
- 写入时采用"生产者-消费者"模式,数据可以先写入磁盘上的临时缓存,再由 write 线程写入最终 xlsx 文件,内存里只保留当前批次的一小块数据。
用大白话讲,POI 是把整座城连同居民全部装进卡车再运走,EasyExcel 是让居民自己排队上车。所以同样是导三十万行,POI 需要几个 G 内存,EasyExcel 通常只需要几十 MB 到一两百 MB。
还有一个被很多人忽略的点:EasyExcel 在 Web 环境里真的"会做人"。它的默认编码转换、日期格式处理、自动关闭输出流这些行为,都是按照"给 HttpServletResponse 写文件"这个场景设计的。POI 需要你自己手动处理一堆细节,细节一多,出错的面就大了。
1.3 什么时候才需要考虑换成 EasyExcel
如果你的数据量常年在一万行以内,用 POI 完全没问题,导出的时间甚至可能更快,因为省去了 EasyExcel 的初始化开销。但如果你的导出需求是这样的,我建议直接上 EasyExcel:
- 数据量可能超过五万行,或者你不想天天担心"哪天用户多导一次就炸了"。
- 需要导出动态列、复杂表头、多个 Sheet、下拉框等进阶功能,EasyExcel 的注解和策略类做起来更顺手。
- 团队里接手的人水平参差不齐,选一种可控、坑少、社区资料多的方案,长期来看最划算。
另外要注意:EasyExcel 的底层解析仍然基于 POI 的 xmlbeans 那一套,版本兼容性一直是个敏感话题。我的经验是,项目里如果还引用了 POI 的其他模块,最好以 EasyExcel 传递进来的 POI 版本为准,不要自己再强制指定一个高版本 POI,容易踩到 NoSuchMethodError。
2. 先把最简单的导出跑通:依赖、注解、三行核心代码
2.1 依赖引入和版本选择
我用的是 Spring Boot 2.7 + Maven,引入方式非常简单:
<dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.4</version> </dependency>版本这块我多说一句:EasyExcel 3.x 是目前的主流,API 和 2.x 有不小的差别,尤其是 WriteHandler 那套接口。网上大量教程还在写 2.x 的代码,你如果用的是 3.x,直接照抄会报编译错误。建议以官方 GitHub 仓库的最新 release 为准,我目前用 3.3.x 很稳。
2.2 实体类上用注解描述列
EasyExcel 的核心理念是"用注解把 Java 对象和 Excel 列的关系固定下来"。比如我要导出订单数据,就建一个专门的导出模型类:
@Data public class OrderExportModel { @ExcelProperty(value = "订单号", order = 0) private String orderNo; @ExcelProperty(value = "下单时间", order = 1) private Date orderTime; @ExcelProperty(value = "买家昵称", order = 2) private String buyerName; @ExcelProperty(value = "订单金额", order = 3) private BigDecimal amount; @ExcelProperty(value = "订单状态", order = 4) private String status; }有几个细节值得说明:
@ExcelProperty的value就是表头名称,默认情况下直接作为列名显示。order控制列顺序,不加也行,按字段声明顺序排,但显式声明更稳妥。- 如果数据库查出来的字段是另一个 DTO,建议单独建一个 ExportModel,不要拿领域对象直接导。理由是:领域对象里的字段可能不想暴露给外部,也可能会出现"同一个字段这次导出要、下次导出不要"的变动,单独隔离一个模型类,改动成本最低。
- 日期格式、金额格式可以用
@DateTimeFormat("yyyy-MM-dd HH:mm:ss")和@NumberFormat("#,##0.00")处理,不需要自己拼字符串。
2.3 Controller 里三行代码导出到浏览器
实体定义好之后,最简单的导出接口长这样:
@GetMapping("/order/export") public void exportOrder(HttpServletResponse response) throws IOException { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String fileName = URLEncoder.encode("订单导出" + System.currentTimeMillis(), "UTF-8") .replaceAll("\\+", "%20"); response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); List<OrderExportModel> list = orderService.listAllForExport(); EasyExcel.write(response.getOutputStream(), OrderExportModel.class) .sheet("订单数据") .doWrite(list); }这里有三点是初学者最容易踩的坑:
- Content-Type 必须是
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,表示输出的是 xlsx 格式;如果用错了,浏览器可能直接把乱码文件存下来。 - 文件名含中文时不能直接拼在
Content-Disposition里,一定要做 URL 编码,并且使用filename*=utf-8''这种 RFC 5987 写法。不加编码的话,Chrome 还好,IE 和 Safari 会出现显示乱码的情况。 doWrite会自己处理关闭流。但如果你在doWrite之前就把response.getOutputStream()取出来做了别的操作,请格外小心流的生命周期,详见后面第五部分。
跑通这一个接口之后,你其实已经完成 90% 的基础工作了。接下来真正麻烦的是:数据量一大,怎么办?
3. 大数据量导出的正确姿势:分页拉取 + 分批写入
3.1 直接查全量再导,内存怎么爆的
我最早写导出的时候,就是select * from order查出来一个 List,然后直接doWrite(list)。这种写法在小数据量时没有任何问题,但数据量到十万级以后,不只是 EasyExcel 写文件占内存,更大的包袱是 MyBatis 把十万个对象一次性堆在内存里。JVM 里瞬间多出来的这几十上百 MB 对象,才是压垮服务的元凶。
所以大数据量导出有两个优化方向,缺一不可:
- 数据库查询端:分页或者流式查询,避免一次加载全量数据。
- 文件写入端:用 ExcelWriter 分批写入,让 EasyExcel 的缓存机制生效。
3.2 用 MyBatis 分页流式获取数据
我当时的落地方案是:使用 MyBatis 的分页查询,每次取 5000 条,写完一批再查下一批。伪代码如下:
long pageNum = 1L; int pageSize = 5000; List<OrderExportModel> pageData; do { pageData = orderMapper.selectOrderExportPage((pageNum - 1) * pageSize, pageSize); if (pageData != null && !pageData.isEmpty()) { writer.write(pageData, writeSheet); } pageNum++; } while (pageData != null && pageData.size() == pageSize);这里有几个经验值:
- pageSize 不要设得太大,2000 到 5000 一般比较合适。太大时,MyBatis 嵌套结果映射依然会吃不少内存;太小时,数据库往返次数太多,导出速度明显变慢。
- 如果你的 SQL 里有
LIMIT offset, size,注意 offset 越深越慢的问题。几十万行倒还好,如果数据量是千万级,建议改用游标方式(MappedStatement 的fetchSize+ResultHandler),MyBatis 也支持,只是配置起来稍微麻烦一点。 - 排序字段必须稳定,分页时才不会出现"页与页之间数据重叠或遗漏"的情况。我习惯在 SQL 里固定
ORDER BY id或者业务上的唯一有序字段。
3.3 分批写入与 writer 复用
下面给出一个完整的、适合大数据量导出的 Controller 写法:
@GetMapping("/order/export-large") public void exportOrderLarge(HttpServletResponse response) throws IOException { String fileName = URLEncoder.encode("订单导出", "UTF-8").replaceAll("\\+", "%20"); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx"); ExcelWriter writer = EasyExcel.write(response.getOutputStream(), OrderExportModel.class) .autoCloseStream(false) .build(); WriteSheet writeSheet = EasyExcel.writerSheet("订单数据").build(); try { long pageNum = 1L; int pageSize = 5000; List<OrderExportModel> pageData; do { pageData = orderService.pageForExport(pageNum, pageSize); if (pageData != null && !pageData.isEmpty()) { // 分批写入,内存只保留当前批次数据 writer.write(pageData, writeSheet); } pageNum++; } while (pageData != null && pageData.size() == pageSize); } finally { writer.finish(); } response.flushBuffer(); }这段代码的关键点:
- 用
ExcelWriter+writer.write(pageData, writeSheet)代替doWrite,目的是把"一次查全量再一次性写"改成"查一批写一批"。 - 一定不要漏掉
writer.finish()。它做两件事:把剩余缓存数据落盘,并关闭底层输出流。很多人写完writer.write就以为完事了,最后生成的 xlsx 文件损坏,就是因为没调finish。 autoCloseStream(false)在这里是必要的吗?其实要看场景。默认情况下EasyExcel.write(...).build()并不会在 finish 时自动关闭传入的 OutputStream,你要自己负责关闭。写成autoCloseStream(false)是为了明确这个行为,防止某些版本差异导致连接被提前关闭。
3.4 实测效果和参数调优
我在这台 2C4G 的开发机上压了一把,30 万行、17 列数据,分页 5000 条一批,导出完成时间大约在 35 秒左右,JVM 堆峰值维持在 600MB 以内;同样的数据用 XSSFWorkbook 直接导,没到一半就 OOM 了。如果说要压到更快,可以考虑:
- 关闭 EasyExcel 默认的样式写入,
EasyExcel.write(...).useDefaultStyle(false),能省不少体积和 IO。 - 数据库端减少不必要的大字段查询,比如把订单备注、用户完整地址这种超长字符串从导出 SQL 里摘掉。
- 需要导出的数据是实时快照而非最新数据时,可以走只读从库,避免长时间占用主库连接。
大数据量导出还有一个隐藏问题:用户点击导出后要干等半分钟,很容易误以为页面卡死,然后又点一次。我这个项目的做法是:前端按钮点击后置灰,并提示"正在生成,预计 1 分钟内完成";如果项目对实时性要求不高,后台异步生成文件、生成完再给下载链接是更好的方案,这块我会在最后简单说一下。
4. 复杂表头和动态列,这才是 EasyExcel 真正的价值所在
如果你只是把数据平铺导出来,EasyExcel 的优势还不算明显。一旦业务方提出"表头要跨列合并""列不是固定的,今天导这几个字段,明天导那几个字段"这类需求,EasyExcel 的策略类机制才有真正发挥空间。
4.1 固定复杂表头:注解写二级表头
最常见的是二级表头,比如"基本信息"下面挂"姓名、年龄","交易信息"下面挂"订单号、金额"。用注解可以直接写:
@Data public class MemberBillExportModel { @ExcelProperty(value = {"基本信息", "姓名"}) private String name; @ExcelProperty(value = {"基本信息", "手机号"}) private String mobile; @ExcelProperty(value = {"交易信息", "订单号"}) private String orderNo; @ExcelProperty(value = {"交易信息", "账单金额"}) private BigDecimal amount; }value数组的第一个元素是上级表头,第二个是当前列的表头,EasyExcel 会自动生成合并单元格。这种固定写法适合表头结构确定、以后不太会变的场景。如果表头层级有三级、四级,注解也能支持,但可读性会变得很差。我个人的习惯是:超过三层就改用动态构建表头。
4.2 动态列导出:运行期构建表头模型
跨境外贸场景特别容易出现"客户要求按 SKU 动态生成一列"这种需求,比如导出的列是:日期、渠道、SKU-A、SKU-B、SKU-C……SKU 列表是接口运行期才知道的。这种情况下没法用注解写死,必须用 EasyExcel 的List<List<String>>表头模型:
// 构建表头 List<List<String>> head = new ArrayList<>(); head.add(Collections.singletonList("日期")); head.add(Collections.singletonList("渠道")); for (String sku : skuList) { head.add(Collections.singletonList(sku)); } // 构建数据行,每行也是 List<Object> List<List<Object>> dataRows = new ArrayList<>(); for (InventoryStat stat : statList) { List<Object> row = new ArrayList<>(); row.add(stat.getDate()); row.add(stat.getChannel()); for (String sku : skuList) { row.add(stat.getSkuValue(sku)); } dataRows.add(row); } EasyExcel.write(response.getOutputStream()) .head(head) .sheet("库存统计") .doWrite(dataRows);注意一个容易踩的坑:动态列模式下,最好设置一个全局表头样式,否则默认表头是灰底黑字,遇到品牌方要求"表头必须和主题色一致"的场景又得去研究样式策略。更省事的办法是:数据行也用一个List<Object>对应着拼。这种写法类型转换全靠你自己保证,所以在拼数据时,字段顺序必须和 head 保持一致,这是动态导出最常见的 bug 来源。
4.3 合并单元格、下拉框、固定表头这些策略怎么加
EasyExcel 的策略类机制本质上是给写文件过程挂监听器。官方提供一个简单的固定位置合并策略,很实用:
// 将第1行第1列到第2行第1列的单元格合并,表头场景可以这样玩 OnceAbsoluteMergeStrategy mergeStrategy = new OnceAbsoluteMergeStrategy(1, 2, 0, 0); EasyExcel.write(response.getOutputStream(), SomeModel.class) .registerWriteHandler(mergeStrategy) .sheet("数据") .doWrite(dataList);如果要做"相同值自动合并"这种动态合并,就得自己实现CellWriteHandler,核心逻辑是:在afterCellDispose回调里判断当前单元格和上一行同一列的值是否相同,相同则记录合并区域;然后在所有单元格写完后统一执行addMergedRegionUnsafe。这块代码在 3.x 版本里接口有一定变化,网上老版本代码直接抄会编译不通过,我的建议是把 EasyExcel 源码里的AbstractMergeStrategy打开看一眼再动手。
至于下拉框、单元格校验这类"让用户少填错"的需求,EasyExcel 没有开箱即用的注解,常规做法是通过自定义SheetWriteHandler,在afterSheetCreate里拿到Sheet对象,调用 POI 的DataValidationHelper添加下拉列表。这个属于进阶玩法,一般也只有模板下载功能会用到,这里不展开。
动态列、复杂表头实际做下来,我的最大体会是:先确定表头结构,再设计数据模型。很多开发一上来就写@ExcelProperty,等到需求改成动态列时推倒重来。如果一开始就意识到"表头可能是动态的",直接用List<List<String>>的结构,后面会省很多时间。
5. Web 下载场景里最容易翻车的几个细节
导出跑通、大数据量也扛住了,但上线之后才发现,Web 下载这块的坑一点不比数据内存少。
5.1 文件名编码和 Content-Disposition
中文文件名这个问题,我在 2.3 里提过一嘴,这里细说。很多老代码的写法是:
response.setHeader("Content-Disposition", "attachment;filename=" + fileName + ".xlsx");这段代码在 Chrome 上用通常没问题,但在老的 IE、Edge 浏览器上,中文直接变成乱码;在某些 Linux 浏览器环境下甚至会导致下载失败。推荐的写法是:
String encodedFileName = URLEncoder.encode("订单导出", "UTF-8").replaceAll("\\+", "%20"); response.setHeader("Content-Disposition", "attachment;filename*=utf-8''" + encodedFileName + ".xlsx");注意filename*后面的utf-8''是固定格式,不能省略。如果你要兼容已经废弃的 IE,可以同时提供filename=和filename*=两个字段,前者用 URLEncoder 编码后的值,后者用标准格式。这里还有个细节:URLEncoder 会把空格编码成+,而标准 URL 编码里空格应该用%20,所以必须.replaceAll("\\+", "%20"),否则下载的文件名里会出现加号。
5.2 临时文件与流的关闭时机
如果你不是直接写response.getOutputStream(),而是先把文件写到本地临时目录,再通过FileInputStream转发给浏览器,那么临时文件的删除时机就特别容易出问题。我见过一个线上 bug:每天导出的临时文件堆积在/tmp目录,把磁盘打满了。原因是开发者在写文件之后立刻执行了file.delete(),但那时输出流还没关闭,Linux 下文件删除虽然成功,但文件句柄依然被占用,磁盘空间并不会真正释放。
正确的做法是:
File tempFile = File.createTempFile("export-", ".xlsx"); try (ExcelWriter writer = EasyExcel.write(tempFile, OrderExportModel.class).build()) { // 分批写入数据 } // 读文件并输出到 response... if (tempFile.exists()) { tempFile.delete(); }工程化一点的做法,是给 Servlet 容器的请求结束事件挂监听,在requestCompletion里统一清理本次请求生产的临时文件。这个思路比在业务代码里到处写delete要可靠得多。
5.3 导出失败时怎么给前端一个像样的错误
这是我最想强调的一个点。很多人导出接口返回的 Content-Type 是 Excel 类型,一旦导出过程中抛出异常,Spring Boot 默认返回一个 HTML 错误页面,前端拿到response.data是一堆 HTML 标签,根本判断不了具体错误。更麻烦的是,如果response.getOutputStream()已经被 get 走,再想往 response 里写 JSON 错误信息是写不进去的。
我当时的处理方案:
- Controller 层把"查询数据"和"写入 Excel"拆成两步,查询数据之前先做参数校验,校验失败直接抛一个业务异常,由全局异常处理器返回 JSON。
- 写入 Excel 过程中的异常,在 catch 块里不要尝试再写 JSON 响应了,直接把异常记录日志,然后
response.reset()并返回一个自定义错误状态码;前端检测到状态码不是 200,就弹出统一的"导出失败,请稍后重试"提示。
try { ExcelWriter writer = ...; writer.write(data, sheet); writer.finish(); } catch (Exception e) { log.error("导出订单数据失败", e); response.reset(); response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); }response.reset()的作用是清空已经写出去的响应头和响应体,避免浏览器拿到一份残缺的 Excel 文件。这一点很多人不知道,后果就是用户导出一份打不开的文件,报错信息又看不到。
5.4 权限、超时、并发这几个现实问题
导出接口千万不要当成普通查询接口来设计。我见过一些项目把导出和列表查询放在同一个权限点下面,结果低权限用户直接调用导出接口把全表数据拿走了,这属于数据安全事故,必须单独做权限控制,最好确认"当前用户能导出的数据范围"要和列表查询一致。
其次是超时。这种大导出接口动辄三十秒、一分钟,如果服务前面挂了网关,网关的默认超时时间很可能只有三十秒左右。你需要确认网关层对这一个接口的超时配置是放开的,或者干脆走异步导出,避免超时中断导致生成了一半的文件被丢弃。
最后是并发。导出是吃 CPU 和内存的操作,如果接口不做任何限制,某个员工连续点十次导出,等于给服务端制造了十份同样的压力。我在项目里民警一句话总结:导出接口做好权限校验、限流和超时配置,和写好 EasyExcel 代码同等重要。
最后多说两句
从最初 POI 爆内存的翻车,到后来 EasyExcel 稳定扛住几十万行导出,我最大的感受是:这种功能没有太多高深技术,真正决定项目成败的都是边界条件——分页查询的排序是否稳定、writer 是否 finish、文件名编码是否正确、临时文件有没有清理、异常情况下响应会不会变成一堆乱码。把这些细节一条条处理完,导出功能才算是真正"完成"。
最后分享一个小技巧:如果你的项目经常需要导各种报表,建议抽一个ExportHelper工具类,把"设置响应头、编码文件名、创建 ExcelWriter、重试分页、finish 资源"这几段固定逻辑封装起来,业务代码只关心模型类和数据查询。这样后面再加一百个导出接口,每一处都是十几行代码搞定,而且不容易写歪。