從一次netty 記憶體洩露問題來看netty對POST請求的解析

趙孤鴻發表於2021-08-24

背景

最近生產環境一個基於 netty 的閘道器服務頻繁 full gc

觀察記憶體佔用,並把時間維度拉的比較長,可以看到可用記憶體有明顯的下降趨勢

出現這種情況,按往常的經驗,多半是記憶體洩露了

問題定位

找運維在生產環境 dump 了快照檔案,一分析,果然不出所料,在一個 LinkedHashSet 裡面, 放入 N 多的臨時檔案路徑

可以看到,該 LinkedHashSet 是被類 DeleteOnExitHook 所引用。

DeleteOnExitHook

DeleteOnExitHook 是 jdk 提供的一個刪除檔案的鉤子類,作用很簡單,在 jvm 退出時,通過該類裡面的鉤子刪除裡面所記錄的所有檔案

我們簡單的看下原始碼

class DeleteOnExitHook {
    private static LinkedHashSet<String> files = new LinkedHashSet<>();
    static {
        // 註冊鉤子, runHooks 方法在 jvm 退出的時候執行
        sun.misc.SharedSecrets.getJavaLangAccess()
            .registerShutdownHook(2 /* Shutdown hook invocation order */,
                true /* register even if shutdown in progress */,
                new Runnable() {
                    public void run() {
                       runHooks();
                    }
                }
        );
    }

    private DeleteOnExitHook() {}

    // 新增檔案全路徑到該類裡面的set裡
    static synchronized void add(String file) {
        if(files == null) {
            // DeleteOnExitHook is running. Too late to add a file
            throw new IllegalStateException("Shutdown in progress");
        }

        files.add(file);
    }

    static void runHooks() {
       // 省略程式碼。。。 該方法用做刪除 files 裡面記錄的所有檔案
    }
}

我們基本猜測出,在應用不斷的執行過程中,不斷有程式呼叫 DeleteOnExitHook.add方法,放入了大量臨時檔案路徑,導致了記憶體洩露

其實關於 DeleteOnExitHook 類的設計,不少人認為這個類設計不合理,並且反饋給官方,但官方覺得是合理的,不打算改這個問題

有興趣的可以看下 https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6664633

原因分析

既然已經定位到了出問題的地方,那麼到底是什麼情況下觸發了這個 bug 了呢?

因為我們的閘道器是基於 netty 實現的,很快定位到了該問題是由 netty 引起的,但要說清楚這個問題並不容易

HttpPostRequestDecoder

如果我們要用 netty 處理一個普通的 post 請求,一種典型的寫法是這樣,使用 netty 提供的解碼器解析 post 請求

// request 為 FullHttpRequest 物件
HttpPostRequestDecoder decoder = new HttpPostRequestDecoder(request);
try {
    for (InterfaceHttpData data : decoder.getBodyHttpDatas()) {
        // TODO 根據自己的需求處理 body 資料
    }
    return params;
} finally {
    decoder.destroy();
}

HttpPostRequestDecoder 其實是一個解碼器的代理物件, 在構造方法裡使用預設使用 DefaultHttpDataFactory 作為 HttpDataFactory

同時會判斷請求是否是 Multipart 請求,如果是,使用 HttpPostMultipartRequestDecoder,否則使用 HttpPostStandardRequestDecoder

public HttpPostRequestDecoder(HttpRequest request) {
        this(new DefaultHttpDataFactory(DefaultHttpDataFactory.MINSIZE), request, HttpConstants.DEFAULT_CHARSET);
}

public HttpPostRequestDecoder(HttpDataFactory factory, HttpRequest request, Charset charset) {
        // 省略引數校驗相關程式碼

        // Fill default values
        if (isMultipart(request)) {
            decoder = new HttpPostMultipartRequestDecoder(factory, request, charset);
        } else {
            decoder = new HttpPostStandardRequestDecoder(factory, request, charset);
        }
    }

DefaultHttpDataFactory

HttpDataFactory 作用很簡單,就是建立 httpData 例項,httpData 有多種實現,後續我們會講到

HttpDataFactory 有兩個關鍵引數

  • 引數 useDisk ,預設 false,如果設為 true,建立 httpData 優先使用磁碟儲存
  • 引數 checkSize,預設 true,使用混合儲存,混合儲存會通過校驗資料大小,重新選擇儲存方式

HttpDataFactory 裡方法雖然不少,其實都是相同邏輯的不同實現,我們選取一個來看下原始碼

@Override
public FileUpload createFileUpload(HttpRequest request, String name, String filename,
        String contentType, String contentTransferEncoding, Charset charset,
        long size) {
	// 如果設定了用磁碟,預設會用磁碟儲存的 httpData, userDisk 預設是 false
    if (useDisk) {
        FileUpload fileUpload = new DiskFileUpload(name, filename, contentType,
                contentTransferEncoding, charset, size);
        fileUpload.setMaxSize(maxSize);
        checkHttpDataSize(fileUpload);
        List<HttpData> fileToDelete = getList(request);
        fileToDelete.add(fileUpload);
        return fileUpload;
    }
	// checkSize 預設 true
    if (checkSize) {
		// 建立 MixedFileUpload 物件
        FileUpload fileUpload = new MixedFileUpload(name, filename, contentType,
                contentTransferEncoding, charset, size, minSize);
        fileUpload.setMaxSize(maxSize);
        checkHttpDataSize(fileUpload);
        List<HttpData> fileToDelete = getList(request);
        fileToDelete.add(fileUpload);
        return fileUpload;
    }
    MemoryFileUpload fileUpload = new MemoryFileUpload(name, filename, contentType,
            contentTransferEncoding, charset, size);
    fileUpload.setMaxSize(maxSize);
    checkHttpDataSize(fileUpload);
    return fileUpload;
}

httpData

httpData 可以理解為 netty 對 body 裡的資料做的一個抽象,並且抽象出了兩個維度

  • 從資料型別來看,可以分為普通屬性和檔案屬性
  • 從儲存方式來看,可以分為磁碟儲存,記憶體儲存,混合儲存
型別/儲存方式 磁碟儲存 記憶體儲存 混合儲存
普通屬性 DiskAttribute MemoryAttribute MixedAttribute
檔案屬性 DiskFileUpload MemoryFileUpload MixedFileUpload

可以看到,根據資料屬性不同和儲存方式不同一共有六種方式
但需要注意的是,磁碟儲存和記憶體儲存才是真正的儲存方式,混合儲存只是對前兩者的代理

  • MixedAttribute 會根據設定的資料大小限制,決定自己真正使用  DiskAttribute 還是 MemoryAttribute
  • MixedFileUpload 會根據設定的資料大小限制,決定自己真正使用 DiskFileUpload  還是 MemoryFileUpload

我們來看下 MixedFileUpload 物件構造方法

public MixedFileUpload(String name, String filename, String contentType,
        String contentTransferEncoding, Charset charset, long size,
        long limitSize) {
    this.limitSize = limitSize;
	// 如果大於 16kb(預設),用磁碟儲存,否則用記憶體
    if (size > this.limitSize) {
        fileUpload = new DiskFileUpload(name, filename, contentType,
                contentTransferEncoding, charset, size);
    } else {
        fileUpload = new MemoryFileUpload(name, filename, contentType,
                contentTransferEncoding, charset, size);
    }
    definedSize = size;
}

後續在往 MixedFileUpload 新增內容時,會判斷內容如果大於 16kb,仍舊用磁碟儲存

@Override
public void addContent(ByteBuf buffer, boolean last)
        throws IOException {
	// 如果現在是用記憶體儲存
    if (fileUpload instanceof MemoryFileUpload) {
        checkSize(fileUpload.length() + buffer.readableBytes());
		// 判斷內容如果大於16kb(預設),換成磁碟儲存
        if (fileUpload.length() + buffer.readableBytes() > limitSize) {
            DiskFileUpload diskFileUpload = new DiskFileUpload(fileUpload
                    .getName(), fileUpload.getFilename(), fileUpload
                    .getContentType(), fileUpload
                    .getContentTransferEncoding(), fileUpload.getCharset(),
                    definedSize);
            diskFileUpload.setMaxSize(maxSize);
            ByteBuf data = fileUpload.getByteBuf();
            if (data != null && data.isReadable()) {
                diskFileUpload.addContent(data.retain(), false);
            }
            // release old upload
            fileUpload.release();
            fileUpload = diskFileUpload;
        }
    }
    fileUpload.addContent(buffer, last);
}

