大规模数据OLAP分析爆内存问题分析与解决
PR地址:Fix RocksDB OOM #823
问题背景
问题表现
在执行如下Gremlin语句时,如果一次查询分析的结果数据在千万条以上,进程内存在几分钟内暴涨,JVM老年代占据将近20G内存、RocksDB占用本地内存超过100G,最终导致OOM,甚至进程被系统kill掉。
g.V().hasLabel('vbsku').count()
g.V().hasLabel('vbsku').groupCount().by(properties('p_trade').value())
问题分析:
经过分析,发现大量迭代器RocksIterator没有释放,达到几百万个。这些java层RocksIterator对象占用的内存其实并不算大,但是在JNI层之下,其持有的本地对象占用了大量资源。这些资源依赖JVM的GC垃圾回收机制来释放,当JVM GC压力大时,导致本地资源无法及时释放,最终恶性循环越积越多,内存撑爆。
那么,RocksIterator为何没有释放?
在HugeGraph中,一个用户查询Query会被拆分为多个子查询SubQuery,并且将SubQuery的结果通过迭代器Iterator列表串起来。每个SubQuery的的结果也是一个迭代器,比如顶点迭代器Iterator<Vertex>,边迭代器Iterator<Edge>;并且,最上层的接口到最底层的数据之间也是通过迭代器串联起来的,这中间可能涉及到针对迭代器的几类转换操作:
- map
- flatmap
- batchmap
- filter
- concat
比如:
把二进制数据迭代器转换为顶点迭代器的操作:
- map(Iterator<Entry>) => Iterator<Vertex>
或者把索引结果迭代器转换为顶点迭代器的操作:
- map(Iterator<Entry>) => Iterator<Index>
- batchmap(Iterator<Index>) => Iterator<IdList>
- flatmap(Iterator<IdList>) => Iterator<Entry>)
- map(Iterator<Entry>) => Iterator<Vertex>
这种获取数据的模式在数据库领域称之为火山模型,使用火山模型的目的是通过流式读取数据,而不是把所有数据都从磁盘读到内存(数据量比较大时内存装不下),再进行下一步操作。
由于迭代器的组合灵活,导致各类迭代器的生命周期难以管理,此前迭代器的释放基本是采取如下原则:
- 尽量在迭代器不需要使用的时候释放,比如对底层的数据迭代器,当取下一条数据next()发现已经无数据时,及时关闭。
- 对于无法及时关闭的的迭代器,则依赖JVM GC来自动释放,比如上层迭代到一半就因为其它条件不满足而提前结束时(类似limit()或one()等),底层迭代器无法感知,只能在无人引用该对象的时候,触发GC finalize()对底层迭代器进行释放。
正是因为有部分依赖GC机制释放的迭代器存在,引入了爆内存的风险,当GC压力大时,JVM本地冰山对象严重消耗内存,并且来不及回收,出现了上述问题。
问题解决
解决方案:
- 将所有的迭代器的生命周期进行链式管理,最上层的迭代器关闭时,传导到中间层的所有迭代器,最终传导到最底层的迭代器,触发资源释放。
- 所有迭代器,包括中间层和上层的,在无需继续使用时均采取手动关闭该迭代器,进而触发底层的迭代器释放。参考代码:Manually close the iterators
- 禁止使用一次性加载数据到内存集合的操作,把迭代器转换为集合,包括遗留的部分场景比如边的顶点列表获取、缓存列表获取、任务列表获取等等。参考代码:iter gloabal by batch、limit iterator to list、force limit BatchIdHolder.all() 、adapt ListIterator.list() return Collection
- 同时,解决了一些极端情况下爆内存的场景,比如超级点的边被大量加载到内存,限制了一次最多读取的条数。参考代码:fix cassandra oom、fix check subRows().size() <= INLINE_BATCH_SIZE
- 顺便解决了offset在多个子查询结果之间无法定位的问题,思想是在最顶层的Query中维护一个偏移计数,从而让最底层的SubQuery也能够通过OriginQuery链共享该计数。参考代码:fix offset bug