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

背景

最近生產環境一個基於 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 類的設計,不少人認為這個類設計不合理,並且回饋給官方,但官方覺得是合理的,不打算改這個問題

有興趣的可以看下 //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了解更多資訊