別再使用 POI 框架啦!(記一次生產應用頻繁出現 OOM 事故的排查過程)

轉載請註明原創出處,謝謝!

HappyFeet的博客

真的不要再用 POI 了!!!


1、簡要回顧

前段時間生產環境上的應用頻繁 Crash。經過一番努力排查,我發現在使用 POI 框架讀取 Excel 數據的時候產生了大量的 POI 對象,導致應用內存佔用急劇增加,並且這些 POI 對象在處理完 Excel 之後也沒有被正確回收,導致內存泄漏,最終應用因爲 OOM 而 Crash 掉。

生產環境配置:

  • 應用服務器內存大小:8G
  • 使用 Docker 鏡像運行的 Java 應用程序,Java 堆配置:-Xms2G -Xmx6G

解決方案:使用阿里開源的 easyexcel 框架將 POI 替換掉。

2、事情始末

(1)第一次交鋒

在一個週五的下午,用戶反饋說系統貌似掛了。。。

沒錯!就是週五的下午,就在我準備下班開始過快樂週末的時候,此時的心情:

哭

通過跳板機登錄到應用服務器上確認應用狀態:發現確實掛掉了。

將應用日誌拎出來一看,發現在應用 Crash 的時候只有幾個連接數據庫的 WARN,並沒有拋異常:

系統 Crash - 1

繼續往上找,發現有一個 ERROR:

ERROR | 2019-11-29 14:26:18:152 | [XNIO-3 task-27] api.LoggingExceptionHandler (LoggingExceptionHandler.java:80) - UT005023: Exception handling request to /someUrl
java.lang.IllegalStateException: io.undertow.server.RequestTooBigException: UT000020: Connection terminated as request was larger than 20971520

這是 servlet 在處理 HTTP 請求時報的錯誤,因爲我們限制了上傳文件的大小爲 20M,所以這個地方是一個正常報錯。

接着往上找,發現也有幾個和上面日誌一樣的錯誤,所以這個地方應該是用戶嘗試上傳了好幾次,並且都上傳失敗了,所以也不是這個地方的問題。後面也沒發現什麼明顯的 ERROR 或異常。同時我們運維人員發現在應用服務器上面的執行命令中存在 kill -i 的記錄。

針對這樣的情況,初步做了推測:

  1. 有可能是人爲因素將應用 kill 掉了;
  2. 也有可能是內存溢出導致應用 Crash。

這裏解釋一下做出這樣推斷的原因:

  • 應用日誌突然沒了,也沒有任何報錯,應用程序就掛掉了。我在本地進行了測試,在本地啓動應用的過程中將對應的進程 kill 掉,其日誌表現和應用日誌極爲相似;
  • 猜測應用突然 Crash 有可能是 OOM,因爲之前也出現過 OOM,但是具體報了 OutOfMemoryError 的錯誤。其實當時我比較困惑,一般 OOM 會有異常信息,例如像 java.lang.OutOfMemoryError: Java heap space 這種,而像什麼都不報直接 Crash 還是比較少見,所以這也僅是我的一個猜測。因爲也沒有 GC 日誌可看,這其實是我們本身配置上的一個失誤:我們是用 Docker 起的應用,在啓動的時候確實配了 GC 日誌,問題在於沒有把這個 GC 日誌目錄映射出來,所以在應用重啓的時候 GC 日誌也就沒了。。。

根據上面初步推測的結果,做了兩件事情:

  1. 確認應用是否真的是人爲 kill 掉的;(後面事實證明應用並不是被人爲 kill 的)
  2. 完善 GC 日誌配置,並且多加了一些 JVM 的配置,使其能在 OutOfMemoryError 時把堆的內存快照 dump 下來。

添加的 JVM 參數如下:

-XX:+UseG1GC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintGCCause -Xloggc:/var/log/gc_%p_%t.log -XX:+UseGCLogFileRotation -XX:GCLogFileSize=2M -XX:ErrorFile=/tmp/jvm/hs_err_pid_%p.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumpFilePath
(2)再次相遇

又一個週五的下午!用戶來反饋說系統又掛了。。。

沒錯!你絕對沒聽錯!又是週五。。。

哭-2

雖然挺委屈的,但事還是得做的嘛。而且心裏想着上次加的 JVM 的配置,這次怎麼着也得把這個問題找到!

然而結果挺讓人意外的,應用日誌表現和上次一模一樣!一模一樣!而且,新加的 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumpFilePath 這個參數好像也沒起作用,並沒有在對應的目錄找到堆的內存快照!(這裏可以思考一下爲什麼會沒有內存快照)

服務 Crash-2

不過好在這次是有 GC 日誌的。

GC 日誌 - 1

從這個 GC 日誌來看,應該是有問題的,因爲我們應用的堆大小配置爲:-Xms2G -Xmx6G

將 GC 日誌檢查了一遍,如圖:

頻繁 GC

很顯然,這個 GC 有問題!不到 6s 鍾,堆內存從 2376.0M 擴大到了 5358.0M,後續也沒有降下來,直到程序 Crash。

