青胜于蓝丨腾讯MongoDB百万库表探索之路

2021-07-29 15:40:25 浏览数 (1)

文章出处: 鹅厂架构师

导读

腾讯 MongoDB 百万库场景下的性能优化成果汇报

腾讯 MongoDB目前广泛应用于游戏、电商、ugc、物联网等场景,很多客户在使用过程中库表数量会大量增长,甚至达到百万级别,导致性能急剧下降,严重影响客户业务。腾讯数据库研发中心CMongo团队在进行深入性能分析之后,改造底层引擎为共享表空间架构,新架构在百万级库表的场景下,相比原生版本读写性能提升 1-2 个数量级,内存消耗显著降低,启动时间从原先小时级缩短到一分钟内。

探索之路正式开始,文章很长但干货满满,建议收藏品用——

问题初现,立项攻坚

腾讯数据库研发中心CMongo团队(简称 CMongo) 在运营过程中发现很多业务存在创建大量库表的需求,而随着业务库表数量的不断增长,客户反馈持续出现几秒到几十秒的慢查询,并伴随节点不可用的情况,严重影响到了客户的正常业务请求。通过监控观察发现,原生MongoDB在库表和索引数量达到百万量级场景下, MongoDB 实例在 CPU、磁盘等资源远没有达到瓶颈时,也会出现操作卡顿性能下降的问题。

从我们的运营观察来看,至少有以下 3 个非常严重的问题:

● 内存消耗增大,频繁出现 OOM

● 性能严重下降,慢查询变多

● 实例启动时间明显变长,可能达到小时级

针对上述问题,CMongo 团队基于 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列表)

01

内存消耗分析

思考:如果用户不会在短时间内访问所有的表,必然有表长时间空闲,那为何非活跃表的数据长时间停留在内存?

假设:如果非活跃表占用的内存能够及时换出,那将有效提高一个普通规格的集群能够支持的最大表数量,从而避免频繁OOM。

探索过程——

我们在云上创建一个 2核4G 的副本集,不断创建表(每个表2个索引),每个表在插入数据创建完成之后不再访问。测试过程中发现current active dhandle  一直在上升,而 connection sweep dhandles closed 指标却很平缓。最终实例占用的内存也一直上升,在创建的表不足 1 万时,就触发了实例 OOM.

data handle & sweep thread

data handle(简称 dhandle) 可以简单理解为 wiredtiger 资源的专属句柄,类似系统的 fd,源码里简写为dhandle

在全局 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 不够及时,不再访问的表仍然会长时间消耗内存。

验证得出结果——

为了快速验证我们的分析,将代码中 close_idle_time从原先的 1万秒调成 600 秒,运行相同测试程序,发现节点没有 OOM,1 万个 collection 成功插入,内存使用率也维持在较低值。

connection data handles currently active 在一开始就不再直线上升,而是有升有降,最终程序结束后,回归到 250,符合预期。

所以,对于线上业务表多、频繁 OOM、实例规格又小的集群,可以临时采取调整配置的方式来缓解。但是这样又会导致 dhandle 的缓存命中率非常低,带来比较严重的性能下降。

因此,如何降低 dhandle 个数减少内存消耗,同时又保证 dhandle 缓存命中率避免性能下降,成为了我们的重点优化目标之一。

02

读写性能分析

场景一:预热前性能分析

预热指顺序读取每个 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_session_get_dhandle 的代码逻辑。

通过在 WT 代码中增加 data handle 缓存的访问性能统计,发现用户侧慢请求的个数和 data handle 访问慢操作的个数相关,而且时延分布也基本一致。如下所示:

慢操作次数统计

MongoDB用户侧慢请求个数

遍历data handle hash表慢操作个数

10-50ms

13432

19684

50-100ms

81840

75371

>100ms

12473

6905

我们在压测时尝试将 Bucket 个数提升 100 倍,使每个 Hash 链表的长度大幅变短,发现性能会有几倍甚至数量级的提升。

通过以上分析,可以得到的启示是:如果能够将 MongoDB 表共享到少量 WT 表空间中,能够降低 data handle 个数,提升 data handle 缓存的访问效率,从而提升性能。

03

启动速度分析

原生 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 的上层逻辑带来优化空间。通过将串行初始化优化成多线程并发初始化表和索引,能够进一步加快启动速度。

04

性能分析总结

WT 引擎在百万级库表场景下,会对应生成大量的 data handle 并导致性能下降,包括锁竞争、关键数据结构的缓存命中率低、部分串行化流程在百万级库表下带来的耗时放大等问题。

思考:结合前面的分析,如果 MongoServer 层能够共享表空间,只在 WT 引擎上存储数量较少的物理表,是否能避免上述性能瓶颈?

进阶优化,青胜于蓝

01

方案选型

在 MySQL 中,InnoDB 通过为每个表分配 space id实现共享表空间;在 MongoRocks 中, 每个表会分配唯一的 prefix,每条数据的 Key 都会带上 prefix 信息。

在原生 MongoDB 代码中,也遵循“前缀映射”的思路实现了部分基础代码。具体可以参考 KVPrefix 的定义,以及 GroupCollections 选项的相关代码和注释。但是这个功能并没有完全实现,只进行了基础的数据结构定义,因此也不能直接使用。我们通过邮件和作者进行了沟通,社区目前也没有实现该功能的后续计划。

MongoDB JIRA 相关信息如下:

问题

描述

状态

使WT cursor 支持 groupCollections

当开启了groupCollections后,数据和索引的key_format分别变成qq, qu,cursor 在迭代时要检测prefix是否匹配

fixed

兼容sampling cursor

当支持groupCollections时,为WT_CUROSR 提供一个新方法 range()以支持random cursor,目前没有此方法

open

兼容sampling cursor

与第2个问题相关,需要WT 层支持random cursor 正确返回匹配前缀的数据。这在为oplog 设置截断点时要用到。据原作者所说,有比较多的工作要做

closed

官方测试百万表

对groupCollections 特性进行压测的POC 程序

closed

官方groupCollections 设计文档,2017.4

工作较多,包括共享session等;google doc需要申请

底层表不存在时才创建

当开启groupCollections时,是先搜索是否存在兼容的table,只能在没有underlying table时才创建它;删除collection/indexes 要注意不要无条件删除了底层表; oplog 要单独放

closed

结合我们对原生多表场景下的测试和分析,认为共享表空间可以解决上述性能瓶颈,从而提升性能。因此我们决定基于“前缀映射”的思路进行共享表空间架构优化和验证。

02

架构设计

在方案设计初期,我们对于在哪个模块实现共享表空间进行了以下对比:

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 共享表空间之后也会带来一些新的使用限制,主要有:

  1. 用户删除表之后,空间不会释放,但是可以被重新使用
  2. 部分表操作的语义发生了变化。比如 compact 和 validate 操作需要放到全局实现,目前暂不支持
  3. 部分统计信息发生了变化。比如表和索引的 storageSize 都无法统计,只能统计出逻辑大小 (压缩前的大小)。由于原生 sizeStorer.wt 中只记录了表的逻辑大小,因此需要自己实现索引逻辑大小的统计。进行这个改造之后,show dbs (listDatabases 命令) 的速度提升了不少,从我们的测试结果来看可以从 11s 缩短到 0.8s, 主要得益于不需要对大量索引文件执行统计操作。

03

优化效果

我们在腾讯云上搭建的测试环境,具体的测试环境如下:

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

测试结果

CMongo团队根据业务使用情况,挑选出 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 随着表数量的大量增长,资源消耗也会大幅增加,性能急剧下降。CMongo 团队在进行性能分析之后,使用了共享表空间思路,将用户创建的海量库表共享底层 WT 引擎的 1 个表空间。WT 引擎维护的表数量不随用户创建表和索引的操作线性增长,始终保证在个位数。

目前架构优化已经通过了各项功能测试和性能压测并顺利上线。云上的即时通信IM业务,替换了百万库表版本之后,有效的解决了客户表很多时造成的CRUD操作变慢的问题,系统内存消耗也明显降低。后面该内核特性也会全面开放到云上,欢迎大家体验。

步履不停,在路上

腾讯MongoDB(TencentDB for MongoDB) 是腾讯基于全球广受欢迎的文档数据库MongoDB 打造的高性能 NoSQL 数据库,100% 完全兼容 MongoDB 协议。相比原生版本,CMongo内核团队对原生内核做了大量优化和深度定制,包括百万TPS、物理备份、免密、无损加节点、rocksdb引擎等优化,同时也新开发了流控、审计、加密等企业级特性,为公司内外客户的核心业务提供了有力的支撑。

目前,腾讯MongoDB 已在稳定性、安全性、性能以及企业级特性上取得了显著突破。接下来我们还会继续在性能、新特性、成本等方面进行持续不断的打磨、改进,争取为公司内外用户提供更好的云MongoDB服务。

↓↓ 产品动态:【腾讯云官网】云数据库 TencentDB for MongoDB

0 人点赞