背景
最近3个月,数据库内核做了非常多的新功能特性的开发,目前在测试收尾阶段。在进行多表多列(表数量1000+, 总计列数量100W+)场景的性能测试时,发现存储引擎节点在启动时出现启动加载过程缓慢和内存溢出导致启动失败的现象。此处记录的为内存溢出的排查过程。
产生dump文件
通过dump, 查看
jmap -dump:format=b,file=/home/fengyang/oom.phrof 10391
或者在JVM参数中配置以下参数 XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath,当内存溢出时输出Dump文件,dump文件名格式为xxx.hprof。
分析工具
Memory Analyzer
由于我的开发笔记本为Ubuntu pro系统,因此我选择了 Linux (x86_64/GTK+) 版本 。
打开MAT,File—>open dump file—>选择本次输出的dump文件,加载文件。
加载成功后,可以看到如下总览页面,点击Leak Suspects查看内存泄露分析。
分析dump文件
进入Leak Suspects可以看到,有3处可疑的内存泄露问题,占用了绝大部分内存,接下来分析这3处问题。
PS: 由于一些敏感原因,对源代码类做了马赛克。 我们假定Problem Suspect 1 的问题类为 AService
,Problem Suspect 2 的问题类为 BSchema
, Problem Suspect 3 的问题类为 CEngine
。并不影响我们后续的排查过程和逻辑。
通过上述信息,我们可以得到, 在启动过程中,AService 占了存储引擎节点总计14GB的内存,BSchema 占了存储引擎节点总计15.8GB的内存, CEngine 占了节点总计 6.3GB的内存。
这里先说一下结论,堆内存总共42G,Suspect1问题间接导致14G内存的占用,并无法回收,Suspect2、Suspect3同理。这样就基本可以确定是这一次内存溢出导致的bug。
MAT 中的基础知识
分析到这,我们先列举一下MAT中的各个术语代表的意思。
对象的 Shallow heap 是其自身在内存中的大小。
对象的 Retained heap 指的就是在垃圾回收特定对象时将释放的内存量。
进一步查看某个 BSchema 实例
由于 BSchema 与表、列的元数据信息相关,我们优先查看Suspect2 details,在按下图所示查看某个 BSchema 实例所占据的内存大小(with incoming references 选项)。
可以看到,一个具体的 BSchema 实例,占据了2.6GB内存。这里是指没释放的对象!!!
BSchema 对象持有N个entries的引用,如果 BSchema 对象是从内存中被垃圾回收,则将不再有对N个entries的引用。这意味着此时N个entries也可以被垃圾收集。
因此 BSchema 对象的Retained Heap 大小为:= BSchema 本身的 shallow heap 大小 + N个entries的 shallow heap 大小。
查看 BSchema 本身的内存使用情况:
发现 BSchema 对象持有9个entries的引用, 但每个entry占用的内存空间并不大。
进一步查询关联引用的内存使用情况,细看每个entry发现Table数量异常。
一个 BSchema 分配了2.6G内存,包含4164个entries, 每个entry(即Table实例对象)平均0.47M来算的话,乘积是2G内存占用. 32个shard共产查看本身的内存使用情况:生32个 BSchema,内存直接满。
(实际表为1691个, 实际列101w)。哈哈。再继续往下看看。
mysql> select count(1) from tables;
+----------+
| count(1) |
+----------+
| 1691 |
+----------+
1 row in set (0.01 sec)
mysql> select count(1) from columns;
+----------+
| count(1) |
+----------+
| 1015112 |
+----------+
1 row in set (1.98 sec)
为了印证上述表数量的正确性,接下来统计下每个对象类型的数量:
发现一个 BSchema 下具体的Table的实例数量为 4160个、具体的Column的实力数量为250w。
继续查看全局的Table和Column实例化数量:
可以发现,在服务启动期间,创建并存留了5.3W个Table实例,3200w个Column实例。
目前存储层对于Table的管理方式:
每个shard下的每个表,都会产生一个Table的实例,推算出该实现的理论Table实例数量为: 1691 * 32 = 54112个。 排除掉内置库和内置表的个数,与分析大致相符。
在建表时每个表有600列,因此产生的Column数目为: 53379 * 600 = 3202w。
谁持有了 BSchema 的引用?
AService 中的schemas属性,强引用了 BSchema。
BSchema 内存溢出的结论
大量表大量列的场景下,数据库内核的实现并不能良好的进行支持。
优化方案:
1、 需要进一步优化重写Table句柄、Column句柄的相关实现。
2、Table和Colum的基数过大,可以考虑实例复用和减少实例创建的相关优化
查看某个 AService 实例
AService 中产生了30个异步线程。
进入其中一个线程中查看,发现内部进行了paralleLoadTable动作,可判断出 AService 产生大量内存占用的原因是因为持有了 BSchema 的引用(同Retained heap原理)。
AService 内存溢出的结论
优化方案,两部分:
1、修复 BSchema 问题.
2、将线程池parallelLoadShard和parallelLoadTable的核心线程数调小, 减少BSchema期间的并发压力。
优化依据: table数量不确定,但是shard确定为32, 因此shard势必应并行处理。在这个基础上,应该减少table的并发量,来减缓线程数的膨胀。
优化如下:
线程池 | 调整之前 | 调整之后 |
---|---|---|
parallelLoadShard | 8 | 2 |
parallelLoadTable | 256 | 64 |
CEngine 的内存溢出排查
同上。 此处忽略。
为什么每个Table实例会分配 0.47MB?
现在再分析最后一个问题: 为什么每个entry(即Table实例对象)会平均分配0.47MB? 做了哪些事情?
分析一个具体的Table实例的构成
分析一个具体的Table实例的构成, 如下图:
按MB为单位, 统计构成如下:
Table -- 0.47MB
+ columnList -- 0.22MB
+ normalIndexes -- 0.13MB
+ columnIdMap -- 0.01MB
+ columnIdsWithNormalIndex -- 0.01MB
+ columnMap -- 0.06MB
+ 其他属性
columnList
同理。 此处忽略。
columnMap
同理。 此处忽略。
normalIndexes
normalIndexes维护了600个列的 NormalIndex 属性信息;
NormalIndex 内存占用如下图:
优化点
从上述每个Table实例的内存占用情况来看,基本上每个属性都是合理的。但Table的基数太大。 Table的基数优化在上述已经有定论,在这个定论的基础上,可以进一步探讨对NormalIndex对象和Column对象压缩。减少内存占用。比如NormalIndex可以按照字节码和定长/变长编码来代替Java Bean Object(一种思路,后续讨论)。
分析结论
经过上述分析,总结如下:
1、进一步优化重写Table句柄、Column句柄的相关实现。目前对于这两个句柄的holder实现过于分散且繁琐,句柄的对象布局非常复杂。在元数据角度,不需要如此复杂的Table句柄、Column句柄设计与实现。通过精简对象布局,可以极大的减少对内存空间的占用。
2、Table和Colum的基数过大,要考虑实例复用和减少实例创建的优化。目前同一个表或者同一个表的同一列(分别对应 Table Instance 和 Column Instance)在 BSchema 的实例中是要创建32次(因为存在32个shard)。但对于同一个表或者同一个表的同一列而言,Table Instance 和 Column Instance 是不会变化的(如果存在ALTER操作,BSchema 内部进行 renameTable / renameColumn Update 即可)。
因此完全可以进一步对 BSchema 进行拆分,剥离出 Table/Column 句柄,在同一个表中只产生一个Table Instance , 同时产生32个该表的 BSchema Instance, 这32个 BSchema Instance 只需要持有Table Instance 的 Ref。 如此, Table的实例数会减少 31/32 = 96.9%, Column 的实例数同理。
3、将线程池parallelLoadShard和parallelLoadTable的核心线程数调小, 减少 BSchema 期间的并发压力。
优化依据: table数量不确定,但是shard确定为32, 因此shard势必应并行处理。在这个基础上,应该减少table的并发量,来减缓线程数的膨胀。
4、Table 、 Column 、NormalIndex 等的对象布局,可以采用更紧密的数据结构,来代替Java Bean Object。 如NormalIndex可以按照字节码和定长/变长编码实现(一种思路,后续讨论)。
5、load过程 流程优化。目前该流程比较重。除了会带来启动时长的问题,也加重了内存紧张的情况。