所以猜測可能存在內存泄漏問題。

於是就讓運維同事寫了一個腳本,定時監控應用的內存使用情況

同時我也開始檢查在 GC 時間點的用戶行爲。根據每一次 GC 的時間,去應用日誌裏面找對應時間點的日誌,看看這些時間點用戶都做了些什麼操作。

經過檢查發現在每次 GC 附近用戶都有上傳 Excel,應用把 Excel 數據保存在服務器之後,會去讀取 Excel 的數據。於是我就思考,會不會是這些 Excel 搞的鬼?

有了這個想法之後,立馬讓運維同事將其中的一個 Excel 下載下來,這個 Excel 大小爲 17M 多。

拿到 Excel 之後,我在本地寫了一個測試循環多次去讀取這個 Excel 的數據,同時使用 JProfile 查看其內存使用情況,結果如下:

GC 詳情 - 1

僅讀取一個 17M 的 Excel 內存直接就花去了將近 4 個 G! 由於測試配置最大堆內存爲 4 G,所以從上圖也可以看出在不斷的 GC。這是我起在本地的一個測試,除了讀取 Excel 之外其他什麼都沒做。

這裏對這個 Excel 的數據做一個說明:

  • 大小:17M 多一點;
  • 總共兩個 Sheet;第一個 Sheet 大概 500 多行數據,列數大概在 30-40 之間;第二個 Sheet 的數據量和第一個 Sheet 差不多,但是裏面存在公式;

其實代碼裏面只用到了第一個 Sheet 的數據。於是就想着把這些無用的數據刪了試一試,然後現象比較詭異:

  • 刪除第二個 Sheet,Excel 大小變成了 11M 多,大概減小了 5M 多;(讀這個刪減版的 Excel 的內存比原來要小一點點,但是並沒有減小太多)
  • 接着把第一個 Sheet 的數據做了刪減,發現將數據刪減到只剩 2-3 行,Excel 的大小並沒有變化,太奇怪了!

好吧,有點扯遠了!迴歸正題。

前面講到了猜測可能存在內存泄漏問題,並且定時監控應用的內存使用情況。這裏我們採用的是比較簡單的方式:定時腳本,每半小時執行 jmap -histo pid,並將結果輸出到指定文件中。在檢查文件的時候,發現了 POI 存在內存泄漏:

內存泄漏 - 1

內存泄漏 - 2

如圖,第一張圖是 13:30:26 時刻應用的內存使用情況,第二張圖是 14:30:26 時刻應用的內存使用情況。同時,檢查了應用的日誌,發現用戶在 13:03:22 上傳了一個 Excel,在處理完 Excel 的數據之後,按理來說這些 POI 對象應該會被 GC 回收的,然而事實是這些 POI 對象一直到 14:30:26 都沒有被回收,然後 14:42:08 的時候服務 Crash。

這就是導致應用頻繁 Crash 的罪魁禍首。然後查了一下,結果一大堆:

POI 內存泄漏?

只是沒明白的是:使用 POI 框架很容易內存溢出,爲什麼還會有這麼多的人在用它?

apache poi

(3)終於解決

找到問題根源所在之後就好解決了,搜了一下讀取 Excel 的工具,最終我們決定換成阿里開源的 easyexcel 框架:

alibaba easyexcel

換成了 easyexcel 框架之後,使用 JProfile 測試了一下內存使用情況,同樣處理上面提到的 17M 的 Excel ,結果如圖:

easyexcel 內存使用情況

很明顯,看上去比 POI 要好太多了,GC 頻次明顯降低,並且內存佔用也大大降低。

最終,問題解決了。

開心的像個三百斤的胖子

3、總結原因

文中還遺留了一些問題:

  • 爲什麼配置了 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumpFilePath 卻沒有 dump 內存快照?
  • 爲什麼程序 Crash 卻沒有任何異常日誌?

這裏可以從應用服務器的內存使用情況的方向進行思考,本文不再深入探討。

下面來總結一下從這次經歷中吸取到的教訓:

  1. 啓動 Java 應用程序應該記錄 GC 日誌,並將其輸出到指定目錄;如果使用 Docker 執行,記得將日誌目錄映射到宿主機;例如:

    -XX:+UseG1GC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintGCCause -Xloggc:/var/log/gc_%p_%t.log -XX:+UseGCLogFileRotation -XX:GCLogFileSize=2M -XX:ErrorFile=/tmp/jvm/hs_err_pid_%p.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumpFilePath
    
  2. 應在代碼中的關鍵步驟打一些日誌,便於線上問題的排查;

  3. 不要以爲大家都在使用的開源框架就是完美的,它們也有可能存在 BUG;

  4. 書到用時方恨少,事非經過不知難;之前在 18 年年中的時候啃過《深入理解 Java 虛擬機》這本書,在解決這次 OOM 的問題時,還是有很多地方不太熟悉,尚需要翻看書籍做一些參考。

最後,寫給自己的話,也送給大家:每天再忙也應該給自己留點成長的時間!

全文完,希望能對大家有所幫助!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章