Versions Compared

Key

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

...

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

查询线程数 *  同时打开的文件数 * buffer  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 之后添加,之前甚至对单个 buffer 的大小上限没有任何限制。之后添加参数 -Djdk.nio.maxCachedBufferSize 设置,如果不设置则单个 buffer 的大小上限没有任何限制,为 Long.max。

Image Added

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


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

...


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



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

...

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


因此,结合资料和我们自己的推断,一方面由于查询线程池贯穿整个 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