EasyExcel导出报错全解析:从OOM到乱码的排查与解决方案

发布时间:2026/10/12 20:33:32
EasyExcel导出报错全解析:从OOM到乱码的排查与解决方案 1. 从一个导出报错说起为什么EasyExcel总在关键时刻掉链子做过后台管理系统的人对导出Excel这个需求肯定不陌生。运营要导订单、财务要导流水、教务要导成绩单几乎每个项目都会碰到。EasyExcel因为上手快、内存占用低成了很多团队的首选。但真正把它用到生产环境里你会发现导出报错这件事比想象中要磨人得多。我印象很深的一次是给一个后台做月度报表导出。本地跑得好好的测试环境也没问题一上生产运营点导出就转圈最后弹一个空白页或者直接500。日志里翻半天要么是ExcelGenerateException要么是ConvertException要么干脆什么异常都没有文件就是空的。那段时间我几乎把EasyExcel的源码翻了个遍也踩了不少坑今天就把这些经验系统性地梳理一遍。这篇文章主要面向已经用过EasyExcel、但在导出环节遇到各种报错的开发者。不管你是遇到内存溢出、格式错乱、中文乱码还是下载下来文件打不开我都会从原理到实操一步步拆解。文章会覆盖错误分类、根因分析、排查手法、代码级解决方案以及一些只有踩过坑才知道的细节。看完之后你应该能独立定位并解决绝大多数EasyExcel导出问题。2. 导出报错的整体分类与排查思路2.1 先搞清楚错误发生在哪个阶段很多人一看到报错就急着改代码其实第一步应该是定位错误发生在哪个阶段。EasyExcel的导出流程大致可以分成四段数据准备阶段、写入阶段、响应输出阶段、客户端打开阶段。每个阶段报错的表象和根因完全不同。数据准备阶段出错通常是查询数据时抛异常或者数据量太大导致内存问题。写入阶段出错多半是实体类映射、类型转换、样式设置的问题。响应输出阶段出错往往是HTTP头设置、流关闭时机、编码的问题。客户端打开阶段出错则是文件本身损坏或者格式不兼容。我一般会先在Controller入口打一行日志在excelWriter.finish()之后打一行日志在响应流关闭前再打一行。这样三行日志就能把问题范围缩小到具体阶段。这个习惯帮我省了大量时间强烈建议你也加上。2.2 常见错误类型速查表下面这张表是我这些年遇到过的典型错误按出现频率排序你可以对照自己的日志快速定位。错误类型典型异常信息高发阶段根因方向内存溢出OutOfMemoryError: Java heap space数据准备/写入一次性查全量数据类型转换失败ExcelDataConvertException写入实体字段类型与单元格不匹配文件为空无异常下载0字节响应输出流未flush或提前关闭文件损坏打开提示格式错误客户端打开响应头或流写入方式错误中文乱码文件名或内容乱码响应输出编码未设置UTF-8样式丢失导出无表头样式写入样式策略未生效重复导出同一请求生成多个文件响应输出流被多次写入这张表建议收藏遇到问题先对号入座能省掉大量盲目排查的时间。2.3 排查的基本顺序我的排查顺序一般是先看日志异常栈再看数据量再看实体映射最后看响应配置。为什么是这个顺序因为异常栈最直接能告诉你错误类型数据量决定了是不是内存问题实体映射是EasyExcel最容易出问题的地方响应配置则是最容易被忽略的环节。很多人排查时喜欢从代码逻辑开始看但代码逻辑往往是对的问题出在配置或者数据上。先看日志和数据能快速排除掉一大半可能性。3. 内存溢出类错误的深度拆解3.1 为什么EasyExcel也会OOMEasyExcel主打的就是低内存很多人以为用了它就绝对不会OOM。这是个误区。EasyExcel的低内存是针对写入过程的它通过分批写入磁盘来降低内存占用。但如果你在查询数据这一步就把全量数据加载到内存里那OOM照样会发生。我见过最典型的写法是这样的先selectList()查出十万条数据放到List里然后传给EasyExcel去写。这种情况下EasyExcel再省内存也没用因为数据本身已经把堆撑爆了。正确的做法是分页查询边查边写。3.2 分页写入的正确姿势分页写入的核心思路是每次查一批写一批释放一批。下面是一个可参考的实现框架。public void exportByPage(HttpServletResponse response, int pageSize) { int pageNo 1; ExcelWriter excelWriter null; try { excelWriter EasyExcel.write(response.getOutputStream(), OrderVO.class).build(); WriteSheet writeSheet EasyExcel.writerSheet(订单数据).build(); while (true) { ListOrderVO data orderMapper.selectByPage(pageNo, pageSize); if (data null || data.isEmpty()) { break; } excelWriter.write(data, writeSheet); data.clear(); pageNo; } } finally { if (excelWriter ! null) { excelWriter.finish(); } } }这里有几个关键点。第一ExcelWriter只创建一次WriteSheet也只创建一次这样表头只会写一次。第二每批数据写完立刻clear()帮助GC回收。第三finish()必须放在finally里否则异常时文件不完整。3.3 分页大小的选择经验分页大小设多少合适这个没有绝对标准但我的经验是500到2000之间。太小了查询次数多数据库压力大太大了单批内存占用高失去分页意义。具体怎么定可以这样估算假设每条记录平均占用2KB内存JVM可用堆是1GB那么理论上单批可以放50万条。但实际要考虑其他对象占用所以留出十分之一的安全余量单批控制在5万条以内。再结合数据库单次查询的性能500到2000是个比较稳妥的区间。如果你的数据单条特别大比如带长文本字段那分页要相应调小。我做过一个带富文本的导出单条记录接近50KB最后分页设的是200。注意分页查询时一定要有稳定的排序字段否则分页可能出现数据重复或遗漏。用主键或者创建时间排序最稳妥。4. 类型转换与实体映射类错误4.1 ExcelDataConvertException的常见触发场景ExcelDataConvertException是导出时最常见的异常之一。它的本质是实体字段的类型和EasyExcel期望写入的单元格类型对不上。比如你用LocalDateTime字段但没有配置转换器EasyExcel就不知道怎么把它写进单元格。常见的触发场景有这么几类。第一类是日期时间类型LocalDate、LocalDateTime、Date都需要显式配置格式。第二类是枚举类型直接写会输出枚举的name或者报错。第三类是自定义对象比如金额用了BigDecimal但没指定精度。第四类是布尔类型有时候会写成TRUE/FALSE而不是你想要的是/否。4.2 日期格式化的正确配置日期问题我踩过最多次。早期我用DateTimeFormat注解结果发现那是Spring的注解EasyExcel不认。EasyExcel要用ExcelProperty配合converter或者用DateTimeFormatEasyExcel自己也有一个同名注解容易混淆。最稳妥的方式是自定义转换器。下面是一个LocalDateTime的转换器示例。public class LocalDateTimeConverter implements ConverterLocalDateTime { private static final DateTimeFormatter FORMATTER DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss); Override public ClassLocalDateTime supportJavaTypeKey() { return LocalDateTime.class; } Override public WriteCellData? convertToExcelData(LocalDateTime value, ExcelContentProperty contentProperty, GlobalConfiguration config) { if (value null) { return new WriteCellData(); } return new WriteCellData(value.format(FORMATTER)); } }然后在实体类上这样用ExcelProperty(value 创建时间, converter LocalDateTimeConverter.class) private LocalDateTime createTime;为什么要自定义而不是用内置的因为内置的格式控制不够灵活而且不同版本的EasyExcel对时间类型的支持有差异。自定义转换器一次写好到处复用最省心。4.3 枚举和字典值的处理枚举导出是另一个高频问题。假设订单状态是个枚举直接导出会输出枚举名比如PAID、CANCELLED运营看不懂。这时候有两种方案。方案一是用转换器把枚举转成中文描述。方案二是用ExcelProperty的converter配合一个映射表。我更推荐方案一因为逻辑内聚枚举自己知道怎么展示自己。public enum OrderStatus { PAID(已支付), CANCELLED(已取消); private final String desc; OrderStatus(String desc) { this.desc desc; } public String getDesc() { return desc; } }转换器里直接调getDesc()就行。这样枚举和展示逻辑绑定在一起改的时候只改一处。4.4 实体映射的注意事项实体映射还有几个容易忽略的点。第一ExcelProperty的index和value不要混用混用会导致列顺序混乱。第二如果实体有继承关系父类字段的注解可能不生效需要显式处理。第三字段名和Excel列名的对应关系要清晰别用拼音缩写后期维护很痛苦。我一般会在实体类上加注释标明每个字段对应的业务含义和格式要求。这样即使过了半年再看也能快速理解。5. 响应输出与文件下载类错误5.1 文件为空或0字节的根因文件下载下来是空的这个问题的根因通常有三个。第一流没有flush就关闭了。第二excelWriter.finish()没调用或者调用时机不对。第三响应头设置有问题导致浏览器没接收到内容。我遇到过一次是因为在finish()之前就response.getOutputStream().close()了结果数据还没写完流就关了。正确的顺序应该是先finish()再关闭流。而且关闭流这个动作最好交给容器去做不要手动关。5.2 响应头的正确设置响应头设置是下载功能的基础但很多人写得不对。下面是一个标准的配置。response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); String fileName URLEncoder.encode(订单数据, UTF-8).replaceAll(\\, %20); response.setHeader(Content-Disposition, attachment;filename*utf-8 fileName .xlsx);这里有几个细节。第一ContentType要用xlsx对应的MIME类型用错了浏览器可能不识别。第二文件名要URL编码否则中文会乱码。第三filename*这种写法是RFC 5987标准兼容性更好。第四replaceAll(\\, %20)是因为URLEncoder会把空格编码成但HTTP头里空格应该是%20。5.3 流关闭时机的坑流关闭时机是个隐蔽的坑。如果你在finish()之前关闭了输出流EasyExcel写数据时会抛异常。如果你在finish()之后手动关闭又可能和容器的关闭逻辑冲突。我的做法是不手动关闭输出流让容器去管理。只保证finish()被调用。如果用了try-with-resources要注意ExcelWriter的关闭顺序。还有一种情况是用了ResponseBody或者返回ResponseEntity这时候流的控制权在框架手里你只需要把数据写进去就行不要自己去操作流。5.4 多次写入导致文件损坏有时候一个请求会生成多个文件或者文件内容重复。这通常是因为流被多次写入。比如在拦截器或者过滤器里又写了一次响应或者在异常处理里重复写了。排查这种问题可以在写入前后打日志看write方法被调了几次。另外要注意如果用了AOP做统一响应处理可能会干扰文件下载。文件下载的接口应该排除在统一响应包装之外。6. 样式、编码与其他疑难杂症6.1 表头样式不生效表头样式不生效最常见的原因是样式策略没有正确注册。EasyExcel的样式要通过HorizontalCellStyleStrategy或者自定义WriteHandler来设置。WriteCellStyle headStyle new WriteCellStyle(); headStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); WriteFont headFont new WriteFont(); headFont.setFontHeightInPoints((short) 12); headFont.setBold(true); headStyle.setWriteFont(headFont); HorizontalCellStyleStrategy styleStrategy new HorizontalCellStyleStrategy(headStyle, new WriteCellStyle()); EasyExcel.write(outputStream, OrderVO.class) .registerWriteHandler(styleStrategy) .sheet(订单数据) .doWrite(data);注意registerWriteHandler要在sheet之前调用顺序错了样式不生效。这个坑我踩过调了半天才发现是顺序问题。6.2 中文乱码的两种场景中文乱码分两种。一种是文件名乱码一种是单元格内容乱码。文件名乱码是响应头编码问题前面已经讲过。单元格内容乱码通常是字体或者编码设置的问题。单元格内容乱码比较少见因为xlsx本身是UTF-8的。如果真遇到了检查一下是不是用了xls格式老格式对编码支持差或者数据源本身的编码就有问题。6.3 大数据量导出的性能优化大数据量导出除了分页还有几个优化点。第一关闭自动列宽计算这个很耗性能。第二减少样式使用样式越多写入越慢。第三用SXSSFWorkbook模式EasyExcel默认就是这种流式模式。第四如果不需要表头样式就别加。我做过一个百万级导出优化前要跑好几分钟优化后控制在一分钟内。主要就是分页加关闭自动列宽。6.4 并发导出的资源竞争并发导出时如果多个请求同时写同一个临时文件会出问题。EasyExcel默认用临时文件做缓冲临时文件路径如果冲突就会报错。解决办法是给每个导出任务指定独立的临时文件目录或者用内存模式数据量小时。配置方式是在ExcelWriterBuilder里设置inMemory或者临时文件路径。7. 常见问题速查与避坑清单7.1 问题速查表现象可能原因快速验证方法解决方向下载0字节流未flush/finish未调用看finish后日志调整关闭顺序打开提示损坏响应头错误检查ContentType用标准MIME中文乱码编码未设置看响应头设置UTF-8表头无样式策略未注册看register顺序调整调用顺序数据重复分页排序不稳检查order by加主键排序导出慢样式过多看样式数量精简样式内存溢出全量查询看查询SQL改分页查询类型转换异常字段类型不匹配看异常栈加转换器7.2 我踩过的几个典型坑第一个坑是在循环里创建ExcelWriter。早期我图省事每批数据都new一个writer结果文件被覆盖只剩最后一批。正确做法是writer只创建一次。第二个坑是忘记调用finish。有一次异常被catch了但finish在try块里异常后没执行文件不完整。后来我把finish放finally里问题解决。第三个坑是响应头设置顺序。有一次先写了数据再设响应头结果响应头没生效。响应头必须在写数据之前设置。第四个坑是分页查询没有稳定排序。导致同一批数据被查了两次导出结果有重复。加了主键排序后解决。7.3 避坑清单分页查询必须有稳定排序字段ExcelWriter和WriteSheet只创建一次finish必须放finally响应头在写数据前设置输出流不要手动关闭样式策略在sheet前注册大数据量关闭自动列宽并发导出用独立临时目录文件下载接口排除统一响应包装日期枚举必须配转换器这份清单建议贴在工位上每次写导出功能前过一遍能避开八成以上的坑。8. 一套可复用的导出模板8.1 模板结构设计把上面所有经验整合起来我封装了一套导出模板。核心思路是统一入口、分页查询、异常兜底、资源可控。这套模板在我们团队用了两年多基本没再出过导出相关的线上问题。模板分成三层。第一层是Controller负责接收请求和设置响应。第二层是Service负责分页查询和写入。第三层是转换器和样式配置负责格式处理。8.2 核心代码实现public class ExcelExportTemplate { public static T void export(HttpServletResponse response, String fileName, ClassT clazz, PageQueryT pageQuery, int pageSize) { ExcelWriter writer null; try { setResponseHeader(response, fileName); writer EasyExcel.write(response.getOutputStream(), clazz) .registerWriteHandler(buildStyleStrategy()) .build(); WriteSheet sheet EasyExcel.writerSheet(fileName).build(); int pageNo 1; while (true) { ListT data pageQuery.query(pageNo, pageSize); if (data null || data.isEmpty()) { break; } writer.write(data, sheet); data.clear(); pageNo; } } catch (Exception e) { throw new RuntimeException(导出失败, e); } finally { if (writer ! null) { writer.finish(); } } } private static void setResponseHeader(HttpServletResponse response, String fileName) throws UnsupportedEncodingException { response.setContentType( application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); String encoded URLEncoder.encode(fileName, UTF-8) .replaceAll(\\, %20); response.setHeader(Content-Disposition, attachment;filename*utf-8 encoded .xlsx); } private static HorizontalCellStyleStrategy buildStyleStrategy() { WriteCellStyle headStyle new WriteCellStyle(); headStyle.setFillForegroundColor( IndexedColors.GREY_25_PERCENT.getIndex()); WriteFont headFont new WriteFont(); headFont.setBold(true); headStyle.setWriteFont(headFont); return new HorizontalCellStyleStrategy(headStyle, new WriteCellStyle()); } }PageQuery是个函数式接口调用方只需要实现查询逻辑不用关心分页和写入的细节。FunctionalInterface public interface PageQueryT { ListT query(int pageNo, int pageSize); }8.3 使用示例GetMapping(/export) public void export(HttpServletResponse response) { ExcelExportTemplate.export(response, 订单数据, OrderVO.class, (pageNo, pageSize) - orderMapper.selectByPage(pageNo, pageSize), 1000); }调用方只需要一行代码分页、写入、样式、异常处理全在模板里。这样既保证了统一性又降低了出错概率。8.4 模板的扩展点这套模板还有几个扩展点。第一可以加一个beforeWrite回调在写入前做数据预处理。第二可以加一个afterWrite回调写入后做清理。第三可以支持多sheet把WriteSheet做成列表。第四可以加进度回调让前端显示导出进度。这些扩展点我按需加过但核心逻辑保持不变。模板的价值就在于稳定不要为了扩展性牺牲稳定性。9. 一些个人体会导出这个功能看起来简单实际上是个系统工程。它涉及数据库查询、内存管理、IO操作、HTTP协议、文件格式多个层面。任何一个环节出问题都会导致导出失败。我最大的体会是不要相信本地测试的结果。本地数据量小、环境干净很多问题暴露不出来。一定要在接近生产的环境下测试用真实的数据量、真实的并发。我踩过的坑几乎都是在生产环境才暴露的。另一个体会是日志要打够。导出流程长出问题时如果没有足够的日志排查起来非常痛苦。我现在的习惯是在关键节点都打日志包括查询开始、查询结束、写入开始、写入结束、finish调用。这些日志平时看着冗余出问题时就是救命稻草。最后一个体会是模板化能救命。把经过验证的导出逻辑封装成模板团队里所有人都用同一套出问题的概率会大幅降低。即使出问题因为逻辑统一排查也快。这套模板我们迭代了好几个版本现在基本稳定了。如果你也在做导出功能建议先把这篇文章里的避坑清单过一遍然后照着模板搭一套自己的。前期多花点时间后期能省很多事。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询