MongoDB 存儲引擎 mongorocks 原理解析

mongorocks 是基於著名的開源KV資料庫RocksDB)實現的一個MongoDB存儲引擎,藉助rocksdb的優秀特性,mongorocks能很好的支持一些高並發隨機寫入、讀取的應用場景。

MongoDB 與 mongorocks 的關係

mongodb 支持多種引擎,目前官方已經支持了mmapv1、wiredtiger、in-Memory等,而mongorocks則是第三方實現的存儲引擎之一(對應上圖紅框的位置)。

MongoDB KV存儲引擎模型

MongoDB 從 3.0 版本 開始,引入了存儲引擎的概念,並開放了 StorageEngine 的API 介面,為了方便KV存儲引擎接入作為 MongoDB 的存儲引擎,MongoDB 又封裝出一個 KVEngine 的API介面,比如官方的 wiredtiger 存儲引擎就是實現了 KVEngine 的介面,本文介紹的 mongorocks 也是實現了KVEngine的介面。

KVEngine 主要需要支持如下介面

創建/刪除集合

MongoDB 使用 KVEngine 時,將所有集合的元數據會存儲到一個特殊的 _mdb_catalog 的集合里,創建、刪除集合時,其實就是往這個特殊集合里添加、刪除元數據。

_mdb_catalog 特殊的集合不需要支持索引,只需要能遍歷讀取集合數據即可,MongoDB在啟動時,會遍歷該集合,來載入所有集合的元數據信息。

數據存儲及索引

插入新文檔時,MongoDB 會調用底層KV引擎存儲文檔內容,並生成一個 RecordId 的作為文檔的位置信息標識,通過 RecordId 就能在底層KV引擎讀取到文檔的內容。

如果插入的集合包含索引(MongoDB的集合默認會有_id索引),針對每項索引,還會往底層KV引擎插入一個新的 key-value,key 是索引的欄位內容,value 為插入文檔時生成的 RecordId,這樣就能快速根據索引找到文檔的位置信息。

如上圖所示,集合包含{_id: 1}, {name: 1} 2個索引

  1. 用戶插入文檔時,底層引擎將文檔內容存儲,返回對應的位置信息,即 RecordId1
  2. 集合包含2個索引
  • 插入 {_id: ObjectId1} ? RecordId1 的索引
  • 插入 {name: 「rose」} ? RecordId1 的索引

有了上述的數據,在根據_id訪問時文檔時 (根據其他索引欄位類似)

  1. 根據文檔的 _id 欄位從底層KV引擎讀取 RecordId
  2. 根據 RecordId 從底層KV引擎讀取文檔內容

mongorock 存儲管理

mongorocks 存儲數據時,每個key都會包含一個32位整型前綴,實際存儲時將整型轉換為big endian格式存儲。

  • 所有的元數據的前綴都是0000
  • 每個集合、以及集合的每個索引都包含不同的前綴,集合及索引與前綴的關係存儲在 0000metadata-*為前綴的key里
  • _mdb_catalog 在mongorocks也是一個普通的集合,有單獨的前綴

創建集合、寫數據

  1. 創建集合或索引時,mongrocks會為其分配一個前綴,並將對應關係持久化,比如創建集合 bar(默認會創建_id欄位的索引),mongorocks 會給集合和索引各分配一個前綴,如上圖所示的 0002, 0003,並將對應關係持久化。
  2. 接下來往bar集合里寫的所有數據,都會帶上 0002 前綴;
  3. 往其_id索引里寫的數據都會帶上前綴 0003

寫索引時,有個比較有意思的設計,重點介紹下 (其他的key-value引擎,如wiredtiger也使用類似的機制)

MongoDB 支持複合索引,比如db.createIndex({a: 1, b, -1, c, 1}),這個索引要先按a欄位升序、a相同的按b欄位降序.. 依此類推,但KV引擎並沒有這麼強大的介面,如何實現對這種複合索引的支持呢?

MongoDB針對每個索引,會有一個點陣圖來描述索引各個欄位的排序方向,比如插入如下2條索引時 (key的部分會轉換為BSON格式插入到底層)

{a: 100, b: 200, c: 300} == > RecordId1{a: 100, b: 300, c: 400} ==> RecordId2

插入到底層 RocksDB,第1條記錄會排在第2條記錄前面,但我們建立的索引是 {a: 1, b, -1, c, 1},按這個索引,第2條記錄應該排在前面才對,否則索引順序就是錯誤的。

mongorocks 在存儲索引數據時,會根據索引的排序點陣圖,如果方向是逆序(如b: -1),會把key的內容里將b欄位對應的bit全部取反,這樣在 RocksDB 里第2條記錄就會排在第1條前面。

讀取數據

根據_id來查找集合數據時,其他訪問方式類似

  1. 根據集合的名字,在元數據里找到集合的前綴 0002 及其_id索引對應的前綴 0003
  2. 根據 0003 + 文檔id 生成文檔_id索引的key,並根據key讀取出文檔的RecordId
  3. 根據 0002 + RecordId 生成存儲文檔內容的key,並根據key讀取出文檔的內容

