背景
啟動資料加載時間對于很多資料庫來說是一個不容忽視的因素,啟動加載慢直接導緻資料庫恢複正常服務的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啟動加載過程
MongoDB在啟動時,WiredTiger引擎層需要将所有集合/索引的中繼資料加載到記憶體中,而MongoDB的集合/索引實際上就是對應WiredTiger中的表,加載集合/索引就需要打開WiredTiger對應表的cursor。
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和WiredTiger資料組織方式介紹
為了能夠管理所有的集合、索引,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。
metadata cursor使用優化原理
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打開方式的差別:
- 打開metadata:create cursor
WT_CURSOR* c = NULL;
int ret = session->open_cursor(session, "metadata:create", NULL, NULL, &c);
- 打開metadata: cursor
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。接下來我們分析這背後的原因。
metadata cursor工作原理
metadata cursor的工作流程
以namesapce名稱為db2.col1的集合為例,它在WiredTiger中的表名是db2/collection-11--4499452254973778892,來看看對于這個集合,是如何通過metadata cursor擷取到實際的journal配置的,通過這個過程來說明metadata cursor的工作流程。下面具體來看:
-
使用file_cursor查找WiredTiger.wt表的btree結構,查找的cursor key是:
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
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:create cursor代價為什麼高
從上面的分析可以看出,對于metadata: cursor,隻有一次對WiredTiger.wt表的btree search過程。而對于metadata:create cursor,一次中繼資料配置查找其實會有三次對WiredTiger.wt表的btree進行search的過程,并且每次都是從btree的root節點去查找(因為每次要查找的中繼資料在btree結構中的存儲位置上互相是沒有關聯的),開銷較大。
啟動加載優化細節
優化1:擷取集合的存儲格式版本号
這裡最終目的就是要擷取集合中繼資料中"app_metadata=(formatVersion=1)"裡的formatVersion的版本号,從metadata cursor的工作流程可以看到,file_cursor第一次查找的結果裡已經包含了這個資訊。在優化前,這裡用的是metadata:create cursor,是不必要的,是以這裡改用一個metadata: cursor就可以了,每個集合的初始化就少了兩次『從WiredTiger.wt表對應的btree的root節點開始search』的過程。
優化2:擷取所有集合的資料檔案名稱
以db2.col1集合為例,查找的cursor key是:
擷取集合的資料檔案名稱,實際上就是要擷取元資訊裡的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,開銷大得多。
延遲打開cursor優化
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集合插入一條資料的記錄内容:
- 普通資料集合中連續插入一條{a:1}和{b:1}的文檔
record id:1, record value:{ _id: ObjectId('5e93f4f6c8165093164a940f'), a: 1.0 }
record id:2, record value:{ _id: ObjectId('5e93f78f015050efdb4107b4'), b: 1.0 }
- oplog中插入一條的記錄(向db1.col1這個集合插入一個{c:1}的新文檔觸發)
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。
優化後
**
{ "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,官方已經有計劃将啟動資料加載流程并行化,進一步優化啟動時間,我們後續也會持續關注。