Author: 李竟成(花名:腾峰)
启动数据加载时间对于很多数据库来说是一个不容忽视的因素,启动加载慢直接导致数据库恢复正常服务的RTO时间变长,影响服务可用性。比如Redis,启动时要加载RDB和AOF文件,把所有数据加载到内存中,根据节点内存数据量的不同,加载时间可能达到几十分钟甚至更长。MongoDB在启动时同样需要加载一些元数据,结合阿里云MongoDB云上运维的经验,在集合数量不多时,这个加载时间不会很长,但是对于大量集合场景、特别是MongoDB进程资源受限的情况下(比如虚机、容器、cgroup隔离场景),这个加载时间就变得无法预测,有可能会遇到节点本身内存小无法完成加载或者进程OOM的情况。经测试,在MongoDB 4.2.0之前(包括)的版本,加载10W集合耗时达到10分钟以上。MongoDB 在最新开发版本里针对这个问题进行了优化,尤其是对于大量集合场景,效果非常明显。在完全相同的测试条件下,该优化使得启动加载10W集合的时间由10分钟降低到2分钟,并且启动后初始内存占用降低为之前的四分之一。这个优化目前已经backport到4.2和4.0最新版本,阿里云MongoDB 4.2也已支持。
鉴于该优化带来的效果和好处明显,有必要对其背后的技术原理和细节进行深入的探究和学习,本文主要基于MongoDB 4.2社区版优化前后的版本进行对比分析,对MongoDB的启动加载过程、具体优化点、优化原理进行阐述,希望和对MongoDB内部实现有兴趣的同学一起探讨和学习。
MongoDB在启动时,WiredTiger引擎层需要将所有集合/索引的元数据加载到内存中,而MongoDB的集合/索引实际上就是对应WiredTiger中的表,加载集合/索引就需要打开WiredTiger对应表的cursor。
WiredTiger是MongoDB的默认存储引擎,负责管理和存储MongoDB的各种数据,WiredTiger支持多种数据源(data sources),包括表、索引、列组(column groups)、LSM Tree、状态统计等,此外,还支持用户通过实现WiredTiger定义好的接口来扩展自定义的数据源。
WiredTiger对各个数据源中数据的访问和管理是由对应的cursor来提供的,WiredTiger内部提供了用于数据访问和管理的基本cursor类型(包括table cursor、column group cursor、index cursor、join cursor)、以及一些专用cursor(包括metadata cursor、backup cursor、事务日志cursor、以及用于状态统计的cursor),专用cursor可以访问由WiredTiger管理的数据,用于完成某些管理类任务。另外,WiredTiger还提供了两种底层cursor类型:file cursor和LSM cursor。
WiredTiger cursor的通用实现通常会包含:暂存数据源中数据存储位置的变量,对数据进行迭代遍历或查找的方法,对key、value各字段进行设置的getter/setters,对字段进行编码的方法(便于存储到对应数据源)。
为了能够管理所有的集合、索引,MongoDB将集合的Catalog信息(包括对应到WiredTiger中的表名、集合创建选项、集合索引信息等)组织存放在一个_mdb_catalog的WiredTiger表中(对应到一个_mdb_catalog.wt的物理文件)。因此,这个_mdb_catalog表可以认为是MongoDB的『元数据表』,普通的集合都是『数据表』。MongoDB在启动时需要先从WiredTiger中加载这个元数据表的信息,然后才能加载出其他的数据表的信息。 同样,在WiredTiger层,每张表也有一些元数据需要维护,这包括表创建时的相关配置,checkpoint信息等。这也是使用『元数据表』和『数据表』的管理组织方式。在WiredTiger中,所有『数据表』的元数据都会存放在一个WiredTiger.wt的表中,这个表可以认为是WiredTiger的『元数据表』。而这个WiredTiger.wt表本身的元数据,则是存放在一个WiredTiger.turtle的文本文件中。在WiredTiger启动时,会先从WiredTiger.turtle文件中加载出WiredTiger.wt表的数据,然后就能加载出其他的数据表了。
再回到_mdb_catalog表,虽然对MongoDB来说,它是一张『元数据表』,但是在WiredTiger看来,它只是一张普通的数据表,因此启动时候,需要先等WiredTiger加载完WiredTiger.wt表后,从这个表中找到它的元数据。根据_mdb_catalog表的元数据可以对这个表做对应的初始化,并遍历出MongodB的所有数据表(集合)的Catalog信息元数据,对它们做进一步的初始化。
在上述这个过程中,对WiredTiger中的表做初始化,涉及到几个步骤,包括: 1)检查表的存储格式版本是否和当前数据库版本兼容 2)确定该表是否需要开启journal,这是在该表创建时的配置中指定的 这两个步骤都需要从WiredTiger.wt表中读取该表的元数据进行判断。
此外,结合目前的已知信息,我们可以看到,对MongoDB层可见的所有数据表,在_mdb_catalog表中维护了MongoDB需要的元数据,同样在WiredTiger层中,会有一份对应的WiredTiger需要的元数据维护在WiredTiger.wt表中。因此,事实上这里有两份数据表的列表,并且在某些情况下可能会存在不一致,比如,异常宕机的场景。因此MongoDB在启动过程中,会对这两份数据进行一致性检查,如果是异常宕机启动过程,会以WiredTiger.wt表中的数据为准,对_mdb_catalog表中的记录进行修正。这个过程会需要遍历WiredTiger.wt表得到所有数据表的列表。
综上,可以看到,在MongoDB启动过程中,有多处涉及到需要从WiredTiger.wt表中读取数据表的元数据。对这种需求,WiredTiger专门提供了一类特殊的『metadata』类型的cursor。
WiredTiger的metadata cursor是WiredTiger用于读取WiredTiger.wt表(元数据表)的cursor,它底层封装了用于查找WiredTiger.wt对应的内存btree结构的file cursor。File cursor实际上就是用于查找数据文件对应的btree结构的一种cursor,读取索引和集合数据文件也都是通过file cursor。
WiredTiger通过cursor的URI前缀来识别cursor的类型,对于metadata cursor类型,它的前缀是『metadata:』。根据cursor打开方式的不同,metadata cursor又可以分为metadata: cursor和metadata:create cursor两种。看下这两种cursor打开方式的区别:
WT_CURSOR* c = NULL;
int ret = session->open_cursor(session, "metadata:create", NULL, NULL, &c);
WT_CURSOR* c = nullptr;
int ret = session->open_cursor(session, "metadata:", nullptr, nullptr, &c);
实际上,WiredTiger在打开metadata: cursor时,默认只需要打开一个读取WiredTiger.wt表的file cursor(源码里命名是file_cursor),对于metadata:create cursor,还需要再打开另一个读取WiredTiger.wt表的file cursor(源码里命名是create_cursor),虽然只多了一个cursor,但是metadata:create cursor的使用代价却比metadata: cursor高得多。从启动加载过程可以看到,主要有三处使用metadata cursor的地方,而MongoDB启动加载优化中一个主要的优化点,就是把前面两处使用『metadata:create』 cursor的地方改成了『metadata:』 cursor。接下来我们分析这背后的原因。
以namesapce名称为db2.col1的集合为例,它在WiredTiger中的表名是db2/collection-11–4499452254973778892,来看看对于这个集合,是如何通过metadata cursor获取到实际的journal配置的,通过这个过程来说明metadata cursor的工作流程。下面具体来看:
table:db2/collection-11–4499452254973778892 获取到的元信息value: app_metadata=(formatVersion=1),colgroups=,collator=,columns=,key_format=q,value_format=u
其实到这里,metadata:create cursor和metadata: cursor做的事情是一样的,只不过对于metadata: cursor,则到这就结束了。如果是metadata:create cursor,接下来还需要通过create_cursor获取集合的creationString;因为这里要获取creationString中的journal配置,所以必须用metadata:create cursor。
对于metadata:create cursor,使用create_cursor继续查找creationString配置。获取到creationString后,就可以从中拿到实际的journal配置了。create_cursor实际上有两次对WiredTiger.wt表的btree结构进行search的过程:
1)第一次search是为了从WiredTiger.wt表中拿到集合对应的source文件名,查找的cursor key是: colgroup:db2/collection-11–4499452254973778892
获取到的元信息value: app_metadata=(formatVersion=1),collator=,columns=,source=”file:db2/collection-11–4499452254973778892.wt”,type=file
2)第二次search是通过上一步获取到的表元信息中的数据文件名称,继续从WiredTiger.wt表中拿到该表的creationString信息。 cursor key: file:db2/collection-11–4499452254973778892.wt
获取到的元信息value:
access_pattern_hint=none,allocation_size=4KB,app_metadata=(formatVersion=1),assert=(commit_timestamp=none,durable_timestamp=none,read_timestamp=none),block_allocation=best,block_compressor=snappy,cache_resident=false,checksum=on,colgroups=,collator=,columns=,dictionary=0,encryption=(keyid=,name=),exclusive=false,extractor=,format=btree,huffman_key=,huffman_value=,ignore_in_memory_cache_size=false,immutable=false,internal_item_max=0,internal_key_max=0,internal_key_truncate=true,internal_page_max=4KB,key_format=q,key_gap=10,leaf_item_max=0,leaf_key_max=0,leaf_page_max=32KB,leaf_value_max=64MB,log=(enabled=true),lsm=(auto_throttle=true,bloom=true,bloom_bit_count=16,bloom_config=,bloom_hash_count=8,bloom_oldest=false,chunk_count_limit=0,chunk_max=5GB,chunk_size=10MB,merge_custom=(prefix=,start_generation=0,suffix=),merge_max=15,merge_min=0),memory_page_image_max=0,memory_page_max=10m,os_cache_dirty_max=0,os_cache_max=0,prefix_compression=false,prefix_compression_min=4,source="file:db2/collection-11--4499452254973778892.wt"
可以看到这实际上就是wiredTiger在创建表时的schema元信息,可以通过db.collection.stats()命令输出的wiredTiger.creationString字段来查看。获取到creationString信息,就可以从中解析出log=(enabled=true)这个配置了。
从上面的分析可以看出,对于metadata: cursor,只有一次对WiredTiger.wt表的btree search过程。而对于metadata:create cursor,一次元数据配置查找其实会有三次对WiredTiger.wt表的btree进行search的过程,并且每次都是从btree的root节点去查找(因为每次要查找的元数据在btree结构中的存储位置上互相是没有关联的),开销较大。**
这里最终目的就是要获取集合元数据中”app_metadata=(formatVersion=1)”里的formatVersion的版本号,从metadata cursor的工作流程可以看到,file_cursor第一次查找的结果里已经包含了这个信息。在优化前,这里用的是metadata:create cursor,是不必要的,所以这里改用一个metadata: cursor就可以了,每个集合的初始化就少了两次『从WiredTiger.wt表对应的btree的root节点开始search』的过程。
以db2.col1集合为例,查找的cursor key是: colgroup:db2/collection-11–4499452254973778892
获取到的元信息value: app_metadata=(formatVersion=1),collator=,columns=,source=”file:db2/collection-11–4499452254973778892.wt”,type=file
获取集合的数据文件名称,实际上就是要获取元信息里的source=”file:db2/collection-11–4499452254973778892.wt”这个配置。优化后,这里改成了metadata: cursor,只要一次file cursor的next调用就好,并且下个集合在获取数据文件名时cursor已经是就位(positioned)的。在优化前,这里用的是metadata:create cursor,多了两次file cursor的search调用过程,并且每次都是从WiredTiger.wt表对应的btree的root节点开始search,开销大得多。
MongoDB最新版本中,还有一个针对大量集合/索引场景的特定优化,那就是『延迟打开Cursor』。在优化前,MongoDB在启动时,需要为每个集合都打开对应的WiredTiger表的cursor,这是为了获取NextRecordId。这是干什么的呢?先要说一下RecordId。
我们知道,MongoDB用的是WiredTiger的key-value行存储模式,一个MongoDB中的文档会对应到WiredTiger中的一条KV记录,记录的key被称为RecordId,记录的value就是文档内容。WiredTiger在查找、更新、删除MongoDB文档时都是通过这个RecordId去找到对应文档的。
对于普通数据集合,RecordId就是一个64位自增数字。而对于oplog集合,MongoDB按照时间戳+自增数字生成一个64位的RecordId,高32位代表时间戳,低32位是一个连续增加的数字(时间戳相同情况下)。
比如,下面是针对普通数据集合和oplog集合插入一条数据的记录内容:
record id:1, record value:{ _id: ObjectId('5e93f4f6c8165093164a940f'), a: 1.0 }
record id:2, record value:{ _id: ObjectId('5e93f78f015050efdb4107b4'), b: 1.0 }
record id:6815068270647836673,
record value:{ ts: Timestamp(1586756732, 1), t: 24, h: 0,
v: 2, op: "i", ns: "db1.col1", ui: UUID("ae7cfb6f-8072-4475-b33a-78b88ab72c6c"), wall: new Date(1586756748564), o: { _id: ObjectId('5e93fc7c7dc2edf0b11837ad')
, c: 1.0 } }
注:6815068270647836673实际上就是1586756732 << 32 + 1
MongoDB在内存中为每个集合都维护了一个NextRecordId变量,用来在下次插入新的文档时分配RecordId。因此这里在启动时为每个集合都都打开对应的WiredTiger表的cursor,并通过反向遍历到第一个key(也就是最大的一个key),并对其值加一,来得到这个NextRecordId。
而在MongoDB最新版本中,MongoDB把启动时为每个集合获取NextRecordId这个动作给推迟到了该集合第一次插入新文档时才进行,这在集合数量很多的时候就减少了许多开销,不光能提升启动速度,还能减少内存占用。
下面我们通过测试来看下实际优化效果如何。
事先准备好测试数据,写入10W集合,每个集合包含一个{“a”:”b”}的文档。 然后分别以优化前后的版本(完全相同的配置下)来启动加载准备好的数据,对比启动加载时间和初始内存占用情况。
启动日志:
加载完的日志:
启动后初始内存占用:
db.serverStatus().mem { “bits” : 64, “resident” : 4863, “virtual” : 6298, “supported” : true }
可以看到优化前版本启动加载10W集合的时间约为 10分钟 左右,启动后初始内存(常驻)占用为4863M。
启动日志:
加载完的日志:
启动后初始内存占用:
db.serverStatus().mem { “bits” : 64, “resident” : 1181, “virtual” : 2648, “supported” : true }
可以看到优化后版本启动加载10W集合的时间约为 2分钟 左右。启动后初始内存(常驻)占用为1181M。
在同样的测试条件下,优化后版本启动加载时间约为优化前的1/5,优化后版本启动后初始内存占用约为优化前的1/4。
最后,我们来简要总结下MongoDB最新版本对启动加载的优化内容: 1)优化启动时集合加载打开cursor的次数,用metadata:类型cursor替代不必要的metadata:create cursor(代价比较高),将metadata:create cursor的调用次数由每个表3次降到1次。 2)采用『延迟打开cursor』机制,启动时不再为所有集合都打开cursor,将打开cursor的动作延后进行。
可以看到,这个优化本身并没有对底层WiredTiger引擎实现有任何改动,对于上层MongoDB的改动也不大,而是通过深挖底层存储引擎WiredTiger cursor使用上的细节,找到了关键因素,最终取得了非常显著的效果,充分证明了“细节决定成败”这个真理,很值得学习。
尽管已经取得了如此大的优化效果,事实上MongoDB启动加载还有进一步的优化空间,由于启动数据加载目前还是单线程,瓶颈主要在CPU,官方已经有计划将启动数据加载流程并行化,进一步优化启动时间,我们后续也会持续关注。