刪除集合

  1. 將集合的元數據從_mdb_catalog移除
  2. 將集合及其索引與前綴的對應關係都刪除掉
  3. 將第2步里刪除的前綴加入到待刪除列表,並通知 RocksDB 把該前綴開頭的所有key通過compact來刪除掉(通過定製CompactionFilter來實現,這個compact過程是非同步做的,所以集合刪了,會看到底層的數據量不會立馬降下來),同時持久化一條0000droppedprefix-被刪除前綴的記錄,這樣是防止compact被刪除前綴的過程中宕機,重啟後被刪除前綴的key不會被會收掉,直到待刪除前綴所有的key都被回收時,最終會把0000droppedprefix-被刪除前綴的記錄刪除掉。

文檔原子性

MongoDB 寫入文檔時,包含如下步驟

  1. 插入文檔到集合
  2. 更新集合所有的索引
  3. 記錄oplog(如果是複製集模式運行)

MongoDB 保證單文檔的原子性,上述3個步驟必須全部成功應用或者全部不應用,mongorocks 藉助 RocksDB 的 WriteBatch 介面來保證,將上述3個操作放到一個WriteBatch中,最後一次提交,RocksDB 層面會保證 WriteBatch 操作的原子性。

特殊的oplog

在MongoDB里,oplog是一個特殊的 capped collection(可以理解為環形存儲區域),超過配置的大小後,會將最老的數據刪除掉,如下是2個oplog的例子,mongorocks在存儲oplog時,會以oplog集合前綴 + oplog的ts欄位作為key來存儲,這樣在RocksDB,oplog的數據都是按ts欄位的順序來排序的。

0008:ts_to_uint64 ==> { "ts" : Timestamp(1481860966, 1), "t" : NumberLong(71), "h" : NumberLong("-6964295105894894386"), "v" : 2, "op" : "i", "ns" : "[test.tt](http://test.tt)", "o" : { "_id" : ObjectId("58536766d38c0573d2ff5b90"), "x" : 2000 } }0008:ts_to_uint64 ==> { "ts" : Timestamp(1481860960, 1), "t" : NumberLong(71), "h" : NumberLong("3883981042971627762"), "v" : 2, "op" : "i", "ns" : "[test.tt](http://test.tt)", "o" : { "_id" : ObjectId("58536760d38c0573d2ff5b8f"), "x" : 1000 } }

capped collection 當集合超出capped集合最大值時,就會逐個遍歷最先寫入的數據來刪除,直到空間降到閾值以下。

mongorocks 為了提升回收oplog的效率,做了一個小的優化。

針對oplog集合,插入的每一個文檔,除了插入數據本身,還會往一個特殊的集合(該集合的前綴為oplog集合的前綴加1)里插入一個相同的key,value為文檔大小。比如

0008:ts_to_uint64 ==> { "ts" : Timestamp(1481860966, 1), "t" : NumberLong(71), "h" : NumberLong("-6964295105894894386"), "v" : 2, "op" : "i", "ns" : "[test.tt](http://test.tt)", "o" : { "_id" : ObjectId("58536766d38c0573d2ff5b90"), "x" : 2000 } }0009:ts_to_uint64 ==> 88 (假設88為上面這個文檔的大小)

有了這個信息,在刪除oplog最老的數據時,就可以先遍歷包含oplog文檔大小信息的集合,獲取被刪除文檔的大小,而不用把整個oplog的key-value都讀取出來,然後統計大小。個人覺得這個優化當oplog文檔大小比較大效果會比較好,文檔小的時候並不一定能有效。

集合大小元數據管理

MongoDB 針對collection的count()介面,如果是全量的count,默認是O(1)的時間複雜度,但結果不保證準確。mongorocks 為了兼容該特性,也將每個集合的『大小及文檔數』也單獨的存儲起來。

比如集合foo的大小、文檔數分別對應2個key

0000datasize-foo ==> 14000 (0000是metadata的前綴)0000numrecords-foo ==> 100

上面2個key,當集合里有增刪改查時,默認並不是每次都更新,而是累計到一定的次數或大小時更新,後台也會周期性的去更新所有集合對應的這2個key。

mongorocks 也支持每次操作都將 datasize、numrecords 的更新進行持久化存儲,配置storage.rocksdb.crashSafeCounters參數為true即可,但這樣會對寫入的性能有影響。

數據備份

藉助 RocksDB 本身的特性,mongorocks能很方便的支持對數據進行物理備份,執行下面的命令,就會將產生一份快照數據,並將對應的數據集都軟鏈接到/var/lib/mongodb/backup/1下,直接拷貝該目錄備份即可。

db.adminCommand({setParameter:1, rocksdbBackup: "/var/lib/mongodb/backup/1"})

總結

總體來說,MongoDB 存儲引擎需要的功能,mongorocks 都實現了,但因為 RocksDB 本身的機制,還有一些缺陷,比如

  • 集合的數據刪除後,存儲空間並不是立即回收,RocksDB 要通過後台壓縮來逐步回收空間
  • mongorcks 對 oplog 空間的刪除機制是在用戶請求路徑里進行的,這樣可能導致寫入的延遲上升,應像 wiredtiger 這樣當 oplog 空間超出時,後台線程來回收。
  • RocksDB 缺乏批量日誌提交的機制,無法將多次並發的寫log進行合併,來提升效率。

推薦閱讀:

一個大型的SNS網站,是否適合資料庫全部用mongodb來做,為什麼?

TAG:MongoDB | NoSQL | RocksDB |