记临时文件夹被删导致 Excel 导出失败

参考:0004

故障现象与初步分析

系统运行一段时间之后,出现了 Excel 导出失败的错误:

nested exception is java.lang.IllegalStateException: java.nio.file.NoSuchFileException: /tmp/b35f0633-5463-464c-a3ee-c927b2ec275e/poifiles/poi-sxssf-sheet3680958631078595506.xml] with root cause
java.nio.file.NoSuchFileException: /tmp/b35f0633-5463-464c-a3ee-c927b2ec275e/poifiles/poi-sxssf-sheet3680958631078595506.xml
at sun.nio.fs.UnixException.translateToIOException(UnixException.java:86)
at sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:102)
at sun.nio.fs.UnixException.rethrowAsIOException(UnixException.java:107)
at sun.nio.fs.UnixFileSystemProvider.newByteChannel(UnixFileSystemProvider.java:214)
at java.nio.file.Files.newByteChannel(Files.java:361)
at java.nio.file.Files.createFile(Files.java:632)
at java.nio.file.TempFileHelper.create(TempFileHelper.java:138)
at java.nio.file.TempFileHelper.createTempFile(TempFileHelper.java:161)
at java.nio.file.Files.createTempFile(Files.java:852)
...

根据项目信息和日志等,基本可以确认是临时文件夹被删除导致报错:

  1. 服务使用的 Excel 导出组件为 EasyExcel,其底层实现是 Apache POI,使用了SXSSFWorkbook模型。
  2. 异常发生在尝试创建临时文件时,路径指向/tmp目录下的一个子目录,该子目录名称包含 UUID,其下还有poifiles子目录。
  3. 在服务器上检查该路径,发现除了/tmp根目录,其下的 UUID 和poifiles子目录均不存在。

根源分析

SXSSFWorkbook是 Apache POI 提供的一种低内存占用的 Excel 写入方式,它通过将大部分数据写入临时文件,只在内存中保留少量行来避免内存溢出。这些临时文件默认存储在 Java 系统属性java.io.tmpdir指定的目录下,通常是操作系统的/tmp目录。

POI 与 EasyExcel 的临时文件管理机制

  1. POI 的DefaultTempFileCreationStrategy:

    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(); // 创建 /tmp/poifiles 目录
                }
            } finally {
                dirLock.unlock();
            }
        }
    }
    
    @Override
    public File createTempFile(String prefix, String suffix) throws IOException {
        createPOIFilesDirectory(); // 确保 /tmp/poifiles 存在
        File newFile = Files.createTempFile(dir.toPath(), prefix, suffix).toFile(); // 在 /tmp/poifiles 下创建临时文件
        if (System.getProperty(DELETE_FILES_ON_EXIT) != null) {
            newFile.deleteOnExit();
        }
        return newFile;
    }
    

    这段代码表明,POI 会在java.io.tmpdir目录下创建一个名为poifiles的子目录,并在其中生成实际的临时 Excel 数据文件。一旦dir变量被初始化,POI 就不会再次尝试创建poifiles目录。

  2. EasyExcel 的优化:

    EasyExcel 为了防止多个应用共用一个临时文件夹可能存在的权限或冲突问题,在 POI 的基础上进一步优化,引入了 UUID 作为临时目录的前缀:

    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); // 这里的 poiFilesPath 包含了 UUID
        createDirectory(poiFilesPathFile); // 创建 /tmp/{UUID}/poifiles 目录
        TempFile.setTempFileCreationStrategy(new DefaultTempFileCreationStrategy(poiFilesPathFile));
    }
    

    这意味着 EasyExcel 会尝试在/tmp/{UUID}/poifiles这样的路径下创建临时文件。

操作系统对/tmp目录的清理机制

大多数 Linux 发行版(如 CentOS 7+)使用systemd-tmpfiles服务来管理和清理系统临时目录。通过查看/usr/lib/tmpfiles.d/tmp.conf文件,可以发现类似以下规则:

# 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

其中,q /tmp 1777 root root 10d表示/tmp目录下的文件和目录,如果超过 10 天没有被访问或修改,就会被清理。

故障根源

