EasyExcel导出异常 Can not close IO 根因与关流排查
凌晨两点被一个导出接口的告警叫醒日志里只有一行Can not close IO堆栈往上翻三层全是 EasyExcel 的类名看起来像是框架自己出了问题。如果你也踩过使用 EasyExcel 导出 Excel 抛异常 Can not close IO这个坑大概率已经搜过一圈得到的答案从流关两次到浏览器取消了下载再到磁盘满了什么都有。这些说法其实都对但每一条都只对应一种根因照抄任何一种改法都像是在赌运气。我想先说一个反直觉的结论这条异常里EasyExcel 本身几乎从来不背锅。它只是在自己收尾的时候把底层抛上来的 IO 异常包了一层壳壳上写死了Can not close IO这句话真正的凶手永远藏在Caused by里。所以这篇不打算直接甩给你一段万能代码而是把导出链路里到底有哪几方会去关那个流、什么情况下关闭会失败、怎么从一堆堆栈一路收敛到那一行代码讲透。内容偏实战适合已经有 Java 和 EasyExcel 使用基础、正在被导出问题困扰的后端同学。如果你刚开始接触 Excel 导出前面关于流所有权和 SXSSF 临时文件那几段也建议耐心看完能帮你少走很多弯路。1. 先把这条异常链读明白再决定改哪里1.1 EasyExcel 的 finish 阶段到底在关什么不管你是用EasyExcel.write(outputStream, UserVO.class).sheet(用户).doWrite(list)这种一行流写法还是手动build()出ExcelWriter再自己调finish()最后都会走到同一个地方WriteContextImpl的 finish 逻辑。它做的事情大致分三步——先把还没结束的 sheet 收尾然后拿到WriteWorkbookHolder里的 workbook 调用close()最后如果autoCloseStream为 true再把输出流也关掉并打上已完成的标记避免重复收尾。这三步被一个 try/catch 整体包住catch 到任何异常都会被转换成new ExcelGenerateException(Can not close IO, e)抛出来。也就是说报错发生在收尾阶段而不是写数据阶段。这一点非常重要很多同学一看到异常就以为数据没写出去其实数据早就全部写完了用户那边文件甚至已经下载成功只是最后关灯锁门的动作失败了。理解了位置再看 xlsx 的本质会更清楚。xlsx 文件其实就是一堆 XML 打成的 zip 包POI 在写的过程中是流式往压缩流里塞条目的。workbook.close()这一步相当于把压缩包封口要把中央目录、条目索引写出去并 flush。如果这个时候底层的通道已经断了、或者已经被别人关掉了封口就会失败EasyExcel 顺手抛出那句Can not close IO。所以这个异常的本质是写完了但没封好口而不是没写。1.2 为什么这条异常看起来没有信息量因为 EasyExcel 把 message 写死成了一个固定字符串它不区分你到底是流被关了、磁盘满了还是网络断了。这么设计有它的道理——框架只想知道收尾失败了具体的错误留给 JDK 和容器去表达。但对排查的人来说就很痛苦等于把最有价值的信息全部下沉到了cause链里。更麻烦的是很多项目的日志配置或者全局异常处理器只打e.getMessage()或者用某个切面把异常统一转成了业务错误码Caused by那一整段直接丢了。你在日志平台上翻半天只看到一行 Can not close IO自然无从下手。我见过好几个团队卡在这个问题上好几天最后发现根因只是日志没打全。所以第一条硬性建议在导出链路的异常处理里一定要把完整的 cause 链打出来。下面这段是我自己在项目里常用的工具方法直接放在导出的 finally 里用就行public static String dumpCauseChain(Throwable e) { StringBuilder sb new StringBuilder(); Throwable cur e; int depth 0; while (cur ! null depth 10) { sb.append(depth 0 ? : Caused by: ) .append(cur.getClass().getName()) .append(: ) .append(cur.getMessage()) .append(\n); cur cur.getCause(); depth; } return sb.toString(); }注意cause链有可能成环某些容器实现会这样加一个深度上限别写成死循环。1.3 一张对照表常见 Caused by 分别意味着什么把 cause 链打出来之后剩下的就是对号入座。下面这张表是我这几年攒下来的覆盖了绝大多数情况看到对应的关键字基本就能定性Caused by 关键特征常见触发场景快速判定方法处理方向java.io.IOException: Stream Closed流已经被关过一次收尾时又去关或去写本地单测就能稳定复现去掉重复关闭Web 场景设autoCloseStream(false)Broken pipe/Connection reset by peer客户端提前断开下载被取消或网络抖动只在线上偶发本地压不出来降噪成 warn敏感场景改为先落盘再推ClientAbortException容器Tomcat检测到客户端已离开堆栈里有容器包名同上属于正常现象FileNotFoundException且提示另一个程序正在使用此文件Windows 上目标文件被 Excel、杀软占用手动删除该文件会失败换文件名、加时间戳、错峰导出No space left on device磁盘写满df -h一眼可见清理磁盘、迁移临时目录Permission denied/Read-only file system目录权限不足或挂载为只读ls -l看属主修权限或用可写目录Too many open files句柄泄漏历史流没关干净lsof -p pid | wc -l修 finally 逻辑加句柄监控NullPointerException输出流本身为 null或自定义 handler 拿到的对象不对本地必现检查流获取顺序和 WriteHandler 逻辑实际遇到的组合往往比表里更复杂比如磁盘满导致的 close 失败外面还会套一层 POI 的异常。但只要你养成了先看Caused by最深层那个异常的习惯定位速度会快非常多。2. Web 下载接口里的关流权责谁该关谁不该关2.1 Servlet 容器同样持有着那个 OutputStream在 Web 场景里response.getOutputStream()返回的ServletOutputStream并不是你创建的它的生命周期归 Servlet 容器管。请求处理结束时容器会自己去 flush 和 close 这个流。也就是说这个流天然有两个监护人一个是容器一个是你代码里调用的 EasyExcel。很多人没意识到这一点写出来的代码往往是这样的try (ServletOutputStream out response.getOutputStream()) { EasyExcel.write(out, UserVO.class).sheet(用户).doWrite(list); }这段代码在本地跑小数据量时经常看起来没问题但它在语义上是错的try-with-resources 会在块结束时关掉流而 EasyExcel 默认的autoCloseStream又是 true等于关了两次。再加上容器最后还会关一次三方争抢同一个流的关闭权出问题只是时间早晚和数据量大小的问题。2.2 autoCloseStream 的默认值是真凶之一autoCloseStream这个参数在很多版本的默认值都是true意思是我 EasyExcel 负责把流关上。这个默认值在写本地文件的场景下非常合理——你自己 new 出来的FileOutputStream没人管框架帮你关掉是好事能避免句柄泄漏。但在 Web 场景下它就是个陷阱。因为流的归属权在容器手上你让 EasyExcel 提前把它关了后续容器再操作这个流就可能出问题如果中间再有个 Filter 包了一层情况会更复杂。我的取舍原则很简单就下面这张表输出目标autoCloseStream谁负责最终关闭理由本地FileOutputStream/Filetrue默认EasyExcel流是你自己 new 的没人兜底让框架关最省心response.getOutputStream()falseServlet 容器流的归属权在容器越权关闭是麻烦的源头ByteArrayOutputStream无所谓无需关闭内存流 close 是空操作不产生实际影响被 Filter 包装过的流视包装实现而定包装类的持有者需要读包装类源码确认它的 close 语义先把权责理清楚很多偶发的Can not close IO会自动消失。2.3 一份可以直接抄的导出方法下面这段是我目前项目里在用的版本思路是明确让容器做监护人收尾异常按级别降噪GetMapping(/user/export) public void exportUser(HttpServletResponse response) throws IOException { 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); ServletOutputStream out response.getOutputStream(); ExcelWriter writer null; try { writer EasyExcel.write(out, UserExportVO.class) .autoCloseStream(false) // 关键把关闭权还给容器 .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) .build(); WriteSheet sheet EasyExcel.writerSheet(0, 用户列表).build(); writer.write(userService.queryForExport(), sheet); } finally { if (writer ! null) { try { writer.finish(); } catch (Exception e) { log.warn(导出收尾失败, fileName{}, cause{}, fileName, dumpCauseChain(e)); } } } }有两个细节值得说一下。第一Content-Disposition里的中文文件名一定要用URLEncoder编码并把替换成%20否则文件名在部分浏览器上会变成乱码或者被截断这也是导出场景里高频的看起来不报错但体验很差的问题。第二writer.finish()放在finally里、并且单独包一层 try/catch是为了保证即使写数据阶段抛了异常收尾动作也要执行同时不让收尾异常把真正的业务异常覆盖掉。2.4 用户点了取消日志里该不该报错这是我最想聊的一点因为它牵扯到一个认知问题而不是技术问题。用户点了下载浏览器开始拉取数据拉到一半用户觉得慢点了取消或者直接把标签页关了。这时候服务端还在往 socket 里写写不进去抛Broken pipeEasyExcel 收尾时把它包装成Can not close IO。从代码角度看这是个异常从业务角度看这是完全正常的行为用户没做错任何事你也不该把它算作系统故障。我踩过的坑是早期项目里没做区分导致导出接口的告警率奇高运维那边天天找你最后发现全是取消下载。后来我做了两件事——一是统一用上面那段dumpCauseChain打日志靠关键字把Broken pipe、ClientAbortException、Connection reset这三类识别出来日志级别降到 warn 且不进告警通道二是给导出接口单独配了成功率统计口径把客户端主动断开排除在失败率之外。提示降噪不等于全吞。把Stream Closed、No space left on device这类也一起吞掉等于给自己埋雷。识别关键字要精确别用宽泛的catch (Exception e) { }。3. 写本地文件也报错磁盘、权限与文件锁3.1 Windows 上最常见的文件被占用如果导出目标是本地目录比如定时任务生成报表文件、后台异步导出落盘那么Can not close IO大概率跟网络、客户端没关系纯粹是文件系统层面的问题。我在 Windows 环境下见过最多的一种是上一次导出的文件还开着 Excel 没关下一次任务直接往同一个路径写。表现是FileNotFoundException提示信息里带另一个程序正在使用此文件进程无法访问。流创建失败workbook 拿不到通道收尾时自然报错。这个问题的隐蔽之处在于它在开发和测试环境几乎不会出现——谁会一边开着 Excel 一边跑单测呢结果上线到客户现场客户财务同事天天开着报表文件问题就来了。所以给导出文件命名时我现在的习惯是强制带上时间戳或者业务批次号从根上避免覆盖同一个文件String fileName user_export_ LocalDateTime.now() .format(DateTimeFormatter.ofPattern(yyyyMMddHHmmss)) .xlsx; File target new File(exportDir, fileName);顺带说一句杀毒软件的实时防护也会短暂锁定新建的文件尤其是在文件比较大、写入时间比较长的时候。如果你排查了半天代码没发现问题不妨看一眼进程监控里有没有安全软件在你的输出目录上转悠。3.2 临时目录、磁盘配额与句柄数EasyExcel 默认走的是 POI 的 SXSSF 模式特点是低内存代价是要用临时文件。它的工作方式是内存里只保留一定行数的滑动窗口超出的行会滚动写到临时目录下的文件里最终收尾时再把这些临时片段合并成完整的 xlsx。临时目录默认取java.io.tmpdir在多数 Linux 服务器上就是/tmp。这条链路上有三个容易翻车的点。第一/tmp被单独挂载且容量很小一个几百兆的导出就能把它撑爆然后就是No space left on device。第二容器化部署时/tmp可能被设成只读或者被 tmpfs 限制在很小的尺寸写临时文件直接失败。第三临时文件没被及时清理日积月累把 inode 耗光这时候报错信息可能非常隐晦连磁盘满都不给你。我一般会显式指定临时目录并且启动参数里也固定住java -Djava.io.tmpdir/data/app/tmp -jar app.jar同时给/data/app/tmp配一个定时清理任务只删超过一天的文件避免误删正在写的临时片段。还有一个容易被忽略的指标是句柄数——如果你在 finally 里漏了finish()或者早期版本存在流泄漏跑几天之后Too many open files就会找上门而它表现出来往往就是某次导出突然抛了Can not close IO。用lsof -p pid | wc -l对比一下进程重启前后的数字能很快看出有没有泄漏。3.3 中文路径、超长路径与工作目录还有两类问题看着不像问题但确实会让人抓耳挠腮。一类是中文路径某些老环境里 JVM 的file.encoding不是 UTF-8配置文件里写的中文目录名在读取时被解析成了乱码FileOutputStream创建失败。另一类是 Windows 上的路径长度限制目录嵌套深一点加上中文文件名总长度超过 260 个字符就直接创建失败报错信息还很不友好。另外如果你在配置文件里写的是相对路径比如./export/那么工作目录就决定了它到底指向哪里。同一份代码在 IDE 里跑和在服务器上用脚本启动工作目录可能完全不同。我的做法是导出目录一律走配置项并且强制转成绝对路径启动时打一行日志把最终路径输出出来出问题第一眼就能看见。4. 四步定位法从一堆堆栈收敛到一行代码4.1 第一步把 cause 链完整打出来这一步看起来最没技术含量但它是后面所有步骤的前提。前面给的dumpCauseChain直接拿来用把导出接口的最外层异常包进finally里打一遍同时确认日志框架没有做任何只打 message的裁剪。如果你用的是统一异常处理器检查一下它有没有把 cause 丢掉。我见过一个项目全局处理器里写的是log.error(e.getMessage())整个团队拿着这个 message 排查了两天最后发现原异常其实写得很清楚——Stream Closed就四个字。4.2 第二步把 Web 层摘掉做最小复现定性之后接下来的动作是二分定位。具体做法是把同一份数据、同一个实体类改成写本地文件Test public void writeToLocal() { String path /tmp/easyexcel_debug.xlsx; EasyExcel.write(path, UserExportVO.class) .sheet(用户) .doWrite(mockData(10000)); System.out.println(done, size new File(path).length()); }如果本地文件也报同样的错那问题在磁盘、权限或者你自己对流的操作上跟容器和网络无关如果本地一切正常那问题一定出在 Web 层继续往下二分——去掉所有 Filter 和拦截器再试一次如果好了就是某个 Filter 在搞事。这个摘掉一半再看的思路听起来朴素但它能省下大量猜测的时间。我见过太多人一上来就改代码改完发现异常只是换了个地方出现。4.3 第三步排查重复关闭与流包装Web 层的问题里重复关闭占了绝大多数。排查时按下面这个清单过一遍搜索导出方法里有没有try (...)形式的流声明有的话就去掉确认autoCloseStream是否被显式设置过Web 场景应为 false检查有没有out.flush()或out.close()出现在writer.finish()之前检查工具类里有没有顺手关流的习惯比如某些封装过的下载工具、IO 工具类检查接口方法的返回值——如果方法返回了对象、同时又手动写了 response 流Spring 之后还会再往这个响应里写一次这种行为在某些配置下就会引发冲突。第 5 条是比较隐蔽的一类。正确做法是要么返回void直接写流要么返回byte[]交给框架去写不要两边都插手。4.4 第四步排查并发与自定义 WriteHandler如果前面三步都没抓到问题那就要往更深处看了。有两类场景特别容易出问题一类是并发复用。比如把ExcelWriter存成了成员变量或者放在静态缓存里多个请求同时进来共用同一个 writer 和同一个输出流。这种情况下报的错五花八门Can not close IO只是其中一种。要记住ExcelWriter是不可共享的状态机对象一次导出对应一个实例用完即弃。另一类是自定义 WriteHandler。为了实现合并单元格、自定义下拉框、动态样式很多项目会注册一大堆 handler。如果在 handler 里碰了 workbook 或者底层的流对象比如手动关闭了某个 sheet 上的资源收尾阶段就会撞车。排查方式很简单把所有自定义 handler 先注释掉跑一遍一次只加回一个哪个加回来就报错就是哪个的问题。还有一种不太常见但值得一提的情况是模板导出。withTemplate读的是模板文件如果模板目录被设成只读、或者模板正在被其他进程使用读的时候可能不报错但收尾释放资源时会失败。这类问题换个未被占用的模板路径就能验证。5. 数据量一上来就更容易炸大文件导出的连锁反应5.1 为什么数据量大时 close 阶段更容易出问题同一个接口导出一千条数据从来不报错导出十万条数据就偶发Can not close IO这背后的原因其实很直白数据量越大收尾阶段要干的活就越多暴露在外的时间窗口就越长。SXSSF 模式下内存里只留一个固定行数的滑动窗口超出部分会滚到临时文件。数据量越大临时文件越多收尾时要把它们合并、压缩、封口整个过程的耗时可能从几十毫秒涨到十几秒。这十几秒里客户端可能已经不耐烦地取消了磁盘可能写满了GC 可能触发了一次长暂停导致连接超时。任何一个环节出问题最后都表现为那一句Can not close IO。所以当你发现小数据量没事、大数据量偶发的时候不要去怀疑 EasyExcel 的 bug先去看看是不是这几个外部条件在小数据量下还没被触发。这也是为什么同一个异常在小流量测试环境永远复现不出来的原因。5.2 分批写入与多 Sheet 的取舍大数据量导出时很多人第一反应是分多次写。这里有个必须避开的坑不要用同一个输出流反复调用EasyExcel.write()。写成下面这样是错的// 错误示范同一个流被写了两遍完整的工作簿文件必然损坏 EasyExcel.write(out, UserVO.class).sheet(第一批).doWrite(list1); EasyExcel.write(out, UserVO.class).sheet(第二批).doWrite(list2);第二次调用会在已经写过内容的流位置上再写一个完整的工作簿结构得到的 xlsx 是坏的Excel 打开时会提示文件格式错误或者内容不完整。正确姿势是一个 writer、多个 sheetExcelWriter writer EasyExcel.write(out, UserVO.class).autoCloseStream(false).build(); try { for (int i 0; i batches.size(); i) { WriteSheet sheet EasyExcel.writerSheet(i, 第 (i 1) 批).build(); writer.write(batches.get(i), sheet); } } finally { writer.finish(); }这种做法把临时文件的合并、封口动作集中到最后一次完成既减少了开销也避免了重复关闭流的问题。但要注意 sheet 数量不能无限膨胀几百个 sheet 的 xlsx 用 Excel 打开基本会卡死用户那边体验同样很差。如果单个 Sheet 的行数必须很大比如需要一整个下拉筛选表inMemory这个参数值了解一下默认走 SXSSF 的低内存模式改成inMemory(true)会切到全内存的 XSSF 模式兼容性和某些特性支持更好代价是内存占用会显著上升。数据量大时切到全内存模式很容易变成 OOM谨慎使用。5.3 先攒内存再落盘哪些场景值得哪些是灾难还有一类做法是把整个 Excel 先写进ByteArrayOutputStream全部成功之后再一次性写到 response。它的好处很实在写内存的过程中客户端断不断开完全不影响你因为根本没有网络参与等你开始写 response 的时候数据已经是一个完整的字节数组失败了也只是一个普通的写失败。这个方案的代价也很明显。xlsx 在生成过程中临时数据、压缩前数据、压缩后数据会同时存在内存占用大概是最终文件大小的 3 到 5 倍。一个 50MB 的导出文件峰值可能吃掉 200MB 堆内存如果是十个人同时导出堆直接就没了。所以我自己的判断标准是导出行数在几千行、文件预期不超过 10MB 的场景可以用超过这个量级就老老实实流式写 response把偶尔的客户端断开当成正常损耗接受。如果确实需要先落盘再推送比如导出结果要缓存、要重试用临时文件而不是内存先写到/data/app/tmp/xxx.xlsx成功后再用Files.copy推到 response最后删掉临时文件。删除失败不用管交给定时清理任务兜底就行。5.4 把异常降噪成日志要守住的那条线前面提过降噪这里想再强调一次边界。我在项目里把导出的收尾异常分成三档客户端断开类Broken pipe、ClientAbortException、Connection reset打成 warn 并且不计入告警资源类No space left on device、Too many open files、Permission denied打成 error 并立刻告警其他未知类型打成 error 带上完整 cause 链作为待排查项。这样既不会被噪声淹没也不会真的出事的时候没人知道。还有一点是别让异常把文件写坏了还装作没事。如果你在收尾阶段吞掉了异常用户可能拿到一个结构不完整的 xlsx用 Excel 打开时会弹一堆提示甚至出现打开后没法正常复制粘贴、单元格操作异常这类看起来毫不相干的现象——本质上是文件的 ZIP 结构和索引没写完。遇到这种文件能打开但操作很怪的反馈回头查一下导出链路的收尾异常有没有被静默吞掉往往能找到答案。6. 导入链路与模板导出的同类现象6.1 导入侧的 close 异常同样的Can not close IO也会出现在导入链路里只是外层的异常类型通常是ExcelAnalysisException而不是ExcelGenerateException。这个时候要关注的对象就从 OutputStream 变成了 InputStream排查思路完全一致先看 cause。我遇到过的几次导入侧收尾失败原因集中在两类。一类是流被上游提前关掉或者清理掉比如用了MultipartFile上传读到一半临时文件被某个定时清理任务干掉了收尾时再去操作底层通道就失败。另一类是复杂表头导入时headRowNumber配得不对导致解析器读到了超出预期的位置收尾时状态机处于一个奇怪的状态。导入代码我现在的写法是这样的核心原则是谁创建谁关闭职责单一PostMapping(/user/import) public RVoid importUser(RequestParam(file) MultipartFile file) throws IOException { try (InputStream in file.getInputStream()) { EasyExcel.read(in, UserImportVO.class, new UserImportListener(userService)) .headRowNumber(2) // 复杂表头前两行都是表头 .autoCloseStream(false) // 流由 try-with-resources 负责 .sheet() .doRead(); } return R.ok(); }这里autoCloseStream(false)和 try-with-resources 是配对出现的既然外层已经明确负责关流就不要再让框架插手避免出现两方都以为自己该管的混乱状态。这个原则不管是在导入还是导出只要涉及流的归属都适用。6.2 模板导出时的文件占用与路径陷阱用模板导出withTemplate的场景还有两个专属的坑。一个是模板文件被占用尤其在 Windows 上如果模板文件正被 Excel 打开着读取阶段可能勉强能过但收尾释放资源时就容易出问题。另一个是打包之后模板读不到了本地开发时模板放在src/main/resources下用相对路径读得好好的打成 jar 之后withTemplate(templates/user.xlsx)这种写法会直接找不到文件因为类路径资源在 jar 里是以流的形式存在的不是一个真实路径。解决办法有两个按优先级来优先用withTemplate(InputStream)的版本从ClassPathResource或者getResourceAsStream拿流如果模板需要运营同学随时改就把模板放到 jar 外部的独立目录用绝对路径读改完重启或者加个缓存刷新机制。我自己的项目里选的是后一种因为财务那边的表头格式一年要改好几次放外面省事。提示外部模板目录记得设成只读权限给应用账号别让它有机会被误写模板一旦损坏所有导出都会跟着出问题。最后再分享一个我自己的小习惯在导出接口的入口处加一行日志把本次导出的目标类型、预计行数、输出目标文件路径或 response、是否使用模板都打出来同时在收尾的 catch 里把 cause 链补上。这样哪怕线上再冒出一个Can not close IO你拿到日志的第一眼就能知道这次导出到底在往哪里写、写了多少、卡在哪一环不用再去翻代码猜上下文。这行日志的成本几乎为零但省下来的排查时间我已经不知道折算成多少个能准时下班的晚上了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →