☰
SpringBoot整合EasyExcel:从入门到百万级数据导出实践
2026/10/12 23:53:22 网站建设 项目流程

SpringBoot 后端接口里做 Excel 导出,基本是每个 Java 开发者都绕不开的活。以前用 Apache POI 硬写,代码又长又啰嗦,数据量稍大还容易内存溢出,动不动就要调大 JVM 参数。后来换成 EasyExcel,同样的功能,代码量直接少了一大半,内存也稳得不行,谁用谁知道,确实是香。这篇博客就来聊聊 SpringBoot + EasyExcel 做文件导出的完整玩法,从最简单的入门 Demo 到大数据量分批写、动态表头、模板填充和样式定制,再到我踩过的一些坑,一次讲清楚。

如果你是刚接触 EasyExcel 的新手,可以照着第一部分到第二部分抄代码入门;如果你的项目已经上了 EasyExcel,进阶场景和问题排查这两块应该能帮你少走不少弯路。

1. 为什么选 EasyExcel 而不是直接用 POI

1.1 Excel 处理到底难在哪

做导出功能,本质上就是把内存里的业务数据,按照约定的格式写到 xlsx 文件里。听起来很简单,但 Excel 文件的格式复杂度是被很多人低估的。一个 xlsx 文件本质是一个 ZIP 压缩包,里面有一堆 XML 文件描述工作表、单元格、样式、图片、公式、共享字符串……如果用底层 API 直接操作,你要自己保证这些 XML 的逻辑一致,随便一个单元格样式写错了,文件就可能打不开。

Apache POI 是 Java 领域最老牌的 Excel 操作库,功能很全,几乎什么都能做。但它有一个非常典型的问题:它默认把整个工作簿、每个工作表、每一行、每一个单元格都构建成完整的内存对象模型(也就是常说的 DOM 模式)。数据量小的时候没问题,一旦要导出的数据达到几十万行、上百万行,内存里同时存在几十万个 Java 对象,再加上样式对象、字体对象,内存很容易就爆了。

我当年接过一个报表导出需求,单表 60 万条记录,直接用 POI 一次性构建 Workbook,结果 JVM 报 OutOfMemoryError,最后没办法,只能分批切片导出,一个 Excel 拆成好几个文件再压缩给用户,体验很差。这个问题的根源就是整个文件被整体加载到内存里了。

1.2 EasyExcel 的“香”到底香在哪

EasyExcel 的出现,很大程度上就是冲着 POI 的这些痛点去的。它最核心的差异是采用了流式读写机制,不构建整棵内存对象树。我习惯用一个类比来解释:POI 那种方式相当于你写一篇一万字的文章,得先在心里把整篇文章一个字一个字背下来,再一次性写到纸上;EasyExcel 则是边想边写,每一行数据处理完就可以丢掉了,所以无论数据有多少,内存占用都相对平稳。

对于导出来说,EasyExcel 底层会通过一个 SAX 风格的行写入器把实体对象逐行写出到输出流,对象写完一行,这一行的临时数据就可以被垃圾回收,内存占用基本和写入行数没有线性关系。这在处理百万级数据导出时是决定性的优势。

同时,EasyExcel 把 API 设计得非常贴近业务:你想导出一个 List,只用 EasyExcel.write(...).sheet(...).doWrite(list),一行代码就能完成。实体类上用注解声明列名、列宽、日期格式、数字格式,语义清楚,维护起来也容易。相比 POI 里需要手写遍历单元格、设置样式的样板代码,开发效率提升是非常明显的。

1.3 什么情况下仍然考虑 POI

也不是什么场景都无脑选 EasyExcel。有些特殊需求,比如生成非常复杂的动态公式链、操作 Excel 里的图表对象、读写宏文件、做复杂的单元格合并和公式依赖分析,POI 的底层模型会更直接,可行性也更高。EasyExcel 现在的版本虽然也在逐步增强底层扩展能力,但主要目标是高效地完成导入导出,不是为了成为一个无所不能的 Excel 编辑器。

