背景
筆者曾供職於某信息安全公司,接到過一個需求,提取文檔中的文本以供后續分析。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,應當將內容提取獨立成服務
- 內存溢出進程不會退出,只是出現內存溢出錯誤的線程退出,這讓整個服務處於不穩定狀態