ARTICLE DETAIL

资讯详情

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

EasyExcel 导出 Can not close IO 根因与解决

EasyExcel 导出 Can not close IO 根因与解决 上周三下午同事在群里贴了半屏堆栈最底下那行是Caused by: com.alibaba.excel.exception.ExcelAnalysisException: Can not close IO上面还压着一串at com.alibaba.excel.write.ExcelBuilderImpl.finish。用过 EasyExcel 做导出的人对这个报错应该不陌生——它最让人困惑的地方在于导出的文件往往是好的打开一看数据一条不少可后端日志里就是红着一片。我先后在三四个项目里碰到过它每次都以为是同一个坑结果根因各不相同有的是 Servlet 容器和 EasyExcel 抢着关流有的是输出流被提前关掉之后又关了一次还有的是用户在浏览器里点了取消下载。这篇就把这几类情形摊开讲清楚这个异常到底从哪里抛出来、autoCloseStream这个参数为什么是关键、大数据量分批导出时关闭逻辑该怎么写、读取场景为什么也会抛同一个异常以及一份可以直接对照的排查表。不管你是刚接手导出功能的新人还是已经在生产环境被这个报错折腾过几轮的老手都能从中找到能直接落地的写法。1. 先把堆栈读透Can not close IO 是关流失败不是写失败1.1 异常抛出的准确位置在 FileUtils.closeEasyExcel 内部有一个工具类叫FileUtils里面有个close(Closeable)方法大意是这样public static void close(Closeable closeable) { if (closeable null) { return; } try { closeable.close(); } catch (IOException e) { throw new ExcelAnalysisException(Can not close IO, e); } }这段代码的信息量很大它只捕获close()抛出的IOException其它异常一概不管。换句话说只要你在日志里看到Can not close IO就说明前面写数据、刷缓冲、生成 xlsx 压缩包这些活儿全都干完了唯独最后释放资源这一步炸了。数据大概率已经完整落进了输出流甚至已经通过 socket 发给了浏览器。所以第一件要做的事是别盯着 Can not close IO 这行看往下一行找Caused by。真正的根因永远藏在被包装的那个异常里。常见的堆栈形态长这样com.alibaba.excel.exception.ExcelAnalysisException: Can not close IO at com.alibaba.excel.util.FileUtils.close(FileUtils.java:xx) at com.alibaba.excel.write.ExcelBuilderImpl.finish(ExcelBuilderImpl.java:xx) at com.alibaba.excel.ExcelWriter.finish(ExcelWriter.java:xx) at com.example.controller.OrderExportController.export(OrderExportController.java:88) Caused by: java.io.IOException: Stream closed at java.io.FilterOutputStream.close(FilterOutputStream.java:xx)看到Stream closed就是流已经被别人关过了看到Broken pipe或者Connection reset by peer就是客户端那边把连接掐了看到No space left on device那问题落在磁盘上。这三类Caused by对应完全不同的处理方向认清它比盲目改代码有用得多。1.2 为什么它比写入阶段的异常更难收拾写入阶段抛异常比如类型转换失败、单元格格式错误你还能在catch块里给 response 写一个 JSON 错误体前端弹个提示。但Can not close IO出现在收尾阶段此时响应大概率已经 committed状态码和响应头都已经发出去了你再也改不了任何东西。这时候如果代码里还有一段全局异常处理器想去写 JSON它会因为 response 已提交而抛出IllegalStateException于是日志里变成两个异常叠在一起排查难度直接翻倍。我在一个老项目里就吃过这个亏导出接口的catch块写了response.reset()想着清空缓冲重新输出错误信息结果因为已经 commitreset 直接抛异常把原本的根因彻底淹没。后来改成先判断response.isCommitted()只有未提交时才尝试回写错误信息日志一下就干净了。这个判断看着不起眼却是导出接口能不能安心排查问题的分水岭。提示导出接口的 catch 块里先判断response.isCommitted()已提交就只记日志别再试图往 response 里写任何东西。2. 三类高频触发场景根因各不相同2.1 场景一Servlet 容器和 EasyExcel 抢着关流这是最经典的一类。EasyExcel 的写 API 有个默认行为autoCloseStream为true意思是ExcelWriter.finish()执行时会顺手把你传进去的OutputStream关掉。Web 下载场景里这个流是response.getOutputStream()拿到的ServletOutputStream而 Servlet 容器Tomcat、Jetty 等在请求处理链走完之后自己也会去关闭这个流。正常情况下EasyExcel 在方法体内先关容器后关容器的关闭动作是幂等的所以看不出问题。真正出事的是中间插了别的东西比如项目里挂了响应压缩过滤器或者审计日志过滤器用了ContentCachingResponseWrapper这些组件会把原始输出流包一层再交给你的 ControllerEasyExcel 关的是外层包装流包装流的close()里如果有写 footer、刷缓存、回写复制内容这类逻辑就会在容器已经收尾之后再去碰底层流于是抛IOException。判断方法很简单把过滤器全部摘掉只留一个空接口跑导出如果异常消失基本可以锁定是过滤器链路的问题。处理办法有两个方向一是设置.autoCloseStream(Boolean.FALSE)让 EasyExcel 别去动这个流二是调整过滤器顺序保证它不包裹导出接口的流。我更推荐前者改一行代码风险最小。2.2 场景二输出流被提前关闭或重复关闭第二类根因是自己代码里的关闭动作多了。常见的几种写法都在这里翻车过// 写法一手动关了一次finish 时又关一次 OutputStream os response.getOutputStream(); BufferedOutputStream bos new BufferedOutputStream(os); EasyExcel.write(bos, OrderVO.class).sheet().doWrite(list); bos.close();// 写法二把包装流交给 try-with-resources同时又让 EasyExcel 持有 try (BufferedOutputStream bos new BufferedOutputStream(response.getOutputStream())) { EasyExcel.write(bos, OrderVO.class).sheet().doWrite(list); }这两种写法里doWrite内部会触发finishfinish又会去关bos。第一次关没问题第二次关的时候某些包装流实现会抛Stream closed。注意区分一下EasyExcel 的ExcelWriter.finish()自己是有防重复调用保护的第二次调用会抛Can not call finish() twice!那是另一个异常别搞混了。这里抛Can not close IO的永远是底层的流。所以碰到这个异常先把导出代码里所有涉及close()、flush()、try-with-resources 的地方列出来数一数同一个流到底被关了几次。我个人的习惯是导出这段逻辑里流只允许被关一次而且关它的地方要写在注释里。多人协作的项目里这条规则能省下大量排查时间。2.3 场景三客户端断连带来的 Broken pipe这一类最容易被误判成代码 Bug其实跟代码关系不大。用户在导出大文件时等得不耐烦点了取消、关了标签页、刷新了页面或者手机端切到后台被系统回收服务端和浏览器之间的 TCP 连接就断了。EasyExcel 收尾时要关闭流底层往 socket 写剩余数据直接抛Broken pipeLinux 环境或Connection reset by peer被包一层就成了Can not close IO。它的典型特征是偶发、无法稳定复现、日志里只有Caused by能看出端倪。Tomcat 环境下还会先抛一个org.apache.catalina.connector.ClientAbortExceptionSpring 的日志里能看到。遇到这种处理方式不是修代码而是把它从告警规则里排除掉——记一条warn日志就够别让它触发电话告警否则运维半夜被叫起来大概率只是某个用户手滑点了取消。还有一个容易忽略的中间环节Nginx 反向代理。默认的proxy_read_timeout是 60 秒如果导出耗时超过这个值Nginx 会主动断开与后端的连接后端的表现和客户端断连一模一样。全量订单导几十万行的时候这个坑非常容易踩。解决办法是在对应的 location 里单独调大超时并把proxy_buffering关掉让数据边生成边下发而不是等后端全部写完再转发。3. autoCloseStream 到底管什么关闭责任怎么划分3.1 被大多数人忽略的默认值autoCloseStream在写和读两边都存在默认值都是true。写这边的语义是ExcelWriter.finish()执行时是否自动关闭传入的OutputStream。设成false之后EasyExcel 只负责把数据刷出去关流的活儿留给调用方。很多人不知道这个参数代码写成EasyExcel.write(response.getOutputStream(), OrderVO.class).sheet().doWrite(list)默认就让 EasyExcel 去关 Servlet 的输出流了。平时能跑一旦环境里多了个压缩过滤器或者换了容器版本问题就冒出来。这也是为什么同一个导出接口在测试环境好好的上了生产就开始报错——不是代码变了是链路上多了一层东西。读这边同理。autoCloseStream为true时ExcelReader.finish()会关掉你传进去的InputStream。如果这个InputStream来自MultipartFile.getInputStream()而容器在请求结束后自己也会关它那你又多了一次重复关闭的机会。3.2 我用的一张关闭责任对照表与其每次靠记忆判断不如按场景定规则。下面这张表是我自己项目里沿用的划分方式可以直接抄使用场景autoCloseStream关流责任方说明Web 下载写 responsefalseServlet 容器避免与容器抢着关最省事写本地文件自己 new 的流trueEasyExcel也可以用 try-with-resources 自己管写本地文件流要二次加工false业务代码比如写完还要加密、追加签名写入 ByteArrayOutputStream无所谓无实际影响内存流的 close 是空操作读取 MultipartFile 上传流false容器容器会在请求结束时统一释放读取本地文件流trueEasyExcel写法最简洁表里的原则其实就一句话谁打开的流谁负责关EasyExcel 只是借用除非你把关闭权明确交给它。Web 场景下流不是你打开的是容器给的那就设成false把关闭权还回去写文件时流是你自己 new 的交给 EasyExcel 关或者自己用 try-with-resources 关都行但不要两边都关。注意设成autoCloseStream(false)之后finish()不会关闭流但依然会把缓冲区里的数据刷出去不影响文件完整性。别担心数据丢。4. 可直接抄的导出代码4.1 小数据量单 sheet 的稳妥写法先看最常见的场景几千行以内、一次查库搞定的导出。这段代码可以直接拿去用重点是autoCloseStream那一行GetMapping(/export/orders) public void exportOrders(HttpServletResponse response) throws IOException { response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); String fileName URLEncoder.encode(订单明细, StandardCharsets.UTF_8.name()) .replaceAll(\\, %20); response.setHeader(Content-disposition, attachment;filename*utf-8 fileName .xlsx); ListOrderVO list orderService.listForExport(); EasyExcel.write(response.getOutputStream(), OrderVO.class) .autoCloseStream(Boolean.FALSE) .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) .sheet(订单明细) .doWrite(list); }几个细节值得说清楚。文件名用URLEncoder编码之后还要把替换成%20否则文件名里的空格在部分浏览器上会变成加号响应头用filename*utf-8这种写法中文名才不会乱码。LongestMatchColumnWidthStyleStrategy是自适应列宽的处理器加上它导出的表格不用手动拉列宽用户体验会好一截。至于autoCloseStream(Boolean.FALSE)它的作用前面已经讲透了——把流的关闭权还给容器。4.2 大数据量分批写入与临时目录数据量上到十万行级别一次性查库再doWrite会有两个问题一是查询结果全在内存里二是 EasyExcel 写 xlsx 时需要维护共享字符串表内存占用会持续攀升。正确做法是分页查询、分批写入用ExcelWriter手动控制节奏GetMapping(/export/orders/all) public void exportAll(HttpServletResponse response) throws IOException { response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setCharacterEncoding(utf-8); String fileName URLEncoder.encode(全量订单, StandardCharsets.UTF_8.name()) .replaceAll(\\, %20); response.setHeader(Content-disposition, attachment;filename*utf-8 fileName .xlsx); ExcelWriter writer null; try { writer EasyExcel.write(response.getOutputStream(), OrderVO.class) .autoCloseStream(Boolean.FALSE) .build(); WriteSheet sheet EasyExcel.writerSheet(全量订单).build(); int pageSize 5000; int pageNo 1; while (true) { ListOrderVO batch orderMapper.pageForExport((pageNo - 1) * pageSize, pageSize); if (CollectionUtils.isEmpty(batch)) { break; } writer.write(batch, sheet); pageNo; } } catch (Exception e) { log.error(导出全量订单失败已写入页数等信息见日志, e); } finally { if (writer ! null) { try { writer.finish(); } catch (Exception e) { log.warn(关闭 ExcelWriter 时异常客户端可能已断开{}, e.getMessage()); } } } }这段代码有几个地方是刻意的。catch块里只记日志不往 response 写任何东西因为此时响应很可能已经提交finally里的finish()单独套了一层 try-catch专门吃掉客户端断连引发的关闭异常。这两处如果漏掉日志里就会出现前面说的那种两个异常叠一起的情况。还有个隐藏知识点EasyExcel 在大数据量场景下会把部分中间数据缓存到磁盘临时文件位置由 JVM 的java.io.tmpdir决定。生产环境如果这个目录指向一个几 G 的小分区导出几十万行时可能把磁盘写满表现出来就是finish阶段报错。稳妥的做法是给应用单独挂一块数据盘启动参数里加上-Djava.io.tmpdir/data/apptmp并配一个定时清理任务。这件事平时没人注意一到月底跑报表就集中爆发。4.3 多 sheet 复用同一个 writer需要在一个工作簿里放多个 sheet 时别写两次EasyExcel.write那样会生成两个文件。正确姿势是共用同一个ExcelWriterExcelWriter writer EasyExcel.write(response.getOutputStream(), OrderVO.class) .autoCloseStream(Boolean.FALSE) .build(); WriteSheet paidSheet EasyExcel.writerSheet(0, 已支付).head(OrderVO.class).build(); WriteSheet refundSheet EasyExcel.writerSheet(1, 已退款).head(OrderVO.class).build(); writer.write(paidList, paidSheet); writer.write(refundList, refundSheet); writer.finish();writerSheet(0, 已支付)的第一个参数是 sheet 序号从 0 开始第二个参数是 sheet 名称。序号和名称最好都写上只用名称的话在某些特殊字符场景下容易出问题。另外.head(OrderVO.class)在这里是为了让每个 sheet 都带上表头如果几个 sheet 结构完全一致也可以在构建 writer 时统一指定不必重复写。5. 排查清单与实战避坑5.1 五步定位法真在生产环境遇到这个异常按下面这个顺序走基本能在十分钟内锁定方向步骤动作判断依据1看Caused by的异常类型Stream closed / Broken pipe / 磁盘错误走向完全不同2数一遍流被关了几次搜代码里所有 close、finish、try-with-resources3检查过滤器链路临时摘掉压缩、审计类过滤器再跑一次4看请求是否被客户端中断对比请求耗时和用户操作时间看 Nginx 访问日志的 4995检查临时目录与磁盘df -h看 tmpdir 所在分区ls -lh看残留临时文件第二步里数流被关几次这件事听起来简单实际在一个几百行的导出方法里很容易漏。我一般会搜三个关键词close(、finish(、try (把搜出来的位置挨个过一遍标出每个流的所有者。这个动作做熟之后一眼就能看出哪两个地方在抢着关同一个流。第四步里提到的 499 状态码是 Nginx 特有的含义是客户端在服务端返回响应前主动关闭了连接。如果访问日志里这个导出接口的 499 特别多那基本可以确认是用户中途取消属于场景三改代码没用改告警规则才有用。5.2 几个文档里不会写的细节第一日志别只打e.getMessage()。Can not close IO这个字符串本身没任何信息量一定要把完整堆栈打出来尤其是Caused by那一层。我见过有同事为了日志清爽把异常消息精简了结果排查时两眼一抹黑。第二导出接口建议单独配一个线程池和超时。默认的 Tomcat 线程被一个跑了三分钟的导出占着并发几个用户就能把整个应用的线程池拖垮。给导出接口加个 120 秒的超时超时就主动断开并记日志比让它无限期挂着强。第三response.isCommitted()这个判断值得加在每一个导出接口的 catch 块里。它不解决根因但能让日志干净十倍。第四前端如果用 fetch 或 axios 发起下载用户在等待期间点了取消浏览器会 abort 请求后端立刻收到断连信号。如果产品上支持取消下载那就必须接受这类异常的存在把它归到正常现象里。6. 读取场景也会抛同一个异常6.1 MultipartFile 的流什么时候会失效Can not close IO不是导出专属导入解析同样会遇到。有个特别典型的坑为了不让用户等太久把 Excel 解析丢进异步线程主线程直接返回上传成功正在处理。结果异步线程刚开始读就抛异常了。原因是MultipartFile.getInputStream()拿到的流绑定在请求生命周期上请求一结束容器就把临时文件清掉、流关掉异步线程再访问自然失败。处理方式是把文件先落盘或者读成字节数组再交给异步线程MultipartFile file multipartFile; byte[] bytes file.getBytes(); // 请求线程内完成读取 executor.submit(() - { try (InputStream in new ByteArrayInputStream(bytes)) { EasyExcel.read(in, OrderImportVO.class, new PageReadListenerOrderImportVO(list - { orderService.batchSave(list); })).autoCloseStream(Boolean.FALSE).sheet().doRead(); } catch (IOException e) { log.error(解析上传文件失败, e); } });注意这里autoCloseStream(Boolean.FALSE)的用法因为in是try-with-resources管的再让 EasyExcel 去关就重复了。至于PageReadListener它内部按批回调默认每批 100 条适合边读边入库不会把整个 Excel 加载到内存里。6.2 版本选择上的一个现实问题EasyExcel 目前已经进入维护状态原作者在社区里另起了一个分支继续迭代也叫 FastExcelAPI 基本兼容包名不同。这不影响现有项目的使用但如果现在要新起一个项目值得把版本因素考虑进去。真要做迁移改动量主要在 import 和少量 API 签名上核心的autoCloseStream、ExcelWriter这套用法是一致的本文讲的所有内容都通用。另外提醒一句升级 EasyExcel 或 POI 版本时务必要回归测试导出功能。POI 不同大版本之间在流处理上偶有行为变化我遇到过升级之后原本正常的导出开始报关闭异常的情况回退版本就好了。这类问题很难提前预判只能靠回归测试兜住。按我这几年的经验Can not close IO这个异常九成以上不是 EasyExcel 的锅而是流的所有权没理清楚。养成一个习惯每写一个导出接口先在心里过一遍这个流是谁开的、谁该关、我有没有让它被关两次比事后翻日志省事得多。至于剩下的那一成客户端断连记个 warn 就行别跟它较劲。
返回列表