記一次奇怪的檔案控制程式碼洩露問題

劍舞星魂發表於2023-12-03

記錄並分享一下最近工作中遇到的 Too many open files 異常的解決過程。

問題背景

產品有個上傳壓縮包並匯入配置資訊到資料庫中的功能,主要流程如下:

  1. 使用者上傳壓縮包;
  2. 後端解壓存放在臨時目錄,並返回列表給使用者;
  3. 使用者選擇需要匯入哪些資訊;
  4. 後端按需插入資料庫中,完成後刪除臨時目錄。

這個功能上線兩三年了,一直沒出現問題,最近測試在功能迴歸時,匯入的時候出現Too many open files異常。

但是透過lsof -p pid | wc -l檢視當前程式開啟的控制程式碼數時,又是正常的。

Too many open files是Linux系統中常見的錯誤,字面意思就是說開啟了太多的檔案,超過了系統的限制。

這裡的檔案(file)更準確的意思是檔案控制程式碼,或者是檔案描述符。可以說,Linux系統裡的一切都是檔案,包括網路連線、埠等等。

lsof -p pid命令可以檢視指定程式當前開啟的檔案資訊。wc -l命令指按行統計。

問題分析

當時的第一反應是該系統的檔案控制程式碼數配置的太低了,因為在其他環境都是正常的。

透過ulimit -a命令,可以檢視當前系統對各種資源的限制。

[uyong@linuxtest ~]# ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 31767
max locked memory       (kbytes, -l) 64
max memory size         (kbytes, -m) unlimited
open files                      (-n) 4096
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 31767
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

open files 那行就是表示開啟的檔案數限制,即一個程式最多可以同時開啟的檔案數。

也可以透過ulimit -n直接檢視最大開啟檔案數量。

當時查出來的配置是4096,檢視其他沒問題的環境上的配置,數量都是遠遠大於這個數。而且系統重新啟動後,沒做任何操作時,透過lsof -p pid | wc -l檢視檔案佔用,只有100多個。在好的環境上執行匯入成功後,再次檢視,檔案佔用數不變。在有問題的環境上匯入失敗後,檢視檔案佔用數也是不變。

雖然當時的壓縮包檔案很大,但4096按說也夠的。有點奇怪,為了不影響測試進度,只能先臨時修改系統配置,增大檔案數限制,ulimit -n 65535命令執行後,再次匯入沒問題了。

命令ulimit -n 65536只能臨時臨時調整檔案數量,系統重啟後配置就會失效。

如果要永久生效,需要在配置檔案/etc/security/limits.conf裡增加如下兩行:

* soft nofile 65536
* hard nofile 65535

問題到此就結束了嗎,NO😒,治標不治本,而且測試還順手提了個遺留缺陷🐛

問題解決

出現這個問題,不用懷疑,肯定就是開啟的檔案太多了,又沒有及時釋放,檔案越多,佔用的控制程式碼就越多。

擼了一遍整個流程的程式碼,有兩個介面:

一個介面用於上傳壓縮包,解壓後,返回資源列表,偽碼如下:

public List<Item> upload(MultipartFile zipFile) {
    try {
        // 解壓壓縮包到臨時目錄
        unzip(zipFile, tmpDir);
        // 蒐集所有的資源返回給前端
        return collectItems(tmpDir);
    } catch (Exception e) {
        // 只要發生了異常,就把臨時目錄清理了
        clear(tmpDir);
    }
}

一個介面用於處理使用者選擇的資源並匯入到資料庫,偽碼如下:

public void importDb(List<Item> selected) {
    try {
        // 讀取檔案並插入資料庫
        readFilesAndImportDb(tmpDir, selected);
    } finally {
        // 處理完成後,不管失敗與否,都把臨時目錄清理了
        clear(tmpDir);
    }
}

不管哪個介面,都在最後清理了資源,也就解釋了不管匯入成功還是失敗,檢視檔案佔用情況都是正常的。

逐個方法排查後,最終確定是unzip方法有問題,這裡貼一下解壓程式碼,看看你有沒有發現問題所在👀

import java.io.*;
import java.util.*;
import org.apache.commons.io.IOUtils;

/**
* @param in zip輸入流
* @param outDir 解壓後檔案存在目錄
*/
public void unzip(InputStream in, String outDir) throw IOException {
    try (ZipInputStream zipIn = new ZipInputStream(new BufferedInputStream(in))) {
        ZipEntry zipEntry;
        while((zipEntry = zipIn.getNextEntry()) != null) {
            File outItemFile = new File(outDir, zipEntry.getName);
            if (zipEntry.isDirectory()) {
                outItemFile.mkdirs();
            } else {
                outItemFile.getParentFile().mkdirs();
                outItemFile.createNewFile();
                IOUtils.copy(zipIn, new FileOutputStream(outItemFile));
            }
        }
    }
}

上述這段程式碼就會導致,壓縮包裡的檔案越多,所佔用的檔案控制程式碼就越多。

最終,只要再加一行程式碼就解決問題了:

try (FileOutputStream fos = new FileOutputStream(outItemFile)) {
    IOUtils.copy(zipIn, fos);
}

根本原因就是apache的IOUtils.copy方法並不會主動關閉輸出流。

總結

  1. 常見的工具方法,能用現成的就用現成的,輪子可以自己慢慢刨析,私下裡學習研究重造;
  2. 要了解所使用的第三方工具方法,它們會不會影響入參的狀態,比如這裡的copy方法,會不會主動關閉輸入輸出流。

參考資料

  1. Linux下Too many open files問題排查與解決
  2. ulimit命令詳解:如何設定和檢視系統資源限制