Versions Compared

Key

  • This line was added.
  • This line was removed.
  • Formatting was changed.

问题背景


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

...

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



排查过程


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

...

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

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

查询线程数 * buffer    buffer 池大小(1024) * 单个 buffer大小 * 同时打开的文件数。

Image Removed

buffer大小。


以下为 以上为 JVM 源码

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

Image Added

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

MAX_CACHED_BUFFER_SIZE:每个 buffer 的最大值,在 JDK8u102 之后添加,之前甚至对单个 buffer 的大小上限没有任何限制。

bufferCache: 临时堆外内存池

Image Removed

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

Image Added

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

Image Added


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

Image Added


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


Image Added


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


Image Added


Image Added

Image Added



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


Image Removed因此,结合资料和我们自己的推断,解决方案:因此,结合资料和我们自己的推断,一方面由于查询线程池贯穿整个 iotdb 进程的生命周期,且每个查询线程在读取文件时都会直接申请堆外内存,因此必须对查询线程的数量进行限制。另一方面,虽然 JDK 8u102 之后的版本对 buffer 的最大 size 进行了限制,但是 JDK 的发行说明也提到,这个参数只能解决部分问题,因为该申请的依然会申请,只不过大于阈值的 buffer 会在使用后释放,小于阈值的会被缓存下来。因此在遇到临时申请 buffer 过大,或者小 buffer 缓存数量多的情况,依然会对堆外内存产生较大的压力,因此要从根本上对合并产生的 chunk 大小进行限制,避免因单个 chunk 过大而临时申请堆外超出限制的情况。

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