另外如果你的项目原来就有大量基于 POI 的代码,也没必要立刻推翻重写,可以在新开发的导出接口里用 EasyExcel,老接口按风险级别逐步替换。我经手的某个模拟项目 X,就是把高频的大报表导出接口从 POI 换成了 EasyExcel,内存和老代码维护成本都降下来了,而那些只导出几百行的小接口,就保持原样,没必要动。

2. 从零搭一个入门导出接口

2.1 依赖引哪些,版本怎么选

如果你的项目是标准 SpringBoot 工程,直接在 pom.xml 里加 EasyExcel 的依赖就行。注意现在的版本号用起来要稍微留个心,我用的比较稳定的是 3.x 系列:

<dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>3.3.4</version> </dependency>

3.x 的主要 API 和 2.x 基本一致,但一些细节有明显变化,比如样式接口、WriteCellData 相关的 API,2.x 里很多写法到 3.x 不兼容。如果你是从 2.x 升级上来的,建议先跑一遍项目里的导出用例,确认没有编译错误和样式丢失再上线。2.x 已比较老,除非老项目维护,否则新项目直接上 3.x。

如果遇到依赖冲突,可以优先排查是不是项目里同时引入了不同版本的 POI。EasyExcel 本身是基于 POI 的,但它的核心特性并不依赖你项目里手动引入 POI,默认传递依赖通常够用。硬要手动引入 POI 版本,反而容易触发 NoSuchMethodError 这类问题。

2.2 用注解定义导出结构

EasyExcel 导出的核心玩法就是注解。定义一个和 Excel 表格结构对应的实体类,字段上标注列名和格式,框架就能自动完成映射。以某个运营后台要导出的用户列表为例:

import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.format.DateTimeFormat; import com.alibaba.excel.annotation.format.NumberFormat; import lombok.Data; import java.math.BigDecimal; import java.util.Date; @Data public class UserExportVO { @ExcelProperty("用户ID") private Long userId; @ExcelProperty("用户昵称") private String nickName; @ExcelProperty("注册时间") @DateTimeFormat("yyyy-MM-dd HH:mm:ss") private Date registerTime; @ExcelProperty("账户余额") @NumberFormat("#,##0.00") private BigDecimal balance; }

用 @ExcelProperty 定义表头和字段的对应关系,是导出最基础也最常用的方式。注意字段顺序决定了表格列顺序,所以实体类里字段的排列要提前设计好,别写到一半发现列的顺序打乱了。

@DateTimeFormat 和 @NumberFormat 做的是数据类型格式化,可以省掉你在业务代码里事先把 Date 转 String、把 BigDecimal 转 String 这些麻烦步骤。但有一点要注意,如果你把一个字段定义为 String 类型,然后在注解上放 DateTimeFormat,框架是按字符串生成的,日期格式不会生效;正确做法是字段类型保持 Date、BigDecimal 这类原始类型,让注解去处理格式。

不过我还遇到过一种情况:业务方希望在导出的“账户余额”列前面加人民币符号。如果你直接用 String 字段拼上符号,会导致这一列变成文本格式,后续用户拿到 Excel 再做求和、统计就不方便了。更合理的做法是保持 BigDecimal 字段和 NumberFormat 注解,把货币符号放到表头里,比如“账户余额(元)”,既满足展示需求,又不破坏单元格数值类型。

2.3 核心导出代码:一个可以直接复制的 Controller

接下来是控制器层和响应头。导出接口和普通 JSON 接口最大的不同是:它要直接把 Octet 流或者 Excel 的 MIME 类型写回给前端,所以响应头的设置很关键。

下面是带登录鉴权假设的 Controller 示例,引入了一个简单的用户服务:

