入职多年,面对生产环境,尽管都是小心翼翼,慎之又慎,还是难免捅出篓子。轻则满头大汗,面红耳赤。重则系统停摆,损失资金。每一个生产事故的背后,都是宝贵的经验和教训,都是项目成员的血泪史。为了更好地防范和遏制今后的各类事故,特开此专题,长期更新和记录大大小小的各类事故。有些是亲身经历,有些是经人耳传口授,但无一例外都是真实案例。
注意:为了避免不必要的麻烦和商密问题,文中提到的特定名称都将是化名、代称。
2024年5月10日下午临近下班时分,同事Y正准备收拾东西下班回家。正所谓“麻绳总挑细处断,厄运专找苦命人”,说时迟那时巧,突然一旁的座机在不恰当的时间恰当地响起,同事Y极不情愿地拿起听筒,电话那头是项目经理的声音,音量MAX,听起来情绪不算稳定,看戏的群人心想:多半是出事了。原来是业务人员收到客户投诉,某个导出数据的功能无法使用,由于时间紧任务重,必须尽快(加班加点)修复。
首先按照标准流程,先让运维拷贝和固定了事故系统日志及生产版本应用包。发现该功能已经在两个基线版本没有变更更过了,这么说来,不是变更升级引起的问题了。屋漏偏逢连夜雨,麻烦总比办法多。看来今晚不只是同事Y不能准时下班了……
故障的现象很简单,就是前端点击页面导出按钮之后,后端查询数据并生成Excel
文件时报错,据客户说分时段尝试了几次,都不成功。我们在日志上也确实看到了几个相关的异常日志,与客户描述的时点基本一致。让人费解的是异常信息全是类似这样的:
Exception in thread "Thread-1" java.lang.IllegalStateException: java.nio.file.NoSuchFileException: /tmp/42376e0c-b088-4816-9d9d-8fac49614625/poifiles/poi-sxssf-sheet8012028719757675239.xml
at org.apache.poi.xssf.streaming.SXSSFWorkbook.createAndRegisterSXSSFSheet(SXSSFWorkbook.java:696)
at org.apache.poi.xssf.streaming.SXSSFWorkbook.createSheet(SXSSFWorkbook.java:712)
at org.apache.poi.xssf.streaming.SXSSFWorkbook.createSheet(SXSSFWorkbook.java:104)
at com.alibaba.excel.util.WorkBookUtil.createSheet(WorkBookUtil.java:86)
at com.alibaba.excel.context.WriteContextImpl.createSheet(WriteContextImpl.java:223)
at com.alibaba.excel.context.WriteContextImpl.initSheet(WriteContextImpl.java:182)
at com.alibaba.excel.context.WriteContextImpl.currentSheet(WriteContextImpl.java:135)
at com.alibaba.excel.write.ExcelBuilderImpl.addContent(ExcelBuilderImpl.java:54)
at com.alibaba.excel.ExcelWriter.write(ExcelWriter.java:73)
at com.alibaba.excel.ExcelWriter.write(ExcelWriter.java:50)
at com.alibaba.excel.write.builder.ExcelWriterSheetBuilder.doWrite(ExcelWriterSheetBuilder.java:62)
(这里省略10086行堆栈信息)
分析该日志,可以知道该服务使用的Excel
导出组件为EasyExcel
,其底层实现是POI 4
以上(使用了SXSSFWorkbook
模型),目录名称使用了UUID
,文件名则存在类随机数的组成。
看到NoSuchFileException
或者FileNotFoundException
这两兄弟就知道应该是引用了不存在的文件路径。服务器上尝试ls
查看该目录,发现除了/tmp/
根目录,另外两个子目录居然都不存在,难道是哪个可爱的同事忘了创建临时目录或者不小心删除了临时目录。经过观察,所有的报错都指向相同的目录前缀/tmp/42376e0c-b088-4816-9d9d-8fac49614625/poifiles/
, 本着先恢复再查错的原则,决定先手工创建临时文件目录尝试解决该故障,重建目录后,客户反馈数据导出正常。
前面怀疑是同事Y在开发相关模块时,忘记创建临时目录或者使用完临时目录后误删除了。然而事实并非如此,项目中使用组件准确版本号为EasyExcel 3.3.4
,POI 5.2.5
,查看对应的代码,发现临时目录是由组件自动维护的,根本不需要开发人员进行维护(这里是个坑,为后面埋下了伏笔)。其中有两个地方的代码很关键,一段位于POI
的DefaultTempFileCreationStrategy
中:
public static final String POIFILES = "poifiles";
private volatile File dir;
private void createPOIFilesDirectory() throws IOException {
if (dir == null) {
dirLock.lock();
try {
if (dir == null) {
String tmpDir = System.getProperty(JAVA_IO_TMPDIR);
if (tmpDir == null) {
throw new IOException("System's temporary directory not defined - set the -D" + JAVA_IO_TMPDIR + " jvm property!");
}
Path dirPath = Paths.get(tmpDir, POIFILES);
dir = Files.createDirectories(dirPath).toFile();
}
} finally {
dirLock.unlock();
}
}
}
@Override
public File createTempFile(String prefix, String suffix) throws IOException {
createPOIFilesDirectory();
File newFile = Files.createTempFile(dir.toPath(), prefix, suffix).toFile();
if (System.getProperty(DELETE_FILES_ON_EXIT) != null) {
newFile.deleteOnExit();
}
return newFile;
}
可以看到POI
的管理策略为创建临时文件时会先去检查一遍临时目录的创建情况,没有创建则自动创建,并且不会删除这个目录。看到这里可能有人会有疑问,前面提到的带UUID
的目录是哪里来的,没错,UUID
是EasyExcel
干的,目的是为了防止一台机器运行多个应用时,共用一个临时文件夹可能存在读写权限的问题(大部分时候创建目录默认权限是0744
或0755
,除非你用root
用户启动应用服务……)。在EasyExcel
的FileUtils
中实现了该优化:
private static String tempFilePrefix =
System.getProperty(TempFile.JAVA_IO_TMPDIR) + File.separator + UUID.randomUUID().toString() + File.separator;
public static void createPoiFilesDirectory() {
File poiFilesPathFile = new File(poiFilesPath);
createDirectory(poiFilesPathFile);
TempFile.setTempFileCreationStrategy(new DefaultTempFileCreationStrategy(poiFilesPathFile));
}
排除了开发和组件的问题,接下来就不得不怀疑是不是运维人员背刺了。由于临时文件位于系统临时目录,如果目录长时间没有读写,可能会被tmpfiles.d
服务清理,事故服务器上执行cat /usr/lib/tmpfiles.d/tmp.conf
查看:
# This file is part of systemd.
#
# systemd is free software; you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 2.1 of the License, or
# (at your option) any later version.
# See tmpfiles.d(5) for details
# Clear tmp directories separately, to make them easier to override
q /tmp 1777 root root 10d
q /var/tmp 1777 root root 30d
这下问题就定位清楚了,长时间没有读写的临时目录被系统清理了,但是POI
的DefaultTempFileCreationStrategy
还保持了对该目录的引用,以至于后续不会再自动创建临时目录,等再次使用时就会触发FileNotFoundException
或者NoSuchFileException
,定位了问题,解决方案也简单:
java.io.tmpdir
属性来修改临时目录路径,影响的代码范围太大,不推荐。tmpfiles.d
的清理规则。通过修改tmpfiles.d
的清理周期或添加白名单,影响的机器数量太多,不推荐。TempFileCreationStrategy
。利用POI
提供的扩展点,注册自定义的Strategy
,每次使用临时目录前检查是否存在,影响最小,果然还得是开发,命够苦。扩展点位于POI
的TempFile
工具类中:
public final class TempFile {
private static TempFileCreationStrategy strategy = new DefaultTempFileCreationStrategy();
public static void setTempFileCreationStrategy(TempFileCreationStrategy strategy) {
if (strategy == null) {
throw new IllegalArgumentException("strategy == null");
}
TempFile.strategy = strategy;
}
// 这里省略部分代码
}
通过调用TempFile.setTempFileCreationStrategy
,即可替换为自定义的临时文件创建策略。
如果开发和运维沟通到位,或者运维手册严格按照规范来书写,这本是一次可以避免的生产事故。在双方无法直接沟通或者只能有限沟通的情况下,或许可以进一步强化配置测试和引入待机测试?来发现此类问题,减少此类事故。
事故影响较小,同事Y和相关人员被迫延迟两小时下班。第二天被老板知道了,差点把运维和开发都开除,项目负责人和运维负责人连夜编写事故报告一份。