凌晨两点被一个导出接口的告警叫醒,日志里只有一行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/File | true(默认) | EasyExcel | 流是你自己 new 的,没人兜底,让框架关最省心 |
response.getOutputStream() | false | Servlet 容器 | 流的归属权在容器,越权关闭是麻烦的源头 |
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 pipe,EasyExcel 收尾时把它包装成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、多个 sheet:
ExcelWriter 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 R<Void> 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,你拿到日志的第一眼就能知道这次导出到底在往哪里写、写了多少、卡在哪一环,不用再去翻代码猜上下文。这行日志的成本几乎为零,但省下来的排查时间,我已经不知道折算成多少个能准时下班的晚上了。