記一次使用tika解析文件文本導致的內存溢出問題


背景

筆者曾供職於某信息安全公司,接到過一個需求,提取文檔中的文本以供后續分析。tika是apache開源的解析文檔內容的組件,應用十分廣泛。tika幾乎支持你能想到的所有文檔格式,docx , pptx , xlsx , pdf, zip , rar , tar 等。

tika本身只是一個門面,不提供文檔解析實現,這有點類似與sl4j。例如tika使用pdfbox解析pdf文件,使用poi解析 office文檔。然而文檔種類繁多,有壓縮的未壓縮的,有加密的有未加密的,有大文件有小文件。甚至還有一些惡意文件,例如 壓縮炸彈,xml炸彈。這些惡意文件可能會導致整個應用掛掉。

案發

這里先簡單介紹一下:使用tika解析文本之后將文本存入ElasticSearch數據庫中是一個非常經典的使用場景。一些圖書館,檔案館,將文檔內容提取出來存入ES做索引,這樣便可以通過內容檢索到對應的文檔了。

某天,外場報告說ElasticSearch客戶端報告了Request cannot be executed; I/O reactor status: STOPPED 錯誤,導致所有的ES插入報錯。

報錯堆棧如下:

Caused by: java.lang.IllegalStateException: Request cannot be executed; I/O reactor status: STOPPED
at org.apache.http.util.Asserts.check(Asserts.java:46)
at org.apache.http.impl.nio.client.CloseableHttpAsyncClientBase.ensureRunning(CloseableHttpAsyncClientBase.java:90)
at org.apache.http.impl.nio.client.InternalHttpAsyncClient.execute(InternalHttpAsyncClient.java:123)
at org.elasticsearch.client.RestClient.performRequestAsync(RestClient.java:529)
at org.elasticsearch.client.RestClient.performRequestAsyncNoCatch(RestClient.java:514)
at org.elasticsearch.client.RestClient.performRequest(RestClient.java:226)
at org.elasticsearch.client.RestHighLevelClient.performRequest(RestHighLevelClient.java:1256)
at org.elasticsearch.client.RestHighLevelClient.performRequestAndParseEntity(RestHighLevelClient.java:1231)
at org.elasticsearch.client.RestHighLevelClient.search(RestHighLevelClient.java:730)

看起來是不是挺無厘頭的?一般來說越是底層的報錯越難排查。在網上可以大量搜到ES這個 I/O reactor status: STOPPED錯誤,我嘗試很多方法,甚至對ES進行了長時間的壓力測試,沒有復現該問題。

就這樣過去了很長一段時間,我在一次偶然的壓力測試中發現,日志中赫然出現了OutOfMemoryError錯誤,並生成了堆轉儲文件。伴隨着這個內存溢出錯誤,I/O reactor status: STOPPED這個錯誤也復現了。這強烈提示OutOfMemoryError導致了ES客戶端的I/O reactor status: STOPPED錯誤!后續的多次測試也證明了這一點。

原因

后續就簡單了,使用Eclipse MAT工具分析堆轉儲文件,發現pdfbox相關的對象占用空間特別大。這個pdfbox就是tika解析pdf文件所引入的依賴。

tika使用了pdfbox解析了一個大概40M的pdf文件,直接吃掉了2.1G左右的堆內存!文件的大小和占用的堆內存並沒有直接的關聯。筆者在之后還遇到了只有40Kb左右但單壓縮比極高的docx文件(測試構造的極端文件)。這個40Kb左右的文件直接吃掉了1G以上的堆內存。

接下來就很清晰了,tika解析特定的文件占用大量內存,ES客戶端的底層I/O線程申請不到足夠的內存,拋出OutOfMemoryError錯誤,I/O線程意外退出,最終導致了 I/O reactor status: STOPPED

令很多人意外的是,出現內存溢出后,jvm不會退出,如果線程中沒有捕獲Error(區別於Exception),拋出錯誤的線程就會退出。如果是關鍵線程退出(例如定時任務),整個系統就會處於不穩定的狀態,帶來的問題是難以排查的。我這里的例子就是這樣,出現了內存溢出的錯誤,jvm還在,整個服務仿佛還是正常,但底層的I/O線程掛了,導致所有ES的操作全部失敗。當然,如果你的運氣比較好,掛掉的線程僅僅是普通的線程(例如線程池中的線程),那么線程池還會拉起一個線程,只是丟失了這個線程執行的任務而已。

解決

  • tika解析pdf文件可能會占用大量內存,並最終導致內存溢出,在這里我們可以限制pdf文件的內存占用

        public String parse() throws Exception {
    
          ContentHandler contentHandler = new BodyContentHandler();
    
          //設置pdf文件占用最大內存50M
          PDFParserConfig pdfParserConfig = new PDFParserConfig();
          pdfParserConfig.setMaxMainMemoryBytes(50 * 1024 * 1024L);
          Metadata metadata = new Metadata();
    
          //填入pdf參數
          ParseContext parseContext = new ParseContext();
          parseContext.set(PDFParserConfig.class, pdfParserConfig);
    
          Parser parser = new AutoDetectParser();
          try (InputStream in = new FileInputStream(file)) {
              parser.parse(in, contentHandler, metadata, parseContext);
              return contentHandler.toString();
          }
      }
    
    
  • jvm參數添加 -XX:+CrashOnOutOfMemoryError
    出現內存溢出后立刻退出,等待守護進程拉起(需要有守護進程),避免應用處於不穩定狀態

除了內存溢出外,tika社區還報告另外兩個問題,內存泄漏(memory leak)和 死循環(infinite loop)。上面我們勉強通過參數限制了pdf文件的內存占用,但是文件會有極端文件破壞我們服務的穩定。內存泄漏最總會導致內存溢出,添加虛擬機參數溢出后重啟即可解決,可死循環會導致線程永久卡死,直接導致服務不可用。

對於死循環問題,可以通過設定超時時間勉強解決,超時后,進程自動退出,等待守護進程拉起

終極解決方式

上述的解決方式本質上都是通過重啟服務的方式實現的。據tika社區介紹,新版本的tika已默認使用fork的方式解析文件。所謂的fork的方式,就是每處理一個文件就用一個單獨的進程(當然進程可以復用),這種進程隔離的方式將錯誤局限在了子進程中而父進程不受影響,實現了隔離和保護。詳見:https://dist.apache.org/repos/dist/release/tika/2.1.0/CHANGES-2.1.0.txt

所謂的終極解決方式就是實現進程隔離,將錯誤限制在子進程中。例如瀏覽器就是典型的多進程,每個頁面都是單獨的進程,避免個別頁面的崩潰帶崩整個應用。實現多進程的核心是進程間的通信,關於這一主題的討論將放在我的下一篇博客中。

總結

  • 使用tika提取文件內容這個行為充滿着不確定性,可能會導致內存溢出,內存泄漏,死循環等嚴重問題
  • 不要在你的應用中直接集成tika,應當將內容提取獨立成服務
  • 內存溢出進程不會退出,只是出現內存溢出錯誤的線程退出,這讓整個服務處於不穩定狀態


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM