百万级库表能力!这个MongoDB为什么可以这么牛?
探索之路正式开始,文章很长但干货满满,建议收藏品用——
一、 背景:发现问题,立项攻坚
腾讯云MongoDB(简称 CMongo)在运营过程中发现很多业务存在创建大量库表的需求,而随着业务库表数量的不断增长,客户反馈持续出现几秒到几十秒的慢查询,并伴随节点不可用的情况,严重影响到了客户的正常业务请求。
通过监控观察发现,原生MongoDB在库表和索引数量达到百万量级场景下, MongoDB实例在CPU、磁盘等资源远没有达到瓶颈时,也会出现操作卡顿性能下降的问题。
从我们的运营观察来看,至少有以下 3 个非常严重的问题:
性能严重下降,慢查询变多;
内存消耗增大,频繁出现OOM;
实例启动时间明显变长,可能达到小时级。
针对上述问题,腾讯数据库研发中心MongoDB团队基于 v4.0 版本,对原生场景下的百万库表场景进行了性能分析,并结合业界的解决方案进行架构优化,最终取得了非常好的效果(优化后的架构在百万级库表的场景下,将读写性能提升了1-2个数量级,有效降低了内存资源消耗,并将启动时间从原先的小时级缩短到 1 分钟内。)。本文将根据MongoDB原理,为大家介绍分析过程及MongoDB架构优化方案。
二、知己知彼:原生多表场景性能分析
MongoDB内核从3.2版本开始,采用了典型的插件式架构,可以简单理解为分为server层和存储引擎层,通过打点和日志调试,我们最终发现,所有问题都指向存储引擎层,因此,WiredTiger引擎的分析成为了我们后续的重中之重。
(一)WiredTiger 存储引擎简介
MongoDB采用WiredTiger(简称WT) 作为默认存储引擎,整体架构如下图所示:
(WiredTiger 存储引擎整体架构)
用户在MongoDB层创建的每个表和索引,都对应各自独立的WT表。
数据读写经过以下3层:
1.WT Cache:通过B+树缓存未压缩的库表数据,并通过自定义的淘汰算法确保内存占用在合理范围。
2.OS Cache:由操作系统管理,缓存压缩后的库表数据。
3.数据库文件:存储压缩后的库表数据。每个WT表对应一个独立的磁盘文件。磁盘文件划分成多个按照4KB对齐的extent(offset+length),并通过3个链表来管理:available list(可分配的extent列表),discard list(废弃的extent列表,但是还不能马上重用,可能其他checkpoint还在引用)和allocate list(当前已分配的extent列表。
(二)内存消耗分析
思考:如果用户不会在短时间内访问所有的表,必然有表长时间空闲,那为何非活跃表的数据长时间停留在内存?
假设:如果非活跃表占用的内存能够及时换出,那将有效提高一个普通规格的集群能够支持的最大表数量,从而避免频繁OOM。
探索过程:我们在云上创建一个2核4G的副本集,不断创建表(每个表2个索引),每个表在插入数据创建完成之后不再访问。测试过程中发现current active dhandle一直在上升,而connection sweep dhandles closed指标却很平缓。最终实例占用的内存也一直上升,在创建的表不足1万时,就触发了实例OOM。
data handle & sweep thread
data handle(简称 dhandle) 可以简单理解为wiredtiger资源的专属句柄,类似系统的fd。
在全局WT_CONNECTION对象中维护着全局的dhandle list, 每个 WT_SESSION对象维护着指向dhandle list的dhandle cache。第一次访问WT表时,在全局dhandle list和session dhandle cache中没有对应的dhandle,会为此表创建dhandle, 并放到全局dhandle list中。
sweep thread后台线程每10秒扫描WT_CONNECTION中的dhandle list,标记当前没有使用的dhandle。如果打开的btree数量超过了close_handle_minimum(默认值250),则检查有哪些dhandle在 close_idle_time内一直处于idle状态,则关闭与此dhandle相关的 btree,释放一些资源(非全部资源),标记dhandle为dead以便引用它的session能够发现此dhandle已经不能再访问。接下来还得判断是否有session引用这个dead dhandle,如果没有则从全局list中移除。
基于以上分析,我们有理由怀疑为何清理的效率这么差。
深入分析MongoDB代码可知,在初始化WiredTiger引擎时将close_idle_time设置成了100000s(~28h)。 这样设置导致的后果是sweep不够及时,不再访问的表仍然会长时间消耗内存。有兴趣的读者可以参与jira进行讨论。
验证得出结果:为了快速验证我们的分析,将代码中close_idle_time从原先的1万秒调成600秒,运行相同测试程序,发现节点没有OOM,1万个 collection成功插入,内存使用率也维持在较低值。
(不同close_idle_time的内存消耗图)
connection data handles currently active在一开始就不再直线上升,而是有升有降,最终程序结束后,回归到250,符合预期。所以,对于线上业务表多、频繁OOM、实例规格又小的集群,可以临时采取调整配置的方式来缓解。但是这样又会导致dhandle的缓存命中率非常低,带来比较严重的性能下降。
因此,如何降低dhandle个数减少内存消耗,同时又保证dhandle缓存命中率避免性能下降,成为了我们的重点优化目标之一。
(三)读写性能分析探索过程场景一:预热前性能分析
预热指顺序读取每个collection至少一条数据,以便让所有collection的cursor都进入到session cursor cache。
在整个测试过程中,每个线程随机选择collection进行测试,可以发现持续有慢查询。通过下面的监控展示可以看到,在wait time窗口,随着蓝色曲线(schema lock wait)飙高,read latency也飙高。因此,有必要分析schema lock的使用逻辑。
(预热前随机查询监控图)
为什么简单的CRUD请求要直接或间接依赖schema lock呢?
在测试过程中生成火焰图,得到函数调用栈如下:
(预热前单点随机查询火焰图)
在"all"上方还有很多像"conn10091"线程一样的火焰柱形,表明客户端处理线程都在抢schema lock,而它是属于WT_CONNECTION对象的全局锁。这样也就解释了为何会产生那么大的schema lock wait。
那为何线程都在执行open_cursor呢?
在执行CRUD操作时,MongoServer线程需要选择一个WT_SESSION, 再从WT_SESSION中获取表的wtcursor,然后执行findRecord, insertRecord, updateRecord等操作。
为了保证性能,在MongoServer侧和WT引擎侧都会缓存wtcursor。但如果表是首次访问,此时dhandle和wtcursor还未生成,需要执行代价昂贵的创建操作,包括打开file,建立btree结构等。
结合代码和火焰图可知,open_cursor获取schema lock之后在get dhandle阶段消耗了很多CPU时间。当系统刚启动未预热时,很显然会有大量的open_cursor调用,从而造成spinlock长时间等待。可见,dhandle越多,竞争也越大。若想减小这种spinlock竞争,就需减少dhandle数量,这样就能大大地加快预热速度。这也是我们后续优化的一个攻克方向。
场景二:预热后性能分析
持续读写的场景下,在经历了“数据预热”阶段之后,每个WT表的data handle都完成了open操作,此时前文描述schema lock已经不再成为性能瓶颈。但是我们发现和表少的场景相比,性能仍然相对低下。
为了进一步分析性能瓶颈,我们对读写请求的全链路各个阶段进行了分段统计和分析,发现在data handle缓存访问阶段耗时很长。
WT_CONNECTION和WT_SESSION会缓存data handle,并采用哈希链表加速查找,默认的hash bucket的个数为512。 在百万级库表场景下,每个list会变得很长,查找效率剧烈下降,从而导致用户的读写请求变慢。
通过在WT代码中增加data handle缓存的访问性能统计,发现用户侧慢请求的个数和data handle访问慢操作的个数相关,而且时延分布也基本一致。如下所示:
为了快速验证我们的分析,可以在压测时尝试将Bucket个数提升100倍,使每个Hash链表的长度大幅变短,发现性能会有几倍甚至数量级的提升。
通过以上分析,可以得到的启示是:如果能够将MongoDB表共享到少量WT表空间中,能够降低data handle个数,提升data handle缓存的访问效率,从而提升性能。
(四)启动速度分析
原生MongoDB在百万级库表场景下,启动mongod实例会耗时长达几十分钟,甚至超过1个小时。如果有多个节点在OOM之后不能被很快被拉起提供服务,则整体服务可用性将受到很大影响。
探索过程:为了观察启动期间MongoServer和WT引擎的流程和耗时分布,我们对MongoDB和WT引擎日志进行了分析统计。
通过日志分析发现,耗时最长的部分为WT表的reconfig阶段。
具体来说,在mongod启动时mongoDbMain的初始化阶段会初始化存储引擎,并执行loadCatalog初始化所有表的WiredTigerRecordStore。在构造WiredTigerRecordStore时,会根据表的uri执行setTableLogging配置底层WT表是否开启 WAL。最后调用底层WT引擎的schema_alter流程执行配置,这里会涉及到多次IO操作(获取原有配置,再和新配置进行比较,最后可能执行配置更新)。同理,mongoDbMain 也会初始化所有表的索引(WiredTigerIndex),也会执行对应的setTableLogging操作。
另外,以上流程都是串行操作,在库表索引变多时,整体耗时也会线性增长。实测发现在百万库表场景下超过99%的耗时都在这个阶段,成为了启动速度的瓶颈。
为什么启动时要对WT表进行reconfig呢?
MongoDB会根据表的用途以及当前的配置,决定是否对某个表开启WAL,具体可以参考WiredTigerUtil::useTableLogging和WiredTigerUtil::setTableLogging的实现逻辑。在mongod实例启动以及用户建表时都会进行相关配置。仔细分析这里的逻辑,可以得到以下规律:在表创建完成,对应的WT uri确定之后,这个表是否开启WAL是可以确定的,不会随着实例的运行发生改变。
通过以上分析,可以得到2点启示:
1.如果将开启WAL配置一致的MongoDB表都共享到少量WT表空间中,可以将setTableLogging的操作次数由百万级降低为到个位数,从而极大提升初始化速度。从我们的架构优化和测试也能证明,可以将小时级的启动时间优化到1分钟内。
2.减少setTableLogging操作之后,会避免WT引擎进行schema_alter操作时获取全局schema lock,从而给MongoServer的上层逻辑带来优化空间。通过将串行初始化优化成多线程并发初始化表和索引,能够进一步加快启动速度。
(五)性能分析总结
WT引擎在百万级库表场景下,会对应生成大量的data handle并导致性能下降,包括锁竞争、关键数据结构的缓存命中率低、部分串行化流程在百万级库表下带来的耗时放大等问题。
(六)思考
结合前面的分析,如果MongoServer层能够共享表空间,只在WT引擎上存储数量较少的物理表,是否能避免上述性能瓶颈?
三、青胜于蓝:CMongo百万库表架构优化
(一)方案选型
在MySQL中,InnoDB通过为每个表分配space id实现共享表空间;在MongoRocks中, 每个表会分配唯一的prefix,每条数据的Key都会带上prefix信息。
在原生MongoDB代码中,也遵循“前缀映射”的思路实现了部分基础代码。具体可以参考KVPrefix的定义,以及GroupCollections选项的相关代码和注释。但是这个功能并没有完全实现,只进行了基础的数据结构定义,因此也不能直接使用。我们通过邮件和作者进行了沟通,社区目前也没有实现该功能的后续计划MongoDB JIRA相关信息如下:
问题 | 描述 | 当前状态 |
「使WT cursor 支持 groupCollections」-https://link.zhihu.com/?target=https%3A//jira.mongodb.org/browse/SERVER-28743 | 当开启了groupCollections后,数据和索引的key_format分别变成qq, qu,cursor 在迭代时要检测prefix是否匹配 | fixed |
「兼容sampling cursor」-https://link.zhihu.com/?target=https%3A//jira.mongodb.org/browse/WT-3341 | 当支持groupCollections时,为 WT_CUROSR 提供一个新方法 range() 以支持random cursor | open |
「兼容sampling cursor」- https://link.zhihu.com/?target=https%3A//jira.mongodb.org/browse/SERVER-29113 | 与第2个问题相关,需要WT 层支持random cursor 正确返回匹配前缀的数据。这在为oplog 设置截断点时要用到。据原作者所说,有比较多的工作要做 | closed |
「官方测试百万表」- https://link.zhihu.com/?target=https%3A//jira.mongodb.org/browse/WT-3337 | 对groupCollections 特性进行压测的POC 程序 | closed |
「官方groupCollections 设计文档」- https://link.zhihu.com/?target=https%3A//jira.mongodb.org/browse/SERVER-28745 | 工作较多,包括共享session等 | - |
「底层表不存在时才创建」- https://link.zhihu.com/?target=https%3A//jira.mongodb.org/browse/SERVER-28744 | 当开启groupCollections时,是先搜索是否存在兼容的table,只能在没有underlying table时才创建它; 删除collection/indexes 要注意不要无条件删除了底层表; oplog 要单独放 | closed |
结合我们对原生多表场景下的测试和分析,认为共享表空间可以解决上述性能瓶颈,从而提升性能。因此我们决定基于“前缀映射”的思路进行共享表空间架构优化和验证。
(二)架构设计
在方案设计初期,我们对于在哪个模块实现共享表空间进行了以下对比:
1.改造WT存储引擎内部逻辑。多个逻辑WT表通过前缀进行区分,共享同一个物理WT表空间,共享data handle, btree, block manager等资源。但是这种方式涉及的代码改动量极大,开发周期太长。
2.改造MongoDB中KVEngine抽象层的逻辑。在存储引擎上层通过前缀映射的方式,使多个MongoDB表共享同一个WT表空间。这种方式主要涉及KVEngine存储抽象层使用逻辑的改造,并兼容原生WT引擎对前缀操作支持不完备带来的问题。这种方式涉及的代码改动量相对较少,不涉及WT引擎内部架构的调整,稳定性和开发周期更加可控。
因此,优化工作主要集中在“KVEngine抽象层”:通过前缀方式,将多个MongoDB用户表映射成为1个WT表;MongoServer层对指定库表的CURD操作,都会通过key -> prefix+key的转换之后,到WT引擎进行数据操作。上述映射关系通过 __mdb_catalog存储。
整体架构图如下所示:
(优化后整体架构)
建立好映射关系之后,不论用户在上层创建多少个库表和索引,在WT引擎层都只会有9个WT表:
1.WiredTiger.wt:存储Checkpoint元数据信息
2.WiredTigerLAS.wt:存储换出的LAS数据
3._mdb_catalog.wt:存储上层MongoDB表和底层WT表的映射关系
4.sizeStorer.wt:存储计数数据
5.oplog-collection.wt:存储oplog数据
6.collection.wt:存储所有MongoDB非local表数据
7.index.wt:存储所有MongoDB非local索引数据
8.local-collection.wt:存储MongoDB local表数据
9.local-index.wt:存储MongoDB local索引数据
为什么表和索引分开存储?
因为表和索引在WT引擎中的schema定义不同,比如表的schema是qq -> u,索引的schema是qu -> q(q表示int64, u表示string);也可以将collection和index的schema都统一成qu -> u的形式, 但是在数据读写时会带来一些额外的类型转换。
每一个表都对应一个prefix,通过建立(NS, Prefix, Ident)三元关系来将多个表的数据共享到一个文件中,共享后的数据文件如下所示:
(共享后数据文件)
经过架构调整之后,一些关键路径发生了变化:
路径变化1:建表和索引操作,会先生成唯一的prefix,并记录到mdb_catalog中,而不再对WT引擎发起表创建操作(WT引擎的共享表会在实例第1次启动时检查并创建完成。)。
路径变化2:删表和索引操作,会删除mdb_catalog中的记录,然后进行数据删除操作,但不会直接删除WT表文件。
路径变化3:数据读写操作,会将prefix添加到访问的key头部,然后再去WT中执行数据操作。
通过建立prefix映射关系,不论MongoDB上层库表个数如何增长,底层WT表个数都不会增长。因此,上文分析的data handle过多导致内存使用率高,data handle open操作导致的锁争抢,data handle cache效率低下,以及WT表太多导致实例启动慢的问题都不会出现,极大提升了整体性能。
当然,通过prefix共享表空间之后也会带来一些新的使用限制,主要有:
用户删除表之后,空间不会释放,但是可以被重新使用;
部分表操作的语义发生了变化。比如compact和validate操作需要放到全局实现,目前暂不支持;
部分统计信息发生了变化。比如表和索引的storageSize都无法统计,只能统计出逻辑大小(压缩前的大小)。由于原生sizeStorer.wt中只记录了表的逻辑大小,因此需要自己实现索引逻辑大小的统计。进行这个改造之后,show dbs(listDatabases命令)的速度提升了不少,从我们的测试结果来看可以从11s缩短到0.8s, 主要得益于不需要对大量索引文件执行统计操作。
(三)优化效果
我们在腾讯上搭建的测试环境,具体的测试环境如下:
1.数据量大小500GB(500 000 000 000 B)(collection*recordNum
*fieldLength);
2.总体压测线程数量200;
3.副本集的配置:8核,16G内存,2T磁盘;
4.mongo driver 版本:go-driver[v1.5.2];
5.driver的连接池大小为200;
6.测试工具与primary在同一台机器上,直连primary。
测试结果
腾讯数据库研发中心MongoDB团队根据业务使用情况,挑选出50w表与100w表两种场景进行测试。在实际测试过程中发现,改造之前的集群无法写入100w表的数据。最后给出50w表的测试数据。
测试的大概步骤如下所示:
1.默认构建10个库;
2.每个库先创建5w空表;
3.开始向表中写入固定数量的随机数据;
4.执行CRUD的操作。
下面给出百分位延迟对比结果图(由于数据差异较大,为了能显示对比,对所有的数据都做了log10()处理)。从P99图可以看出,改造后在CRUD操作上的性能要优于改造前。下图展示了QPS对比结果:
(改造前后QPS对比图)
改造后的QPS也远优于改造前,原因是,改造后可以认为所有的数据同在一张表中,性能不会随着表的数量发生很大的改变;而改造前抢锁的激烈程度,data handle的访问时间会随着表数量的增加而增加,由此导致性能很低。
下图给出了改造前后多表以及原生单表query操作的QPS对比图,改造后相对于原生单表的QPS最大降幅在7%以内,而改造前相对于原生单表的QPS最大降幅已经超过90%。
(QPS变化图)
线上效果
原生MongoDB随着表数量的大量增长,资源消耗也会大幅增加,性能急剧下降。腾讯数据库研发中心MongoDB团队在进行性能分析之后,使用了共享表空间思路,将用户创建的海量库表共享底层 WT 引擎的1个表空间。WT引擎维护的表数量不随用户创建表和索引的操作线性增长,始终保证在个位数。
优化后的架构在百万级库表的场景下,将读写性能提升了1-2个数量级,有效降低了内存资源消耗,并将启动时间从原先的小时级缩短到1分钟内。目前架构优化已经通过了各项功能测试和性能压测并顺利上线。云上的即时通信IM业务,替换了百万库表版本之后,有效的解决了客户表很多时造成的CRUD操作变慢的问题,系统内存消耗也明显降低。后面该内核特性也会全面开放到云上,欢迎大家体验。
四、步履不停,一直在路上
腾讯云MongoDB(TencentDB for MongoDB)是腾讯基于全球广受欢迎的文档数据库MongoDB打造的高性能NoSQL数据库,100%完全兼容MongoDB协议。相比原生版本,腾讯数据库研发中心MongoDB团队对原生内核做了大量优化和深度定制,包括百万TPS、物理备份、免密、无损加节点、rocksdb引擎等优化,同时也新开发了流控、审计、加密等企业级特性,为公司内外客户的核心业务提供了有力的支撑。
目前,腾讯MongoDB已在稳定性、安全性、性能以及企业级特性上取得了显著突破。接下来我们还会继续在性能、新特性、成本等方面进行持续不断的打磨、改进,争取为公司内外用户提供更好的云MongoDB服务。
推荐阅读
Serverless 在大厂都怎么用?一文说尽Golang单元测试实战的那些事儿
前以色列国防军安全技术成员教你做好 Serverless 追踪
从万物互联到万物智联,物联网的下一个爆发点在哪里?
👇 想看看百万级库表能力的数据库到底是什么?点击【阅读全文】进入【腾讯云官网】云数据库TencentDB for MongoDB查看详情!