如果上面的解釋還沒有讓你理解 httpData 的設計,我相信看完下面這張類圖你一定會明白

httpData 磁碟儲存的問題

我們通過上面的分析可以看到,使用磁碟儲存的 httpData 實現一共有兩個,分別是 DiskAttribute 和 DiskFileUpload

從上面的類圖可以看到,這兩個類都繼承於抽象類 AbstractDiskHttpData,使用磁碟儲存會建立臨時檔案,如果使用磁碟儲存,在新增內容時會呼叫   tempFile 方法建立臨時檔案

private File tempFile() throws IOException {
    String newpostfix;
    String diskFilename = getDiskFilename();
    if (diskFilename != null) {
        newpostfix = '_' + diskFilename;
    } else {
        newpostfix = getPostfix();
    }
    File tmpFile;
    if (getBaseDirectory() == null) {
        // create a temporary file
        tmpFile = File.createTempFile(getPrefix(), newpostfix);
    } else {
        tmpFile = File.createTempFile(getPrefix(), newpostfix, new File(
                getBaseDirectory()));
    }
	// deleteOnExit 方法預設返回 ture,這個引數可配置,也就是這個引數導致了記憶體洩露
    if (deleteOnExit()) {
        tmpFile.deleteOnExit();
    }
    return tmpFile;
}

這裡可以看到如果 deleteOnExit 方法預設返回 ture,就會執行 deleteOnExit 方法,就是這個方法導致了記憶體洩露

我們看下 deleteOnExit 原始碼,該方法會把檔案路徑新增到 DeleteOnExitHook 類中,等 java 虛擬機器停止時刪除檔案

至於 DeleteOnExitHook 為什麼會導致記憶體洩露,文章開始的時候已經解釋,這裡不再贅述

// 在java 虛擬機器停止時刪除檔案
public void deleteOnExit() {
    SecurityManager security = System.getSecurityManager();
    if (security != null) {
        security.checkDelete(path);
    }
    if (isInvalid()) {
        return;
    }
	// 檔案路徑會一直儲存到一個linkedHashSet裡面
    DeleteOnExitHook.add(path);
}

到這裡,我相信你也一定明白問題所在了

在請求內容大於 16kb(預設值,可設定)的時候,netty 會使用磁碟儲存請求內容,同時在預設情況下,會呼叫 file 的   deleteOnExit 方法,導致檔案路徑不斷的被儲存到 DeleteOnExitHook ,不能被 jvm 回收,造成記憶體洩露

解決方案

DiskAttribute 中 deleteOnExit 方法 返回的是靜態變數 DiskAttribute.deleteOnExitTemporaryFile 的值,預設 true

DiskFileUpload 中 deleteOnExit 方法 返回的是靜態變數 DiskFileUpload.deleteOnExitTemporaryFile 的值,預設 true

只需把這兩個靜態變數設為 false 即可

static {
  DiskFileUpload.deleteOnExitTemporaryFile = false;
  DiskAttribute.deleteOnExitTemporaryFile = false;
}

至於臨時檔案的刪除我們也不用擔心,HttpPostRequestDecoder 最後呼叫了 destroy 方法,就能保證後續的臨時檔案刪除和資源回收,因此,上述預設情況下沒必要通過 deleteOnExit 方法在 jvm 關閉時再清理資源

HttpPostRequestDecoder 解析資料的時序圖如下

官方修復

上面的解決方案其實只是避開問題,並沒有真正的解決這個 bug

我看了下官方的 issues,該問題已經被多次反饋,最終在 4.1.53.Final 版本里修復,修復邏輯也很簡單,重寫 DeleteOnExitHook 類為 DeleteFileOnExitHook ,並提供 remove 方法

在 AbstractDiskHttpData 類的刪除檔案時,同時刪除 DeleteFileOnExitHook 類中儲存的路徑

有興趣的可以看下官方的 issuerspr瞭解更多資訊

相關文章