记一次使用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