问题背景


发生问题前,线上系统共 128G 内存,分配 IoTDB 80G 堆内存,堆外内存无限制。

由于除了机器除了运行 IoTDB 进程外,还要运行其他的程序,而此时 IoTDB 对系统内存的占用达到 98%,因此对 IoTDB 的内存进行限制,更新为 60G 堆内存,20G 堆外内存

配置更新后,出现查询卡住的现象,使用 show query processlist 查看,显示有查询一直卡在内存里。



排查过程


首先,从 jstack 入手,查看查询卡在什么地方,为什么不继续进行。



查看 jstack 文件后发现,查询进程卡在从阻塞队列里拿数据,而此时负责拿数据的子线程却已经回到线程池内,等待下一个 task 的到来。于是问题排查的方向转为:为什么子线程退出了,却没有往队列放SignalBatchData或者ExecptionBatchData。


接下来检查系统的 error 日志,当时说是没有任何报错,问题变得扑朔迷离起来...但是经过几轮重启复现,merge 报了 OutOfMemoryError: Direct buffer memory



但是大家普遍认为 merge 只是压死骆驼的最后一根稻草,引发堆外内存 OOM 的原因还应该是在查询。这个时候通过检查代码发现:作为子线程出现错误的最后一道防线,只对 Exception 进行了 catch,而 OOM 属于 Error,即 JVM 认为程序没有能力对这类错误进行捕获和处理。这个时候,查询子线程可能报了堆外内存的 OOM,而因为没能被捕获,因此没有记录下任何的堆栈和 Log。而主线程由于没能收到子线程的 ExceptionBatchData,因此一直在等待。



这样,我们找到了查询卡住的直接诱因。但是 OOM 的根本原因依然没能得到解决。我们重启系统,通过插件进一步监测堆外内存的占用,并关闭 Merge 来排除干扰项,现象仍然是堆外内存不断上涨,即使通过手动执行 Full GC 也降不下去。


 


于是我们开始分析查询对堆外内存的使用,最直接的想到我们的 TsFile 在读取时使用了 FileChannel 类,FileChannel 在读取时会申请临时的堆外内存,但是我们当时对于 FileChannel 申请堆外内存的原理,包括 buffer 大小和生命周期都不熟悉,因此只能看 JVM 源码,找资料,做实验。

有正确的方向总是好事,通过努力我们发现: FileChannel 会将读取时申请的堆外内存存在一个 buffer 池里,这个 buffer 池的大小是 1024(根据机器可能有所不同),每个 buffer 的大小由一个参数控制,而每个 buffer 池又是 ThreadLocal 的,只要线程仍然存活,这个 buffer 池内的堆外内存缓存就不会释放。也就是说,我们给查询开了 CPU 核数的线程池,这个线程池的查询线程会一直存活,且每个线程读取 tsfile 文件时都会开一个 fileChannel,因此堆外内存的总占用为:

查询线程数 *   buffer 池大小(1024) * 单个 buffer大小。


以下为 JVM 源码

FileChannel 会在 Read 方法中调用 Util 中的 Read 方法,其中涉及的成员变量包括:

TEMP_BUF_POOL_SIZE: 临时堆外内存 buffer 池的大小,默认值为 IOV_MAX, 该值与操作系统有关,在 Linux 系统上默认值是 1024。(注:经测试,Win10 系统该值为16)

MAX_CACHED_BUFFER_SIZE:每个 buffer 的最大值,在 JDK8u102 之后添加参数 -Djdk.nio.maxCachedBufferSize 设置,如果不设置则单个 buffer 的大小上限没有任何限制,为 Long.max。

bufferCache: 临时堆外内存池,每个线程有一个


FileChannelImpl 的 Read 操作会调用 IOUtil 中的 Read 方法,其中如果是 DirectBuffer 则直接读入本地内存,否则则需要开临时的堆外内存 buffer。


进一步进入分配本地内存 buffer 的的方法,判断如果申请的 buffer 大小大于阈值,则直接分配,否则选择从 bufferCache 里复用。



在 Read 操作的最后,会执行一个方法来有条件的释放刚才申请的临时本地内存,即如果大于阈值,则使用完直接释放,否则只有在 bufferCache 达到池子的大小时,才会从头部取出一个释放。 





这个时候问题原因已经大致清晰了。线上的机器是 80核,也就是我们整整开了 80 个查询子线程,而且出问题的线上系统的 tsFile 文件大小非常大,最大的文件达到了 30G,单个 chunk 大小为 500M,也就是说即使我们不考虑 buffer 池子有大小这件事,仅仅缓存一个 chunk 大小的 buffer,也耗了 80 * 500M = 40G 的堆外内存!


因此,结合资料和我们自己的推断解决方案

  1. 由于查询线程池贯穿整个 iotdb 进程的生命周期,且每个查询线程在读取文件时都会直接申请堆外内存,因此必须对查询线程的数量进行限制。
  2. 限制每次FileChannel的IO数据量,尽管一次可能申请一个大的数据块读取,可以分批读,在内存中拼接返回。
  3. 限制写入和合并生成的 Chunk 大小。
  4. 通过 -Djdk.nio.maxCachedBufferSize 限制缓存的 buffer 大小
  5. 实现工具类,替代 FileChannel



  • No labels