import com.alibaba.excel.EasyExcel; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.List; @RestController @RequestMapping("/user") public class UserExportController { private final UserQueryService userQueryService; public UserExportController(UserQueryService userQueryService) { this.userQueryService = userQueryService; } @GetMapping("/export") public void exportUserList(HttpServletResponse response) throws IOException { // 文件名要 URL 编码,避免中文乱码 String fileName = URLEncoder.encode("用户列表", StandardCharsets.UTF_8.name()) .replaceAll("\\+", "%20"); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("UTF-8"); response.setHeader("Content-Disposition", "attachment;filename*=UTF-8''" + fileName); // 简易版:一次性导出一批数据 List<UserExportVO> dataList = userQueryService.listAllUser(); EasyExcel.write(response.getOutputStream(), UserExportVO.class) .sheet("用户信息") .doWrite(dataList); } }

这里有几个细节值得展开。

响应头 Content-Disposition 里,filename 使用filename*=UTF-8''这种 RFC 5987 格式,是解决中文文件名乱码最可靠的方式。以前老代码喜欢写attachment;filename="用户列表.xlsx",在部分浏览器和操作系统组合下会乱码,甚至直接下载失败。改用 URLEncoder 先把中文编码,再放到filename*=UTF-8''后面,几乎没有再碰到过乱码问题。

MIME 类型 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet 是标准 xlsx 文件的类型。如果你偷懒用 application/octet-stream 也能下载,但部分浏览器识别不对,打开文件时可能提示格式不匹配。

EasyExcel.write 第一个参数接收的是 OutputStream,你可以直接传 response.getOutputStream(),框架会负责把数据写入这个流,并在 doWrite 内部完成收尾。注意:不要在 doWrite 之前关闭 response 的流,否则什么都不会写进去。

我还见过一个容易踩的坑:有些同学习惯在 Service 里把 List 拼成很长一段字符串再返回,或者把整个 List 先转成 JSON 存到缓存,导出时再从缓存读。这在数据量小时没感觉,数据量一大,额外的转换开销会直接拖慢导出接口。正确姿势是拿到 List 就直接写,不要在导出链路里做多余的中间态转换。

2.4 前端怎么接收下载

虽然这篇主要讲后端,但导出联调时前端经常问怎么接。我先说最简单的方案:直接用浏览器地址访问下载接口,或者用 window.location.href 跳转。这种方式的缺点是你没法在响应里拿到业务错误信息,比如导出失败时后端返回 JSON 错误,浏览器会直接把 JSON 当成文件下载。

更稳的做法是用 fetch 或者 axios 请求接口时配置 responseType: 'blob',拿到 Blob 再创建一个临时下载链接。

fetch('/user/export', { method: 'GET' }) .then(response => { if (!response.ok) throw new Error('导出请求失败'); return response.blob(); }) .then(blob => { const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = '用户列表.xlsx'; link.click(); URL.revokeObjectURL(url); }) .catch(err => console.error(err));

用 Blob 方式接收,后端如果抛出 500,前端就能捕获到错误并弹提示,体验会好很多。还有一种做法是在请求里带一个 Authorization 头,很多前后端分离项目下载接口也要校验登录态,直接用 window.location.href 没法带自定义请求头,这种情况就必须用 Blob + fetch 方案。我见过不少团队因为导出接口要带 Token,前段跳转半天下载不了,最后都改成了 Blob 接收。

3. 进阶场景:大数据量、动态表头、模板填充与样式

3.1 大数据量导出:分批查询 + 循环写入

很多导出需求其实根本没有大到需要特殊优化,一次 doWrite 就完了。但业务报表系统里经常会出现“全量导出”这种需求,一导就是几十万行甚至上百万行。这时候如果你还是一次性把数据全部查出来塞进一个 List,最大的瓶颈其实已经不在 EasyExcel 了,而在于这条查询 SQL 本身。几十万条记录全部装载到内存里,光业务对象的占用就非常可观。

正确思路是:数据库分页查询,EasyExcel 分批写入。下面这段代码是当前端要做数据全量导出时的推荐写法:

import com.alibaba.excel.EasyExcel; import com.alibaba.excel.ExcelWriter; import com.alibaba.excel.write.metadata.WriteSheet; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; public void exportLargeData(HttpServletResponse response) throws IOException { // 响应头设置省略,和上一节一致 ExcelWriter writer = null; try { writer = EasyExcel.write(response.getOutputStream(), UserExportVO.class).build(); WriteSheet writeSheet = EasyExcel.writerSheet("用户信息").build(); int pageSize = 5000; int pageNum = 1; while (true) { List<UserExportVO> pageData = userQueryService.queryPage(pageNum, pageSize); writer.write(pageData, writeSheet); pageNum++; // 假设查询返回数量小于 pageSize,说明已经到最后一批 if (pageData.size() < pageSize) { break; } // 每批写完及时清除局部引用,便于 GC pageData.clear(); } } finally { if (writer != null) { writer.finish(); } } }

这段代码用 ExcelWriter 对象手动控制写入生命周期。你可以循环调用 writer.write(pageData, writeSheet),把每一批数据追加到同一个 Sheet 里,最后统一 finish,输出流关闭,文件生成。pageSize 选择经验值一般是 3000 到 10000 都行,太大反而会让单次 SQL 查询和对象创建的开销变大,太小网络和上下文切换开销明显,5000 是一个比较均衡的值。

千万要注意 writer.finish() 必须在 finally 里调用。如果你只调了 writer.write() 就返回,输出流没有正确完成收尾,生成出来的文件大概率是损坏的,或者只有几 KB 且打不开。doWrite() 内部会自动 finish,但如果你用的是手动 ExcelWriter 模式,就必须自己负责 finish,这是最容易遗漏的地方。

另外分页查询本身也有坑。用普通 Limit 做深分页时,页数越靠后性能越差,因为数据库要跳过前边大量行。如果要导的数据接近百万,更推荐用游标式查询,或者基于主键 ID 做范围分页,比如where id > lastId order by id limit 5000,每一批都以上一批最后一条 ID 作为起点。这种写法在导出大表时能明显减少数据库扫描压力。

3.2 动态表头怎么做

有些导出需求连表头都是动态的,比如客户要求根据查询条件动态生成统计周期列,上一周可能只有三列,这一周变成五列。这种场景没法提前写死一个实体类,需要完全跳过实体映射,手动构造表头和行数据。

EasyExcel 里最直接的方式是给 write 方法传一个 List<List > 作为表头,然后传一个 List<List

List<List<String>> head = new ArrayList<>(); head.add(Arrays.asList("业务员")); head.add(Arrays.asList("渠道")); head.add(Arrays.asList("销售额")); // 每一行的 Object 列表要和表头一一对应 List<List<Object>> rows = new ArrayList<>(); rows.add(Arrays.asList("张三", "线上", 12800.50)); rows.add(Arrays.asList("李四", "线下", 9200.00)); EasyExcel.write(outputStream) .head(head) .sheet("销售统计") .doWrite(rows);

这种动态列的写法非常灵活,但代价是你失去了实体类那种类型映射和格式注解。field 是 BigDecimal 还是 String,完全要靠你在构造 List 时自己控制。数字在单元格里希望写成数值类型就放 BigDecimal,希望保留前导零就放字符串,这个细节容易踩。曾经有个同事把手机号都放成了数字类型,结果导出后开头是 0 的手机号全部丢失,后来才改成字符串字段。

还有一种变体:实体类负责大部分固定列,动态列单独剥离。你可以先通过实体映射出前几列,再把动态列追加到表头和数据行的剩余位置。实际操作时可以用 map 结构维护动态列名和对应值,代码会比较绕,但能兼顾类型映射的便利性和动态扩展的需求。

3.3 模板填充:让数据套进现成报表

模板填充是 EasyExcel 一个特别实用的功能。很多时候业务方给了一个做好的 Excel 模板,里面有固定的标题、表头、品牌名、签字栏,甚至预设了公式,你只需要往某个区域把数据填进去。用代码重新生成这个模板非常痛苦,用模板填充可以直接基于模板文件输出最终文件。

使用方式也不复杂,把一个模板文件放到 classpath 下,读取成流,再用 withTemplate 指定:

import com.alibaba.excel.EasyExcel; import com.alibaba.excel.ExcelWriter; import com.alibaba.excel.write.metadata.WriteSheet; InputStream templateStream = this.getClass().getResourceAsStream("/template/user_report.xlsx"); ExcelWriter writer = EasyExcel.write(outputStream) .withTemplate(templateStream) .build(); WriteSheet sheet = EasyExcel.writerSheet().build(); // 数据对象里如果包含 List 字段,用 FillWrapper 包裹以区分数据块 writer.fill(new FillWrapper("users", userList), sheet); writer.finish();

模板里的写法用花括号加变量名占位,比如表头下方要输出一批用户列表,可以在模板的某个区域先写好占位字段,然后 fill 方法会自动识别并展开成多行。注意模板里占位符的命名要和 FillWrapper 的 key 对应。单对象填充也可以直接用 writer.fill(map, sheet),Map 里的 key 对应模板占位名。

这里容易忽略的问题是:使用模板填充时,模板本身的单元格样式、边框、行高会被保留,这是模板填充最大的好处。但如果你在模板里写了公式占位,比如合计行本来预埋了 SUM 公式,填充数据后公式可能不会自动重新计算,具体取决于模板预埋时的公式形式和 EasyExcel 版本的表现。稳妥做法是模板里预埋公式并且用 Excel 已经能正常打开验证过,最终输出文件后再抽查打开确认公式正常。

3.4 样式定制:列宽、行高、表头背景色

很多开发者第一次用 EasyExcel 时说生成的表头太“秃”,想加背景色、字体加粗、调整列宽行高。最简单的办法是在实体类上加类级和字段级注解。

import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.ColumnWidth; import com.alibaba.excel.annotation.HeadRowHeight; import com.alibaba.excel.annotation.ContentRowHeight; import lombok.Data; @Data @ColumnWidth(20) @HeadRowHeight(30) @ContentRowHeight(24) public class ProductExportVO { @ExcelProperty("商品编码") private String sku; @ColumnWidth(32) @ExcelProperty("商品名称") private String productName; @ExcelProperty("库存") private Integer stock; }

@ColumnWidth 加在类上是默认列宽,加在字段上会覆盖默认值;@HeadRowHeight 和 @ContentRowHeight 分别是表头行高和内容行高。这三组注解基本能覆盖日常大多数需求。

如果要给表头加背景色、字体加粗,类级注解也能做,但颜色值写法在 3.x 里稍显晦涩。我更常用的方式是写一个 CellWriteHandler 实现类,在里面针对表头单元格设置样式。代码看起来多一点,但样式完全可控,且不会因为版本升级导致注解里的颜色常量失效。

有一个经验分享:样式性能问题在大数据量导出时会被放大。比如你给每一行内容都自定义字体和边框,百万行导出时样式对象偶尔会成为开销的隐形大头。尽量复用同一个样式对象,别在每行回调里 new 出大量样式实例。普通报表导出的样式做在表头和关键列上就够了,内容行保持默认样式完全能接受。

3.5 多 Sheet 导出:一个文件装下多类数据

业务上经常遇到“一个报表文件里有好几个 Sheet”的需求,比如客户明细一个 Sheet,销售明细一个 Sheet。EasyExcel 对多 Sheet 的支持非常方便,同样使用手动 ExcelWriter:

ExcelWriter writer = EasyExcel.write(outputStream).build(); WriteSheet customerSheet = EasyExcel.writerSheet(0, "客户列表").head(CustomerVO.class).build(); WriteSheet orderSheet = EasyExcel.writerSheet(1, "订单列表").head(OrderVO.class).build(); writer.write(customerList, customerSheet); writer.write(orderList, orderSheet); writer.finish();

每个 Sheet 可以单独指定表头实体和数据源。writerSheet 的第一个参数是 Sheet 序号,第二个是 Sheet 名称。如果只传一个名字,EasyExcel 会按顺序自动排序,比如第一个 sheet 名字叫“客户”,下一个叫“订单”,它也能正确区分,但我习惯显式传序号,避免未来调整代码时 Sheet 顺序乱了。

很多人不知道的是,ExcelWriter 支持一个 writer 连续写任意多个 Sheet,而不只是两个。所以如果你有循环生成不同 Sheet 的需求,比如月初要按部门各导出一个 Sheet,只要在循环里依次 writer.write 就行,性能也完全可控。

4. 问题排查与避坑实录

4.1 中文文件名乱码或下载不了

这应该是导出需求里出现频率最高的问题之一。表现是文件能下载,但文件名是一串百分号编码或者问号。前面已经提过,解决办法就是用 URLEncoder 编码中文,并且响应头写成 filename*=UTF-8''形式。

另一种情况是部分浏览器对 RFC 5987 支持不完整,尤其老版本的老式浏览器。如果公司内有用户反馈下载文件名不对,可以在 Content-Disposition 里同时写普通 filename 和 filename*,给老浏览器一个降级方案。示例:

Content-Disposition: attachment;filename=users.xlsx;filename*=UTF-8''%E7%94%A8%E6%88%B7%E5%88%97%E8%A1%A8.xlsx

这样新浏览器按 UTF-8 filename* 解析,老浏览器也能拿到一个 ASCII 的 filename,不至于完全无法下载。

4.2 导出大文件时内存溢出

OOM 几乎都是“调试数据一次性全查了 + 用 POI 构建 Workbook + 没注意响应流”的组合拳。换用 EasyExcel 的流式导出后,正常场景已经很难 OOM。如果你改成了分批写还是 OOM,先看是不是做分页的 service 方法内部把全量数据 pull 出来了,比如先查了总数,再在同一个事务里加载全部 ID 列表之类。

另外注意聚合查询和 List 装载时的对象大小。一百万条记录,每条对象十几个字段,对象头、引用、字符串内容加起来可能有几百兆。批量查询每批 5000 条,写完后让这批对象不可达,等下批查询占用另一批内存,才是正常状态。有人会在循环外声明一个大 List 反复 add 每批数据,然后从那个大 List 来构造 Excel,那样内存是不可能降下来的。

4.3 日期格式不对、时区偏移问题

EasyExcel 里日期格式化常用 @DateTimeFormat。但有些项目全局设置了 Jackson 的时区,而数据库连接 url 也带 serverTimezone 参数,导致从数据库取出的时间本身就是偏移后的值。这种问题不是 EasyExcel 造成的,导出的数据源头已经错了,再怎么格式化都是错的。

排查思路是先确认查询到的 Java Date 值是不是期望值,再确认实体字段是不是 Date 类型,最后看 @DateTimeFormat 的 pattern 是否正确。之前遇到过一位同学把 pattern 写成 yyyy-MM-dd HH:mm,实际数据需要精确到秒,导出的列全部缺秒,用户反馈后又重新导,这种低级错误其实花一分钟自测就能发现。

还有 LocalDateTime 类型的字段,3.x 对 LocalDateTime 支持也不错,但旧版本可能需要额外注册 LocalDateTimeStringConverter,如果你遇到 LocalDateTime 导出来是一串数字,先查一下版本,必要时手动转换 String。

4.4 并发导出与线程安全

EasyExcel 的底层读写器实例不是设计成跨线程共享复用的。我在实际运维中看到有的同事把 ExcelWriter 保存在类的静态字段里,想着一次初始化多次复用,结果两个用户同时点击导出,文件直接串数据,或者报 Already finished 异常。

正确姿势非常简单:每次请求都 new 一个 writer。Servlet 环境下 Controller 本身是单例,但导出代码里通过局部变量创建 writer,用完在 finally 里 finish,天然就是线程安全的。实体类也要注意不要有静态可变集合、静态 SimpleDateFormat 之类的东西,这些在并发导出时都是隐患。

4.5 导出文件打不开或内容空白

假如你下载下来的 .xlsx 双击提示文件损坏,先不要怀疑 EasyExcel bug,优先检查你的代码是否在 doWrite 之前就关了输出流,或者把它包进了 try-with-resources 后提前 flush 了。我自己踩过一次这样的坑:在 finally 块里顺手调用 response.getOutputStream().close(),导致 EasyExcel 后续写入时流已是关闭状态,生成结果就是一个 0 字节或者残损文件。

文件中有一个 Sheet 但全是空白,多半是导入到导出的 VO 映射出了问题,比如源数据 List 是空的,或者实体类字段和查询结果不一致。终极排查办法是把导出的 List 打印几行到日志,或者先用内存列表测一下,确认是数据问题还是框架问题。

4.6 导出接口在网关/登录拦截器下失效

还有一类问题表面上是 EasyExcel 相关,实际是拦截器把响应劫持了。有些权限框架在请求处理后强制对 response 做 JSON 包装,或者往输出流里追加了内容,导致 Excel 文件最后多出额外字节,Excel 打开时提示文件损坏。这类问题定位起来比较隐蔽,你可以先用 Postman 直接请求导出接口,把返回的文件保存下来看是否正常;Postman 正常但浏览器或前端异常,大概率是前端或网关的问题。

导出接口如果被统一日志拦截器记录响应体,也可能因为试图读取输出流内容而破坏二进制数据。解决办法是把导出接口加入路径白名单,跳过响应包装和响应体日志。

下面把高频问题整理成速查表,方便你以后直接对表排查:

现象常见原因解决方向
文件名乱码Content-Disposition 没做 URL 编码使用 URLEncoder + filename*=UTF-8''
文件打不开输出流提前关闭,或缺 finish()不要手动关流,finally 里 writer.finish()
导出全是空查询数据为空或 VO 字段映射不对打印 List 日志,检查字段名
中文内容乱码响应 CharacterEncoding 没设置设置 response CharacterEncoding UTF-8
内存 OOM全量 List + POI 构建分页查询 + EasyExcel 分批写
日期缺少时分秒DateTimeFormat pattern 不对检查 pattern 与字段类型
列宽太窄/太宽未使用 ColumnWidth 注解类级默认+字段级覆盖
并发导出串文件静态复用了 writer每次请求新建 writer
数字变成文本字段类型用了 String保留 BigDecimal/数字类型
Sheet 顺序错乱writerSheet 未显式传 index传 0、1、2…索引

4.7 一个我很少在文档里看到的习惯

最后分享一个实践心得。导出代码里,我习惯把“查询数据”和“生成 Excel”这两段强制分离。Controller 只负责拿到数据源,然后交给一个独立的 ExportService 去写 Excel。如果哪天要换底层导出工具,或者给同样的数据换一种报表模板,都不需要改动查询逻辑。这个习惯在项目迭代比较快的时候非常有用。

另外一个我常用的优化是,把每次导出操作记一个简单的日志:谁导的、导出多少行、耗了多少秒。很多业务问题比如“系统慢”“文件数据不对”到最后都要靠这份日志反查。导出接口慢的时候,通过日志能直接看出来到底是 SQL 慢还是生成文件慢,省掉大量排查时间。

从初期的 POI 到现在的 EasyExcel,导出功能在 Java 后端已经变得非常“日常化”。入门成本低,能跑通一个导出接口只需要十几分钟;想做得稳、扛得住数据量,再往深走一步就是分批写、模板填充和多 Sheet 组合。希望这篇里分享的代码和避坑经验能派上用场,少踩几个我已经踩过的坑。

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

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

立即咨询