综合上述分析,故障的根本原因在于:

  1. Java 应用(通过 EasyExcel/POI)在/tmp目录下创建了{UUID}/poifiles这样的临时目录及其文件。
  2. 如果这些临时目录和文件在一段时间内(例如 10 天)没有被访问或修改,systemd-tmpfiles服务会自动将其清理删除。
  3. 然而,Java 应用中的 POI DefaultTempFileCreationStrategy可能仍然持有对已删除目录的引用(即dir变量已初始化),在后续的 Excel 导出操作中,它不会再次尝试创建这个目录。
  4. 当应用再次尝试在该已不存在的目录中创建新的临时文件时,就会抛出java.nio.file.NoSuchFileException

解决方案

针对该问题,推荐以下解决方法。

1. 更改java.io.tmpdir系统属性

将临时目录设置为一个不会被系统自动清理的自定义路径,例如应用程序专属的数据目录。操作如下:

1、创建专属目录并设置权限:

sudo mkdir /path/to/your/app_temp
sudo chmod 777 /path/to/your/app_temp # 确保应用有读写权限

2、在 Java 启动命令中添加参数:

java -Djava.io.tmpdir=/path/to/your/app_temp -jar your_app.jar

3、在程序初始化时设置(如果无法修改启动脚本):

// 在应用程序启动时,尽早设置此属性
System.setProperty("java.io.tmpdir", "/path/to/your/app_temp");

注意:System.setProperty必须在任何可能用到临时目录的代码(如SXSSFWorkbook初始化)之前执行。

2. 调整 POI 临时文件创建策略(推荐的开发侧方案)

利用 POI 提供的扩展点,注册自定义的TempFileCreationStrategy。每次使用临时目录前检查其是否存在,如果不存在则重新创建。这是对应用代码影响最小且最健壮的开发侧解决方案。

import org.apache.poi.util.DefaultTempFileCreationStrategy;
import org.apache.poi.util.TempFile;
import org.apache.poi.util.TempFileCreationStrategy;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.UUID;

public class CustomTempFileStrategy {

    // 自定义临时文件目录,避免使用系统/tmp
    private static final String CUSTOM_TEMP_BASE_DIR = "/path/to/your/app_temp"; 

    public static void initCustomTempFileStrategy() {
        // 确保自定义的基础临时目录存在
        File baseDir = new File(CUSTOM_TEMP_BASE_DIR);
        if (!baseDir.exists()) {
            baseDir.mkdirs();
        }

        TempFile.setTempFileCreationStrategy(new TempFileCreationStrategy() {
            private volatile File currentPoiTempDir; // 存储 EasyExcel 生成的 UUID 目录下的 poifiles 目录

            @Override
            public File createTempFile(String prefix, String suffix) throws IOException {
                ensurePoiTempDirExists();
                return Files.createTempFile(currentPoiTempDir.toPath(), prefix, suffix).toFile();
            }

            @Override
            public File createTempDirectory(String prefix) throws IOException {
                ensurePoiTempDirExists();
                return Files.createTempDirectory(currentPoiTempDir.toPath(), prefix).toFile();
            }

            private void ensurePoiTempDirExists() throws IOException {
                if (currentPoiTempDir == null || !currentPoiTempDir.exists()) {
                    synchronized (this) { // 确保线程安全
                        if (currentPoiTempDir == null || !currentPoiTempDir.exists()) {
                            // 重新生成 UUID 目录,并确保其存在
                            String uuidDirName = UUID.randomUUID().toString();
                            Path appSpecificTempPath = Paths.get(CUSTOM_TEMP_BASE_DIR, uuidDirName, DefaultTempFileCreationStrategy.POIFILES);
                            currentPoiTempDir = Files.createDirectories(appSpecificTempPath).toFile();
                        }
                    }
                }
            }
        });
        
        // 验证是否已切换
        System.out.println("POI Temporary directory strategy initialized. Base: " + CUSTOM_TEMP_BASE_DIR);
    }

    public static void main(String[] args) {
        // 在应用程序启动时调用此方法
        initCustomTempFileStrategy();

        // 示例:使用 SXSSFWorkbook 进行导出
        try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) {
            // ... 写入数据 ...
            workbook.createSheet("Test Sheet");
            System.out.println("Excel created successfully.");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

说明:

  • initCustomTempFileStrategy()方法应在应用程序启动时调用,确保在任何 POI 操作之前设置好策略。
  • 自定义策略会确保每次创建临时文件前,其父目录(包含 UUID 和poifiles)都存在。如果被系统清理,它会重新创建。
  • 同时,我们将基础临时目录从/tmp切换到了CUSTOM_TEMP_BASE_DIR,进一步避免系统清理。
posted @ 2025-08-31 10:46  Higurashi-kagome  阅读(4)  评论(0)    收藏  举报