MongoDB4.0事務實現解析

張友東發表於2018-07-13

上個月底 MongoDB Wolrd 宣佈釋出 MongoDB 4.0, 支援複製集多文件事務,阿里雲資料庫團隊 研發工程師第一時間對事務功能的時間進行了原始碼分析,解析事務實現機制。

MongoDB 4.0 引入的事務功能,支援多文件ACID特性,例如使用 mongo shell 進行事務操作


> s = db.getMongo().startSession()
session { "id" : UUID("3bf55e90-5e88-44aa-a59e-a30f777f1d89") }
> s.startTransaction()
> session.getDatabase("mytest").coll01.insert({x: 1, y: 1})
WriteResult({ "nInserted" : 1 })
> session.getDatabase("mytest").coll02.insert({x: 1, y: 1})
WriteResult({ "nInserted" : 1 })
> s.commitTransaction()  (或者 s.abortTransaction()回滾事務)

支援 MongoDB 4.0 的其他語言 Driver 也封裝了事務相關介面,使用者需要建立一個 Session,然後在 Session 上開啟事務,提交事務。例如

python 版本

with client.start_session() as s:
    s.start_transaction()
    collection_one.insert_one(doc_one, session=s)
    collection_two.insert_one(doc_two, session=s)
    s.commit_transaction()

java 版本

try (ClientSession clientSession = client.startSession()) {
   clientSession.startTransaction();
   collection.insertOne(clientSession, docOne);
   collection.insertOne(clientSession, docTwo);
   clientSession.commitTransaction();
}

Session

Session 是 MongoDB 3.6 版本引入的概念,引入這個特性主要就是為實現多文件事務做準備。Session 本質上就是一個「上下文」。

在以前的版本,MongoDB 只管理單個操作的上下文,mongod 服務程式接收到一個請求,為該請求建立一個上下文 (原始碼裡對應 OperationContext),然後在服務整個請求的過程中一直使用這個上下文,內容包括,請求耗時統計、請求佔用的鎖資源、請求使用的儲存快照等資訊。有了 Session 之後,就可以讓多個請求共享一個上下文,讓多個請求產生關聯,從而有能力支援多文件事務。

每個 Session 包含一個唯一的標識 lsid,在 4.0 版本里,使用者的每個請求可以指定額外的擴充套件欄位,主要包括:

  • lsid: 請求所在 Session 的 ID, 也稱 logic session id
  • txnNmuber: 請求對應的事務號,事務號在一個 Session 內必須單調遞增
  • stmtIds: 對應請求裡每個操作(以insert為例,一個insert命令可以插入多個文件)操作ID

實際上,使用者在使用事務時,是不需要理解這些細節,MongoDB Driver 會自動處理,Driver 在建立 Session 時分配 lsid,接下來這個 Session 裡的所以操作,Driver 會自動為這些操作加上 lsid,如果是事務操作,會自動帶上 txnNumber。

值得一提的是,Session lsid 可以通過呼叫 startSession 命令讓 server 端分配,也可以客戶端自己分配,這樣可以節省一次網路開銷;而事務的標識,MongoDB 並沒有提供一個單獨的 startTransaction的命令,txnNumber 都是直接由 Driver 來分配的,Driver 只需保證一個 Session 內,txnNumber 是遞增的,server 端收到新的事務請求時,會主動的開始一個新事務。

MongoDB 在 startSession 時,可以指定一系列的選項,用於控制 Session 的訪問行為,主要包括:

ACID

Atomic

針對多文件的事務操作,MongoDB 提供 “All or nothing” 的原子語義保證。

Consistency

太難解釋了,還有拋棄 Consistency 特性的資料庫?

Isolation

MongoDB 提供 snapshot 隔離級別,在事務開始建立一個 WiredTiger snapshot,然後在整個事務過程中使用這個快照提供事務讀。

Durability

事務使用 WriteConcern {j: ture} 時,MongoDB 一定會保證事務日誌提交才返回,即使發生 crash,MongoDB 也能根據事務日誌來恢復;而如果沒有指定 {j: true} 級別,即使事務提交成功了,在 crash recovery 之後,事務的也可能被回滾掉。

事務與複製

複製集配置下,MongoDB 整個事務在提交時,會記錄一條 oplog(oplog 是一個普通的文件,所以目前版本里事務的修改加起來不能超過文件大小 16MB的限制),包含事務裡所有的操作,備節點拉取oplog,並在本地重放事務操作。

事務 oplog 示例,包含事務操作的 lsid,txnNumber,以及事務內所有的操作日誌(applyOps欄位)

“ts” : Timestamp(1530696933, 1), “t” : NumberLong(1), “h” : NumberLong(“4217817601701821530”), “v” : 2, “op” : “c”, “ns” : “admin.$cmd”, “wall” : ISODate(“2018-07-04T09:35:33.549Z”), “lsid” : { “id” : UUID(“e675c046-d70b-44c2-ad8d-3f34f2019a7e”), “uid” : BinData(0,”47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=”) }, “txnNumber” : NumberLong(0), “stmtId” : 0, “prevOpTime” : { “ts” : Timestamp(0, 0), “t” : NumberLong(-1) }, “o” : { “applyOps” : [ { “op” : “i”, “ns” : “test.coll2”, “ui” : UUID(“a49ccd80-6cfc-4896-9740-c5bff41e7cce”), “o” : { “_id” : ObjectId(“5b3c94d4624d615ede6097ae”), “x” : 20000 } }, { “op” : “i”, “ns” : “test.coll3”, “ui” : UUID(“31d7ae62-fe78-44f5-ba06-595ae3b871fc”), “o” : { “_id” : ObjectId(“5b3c94d9624d615ede6097af”), “x” : 20000 } } ] } }

整個重放過程如下:

  1. 獲取當前 Batch (後臺不斷拉取 oplog 放入 Batch)
  2. 設定 OplogTruncateAfterPoint 時間戳為 Batch裡第一條 oplog 時間戳 (儲存在 local.replset.oplogTruncateAfterPoint 集合)
  3. 寫入 Batch 裡所有的 oplog 到 local.oplog.rs 集合,根據 oplog 條數,如果數量較多,會併發寫入加速
  4. 清理 OplogTruncateAfterPoint, 標識 oplog 完全成功寫入;如果在本步驟完成前 crash,重啟恢復時,發現 oplogTruncateAfterPoint 被設定,會將 oplog 截短到該時間戳,以恢復到一致的狀態點。
  5. 將 oplog 劃分到到多個執行緒併發重放,為了提升併發效率,事務產生的 oplog 包含的所有修改操作,跟一條普通單條操作的 oplog 一樣,會據文件ID劃分到多個執行緒。
  6. 更新 ApplyThrough 時間戳為 Batch 裡最後一條 oplog 時間戳,標識下一次重啟後,從該位置重新同步,如果本步驟之前失敗,重啟恢復時,會從 ApplyThrough 上一次的值(上一個 Batch 最後一條 oplog)拉取 oplog。
  7. 更新 oplog 可見時間戳,如果有其他節點從該備節點同步,此時就能讀到這部分新寫入的 oplog
  8. 更新本地 Snapshot(時間戳),新的寫入將對使用者可見。

事務與儲存引擎

事務時序統一

WiredTiger 很早就支援事務,在 3.x 版本里,MongoDB 就通過 WiredTiger 事務,來保證一條修改操作,對資料、索引、oplog 三者修改的原子性。但實際上 MongoDB 經過多個版本的迭代,才提供了事務介面,核心難點就是時序問題。

MongoDB 通過 oplog 時間戳來標識全域性順序,而 WiredTiger 通過內部的事務ID來標識全域性順序,在實現上,2者沒有任何關聯。這就導致在併發情況下, MongoDB 看到的事務提交順序與 WiredTiger 看到的事務提交順序不一致。

為解決這個問題,WiredTier 3.0 引入事務時間戳(transaction timestamp)機制,應用程式可以通過 WT_SESSION::timestamp_transaction 介面顯式的給 WiredTiger 事務分配 commit timestmap,然後就可以實現指定時間戳讀(read "as of" a timestamp)。有了 read "as of" a timestamp 特性後,在重放 oplog 時,備節點上的讀就不會再跟重放 oplog 有衝突了,不會因重放 oplog 而阻塞讀請求,這是4.0版本一個巨大的提升。

/*
 * __wt_txn_visible --
 *  Can the current transaction see the given ID / timestamp?
 */
static inline bool
__wt_txn_visible(
    WT_SESSION_IMPL *session, uint64_t id, const wt_timestamp_t *timestamp)
{
    if (!__txn_visible_id(session, id))
        return (false);

    /* Transactions read their writes, regardless of timestamps. */
    if (F_ISSET(&session->txn, WT_TXN_HAS_ID) && id == session->txn.id)
        return (true);

#ifdef HAVE_TIMESTAMPS
    {
    WT_TXN *txn = &session->txn;

    /* Timestamp check. */
    if (!F_ISSET(txn, WT_TXN_HAS_TS_READ) || timestamp == NULL)
        return (true);

    return (__wt_timestamp_cmp(timestamp, &txn->read_timestamp) <= 0);
    }
#else
    WT_UNUSED(timestamp);
    return (true);
#endif
}

從上面的程式碼可以看到,再引入事務時間戳之後,在可見性判斷時,還會額外檢查時間戳,上層讀取時指定了時間戳讀,則只能看到該時間戳以前的資料。而 MongoDB 在提交事務時,會將 oplog 時間戳跟事務關聯,從而達到 MongoDB Server 層時序與 WiredTiger 層時序一致的目的。

事務對 cache 的影響

WiredTiger(WT) 事務會開啟一個快照,而快照的存在的 WiredTiger cache evict 是有影響的。一個 WT page 上,有N個版本的修改,如果這些修改沒有全域性可見(參考 __wt_txn_visible_all),這個 page 是不能 evict 的(參考 __wt_page_can_evict)。

在 3.x 版本里,一個寫請求對資料、索引、oplog的修改會放到一個 WT 事務裡,事務的提交由 MongoDB 自己控制,MongoDB 會盡可能快的提交事務,完成寫清求;但 4.0 引入事務之後,事務的提交由應用程式控制,可能出現一個事務修改很多,並且很長時間不提交,這會給 WT cache evict 造成很大的影響,如果大量記憶體無法 evict,最終就會進入 cache stuck 狀態。

為了儘量減小 WT cache 壓力,MongoDB 4.0 事務功能有一些限制,但事務資源佔用超過一定閾值時,會自動 abort 來釋放資源。規則包括

  1. 事務的生命週期不能超過 transactionLifetimeLimitSeconds (預設60s),該配置可線上修改
  2. 事務修改的文件數不能超過 1000 ,不可修改
  3. 事務修改產生的 oplog 不能超過 16mb,這個主要是 MongoDB 文件大小的限制, oplog 也是一個普通的文件,也必須遵守這個約束。

Read as of a timestamp 與 oldest timestamp

Read as of a timestamp 依賴 WiredTiger 在記憶體裡維護多版本,每個版本跟一個時間戳關聯,只要 MongoDB 層可能需要讀的版本,引擎層就必須維護這個版本的資源,如果保留的版本太多,也會對 WT cache 產生很大的壓力。

WiredTiger 提供設定 oldest timestamp 的功能,允許由 MongoDB 來設定該時間戳,含義是Read as of a timestamp 不會提供更小的時間戳來進行一致性讀,也就是說,WiredTiger 無需維護 oldest timestamp 之前的所有歷史版本。MongoDB 層需要頻繁(及時)更新 oldest timestamp,避免讓 WT cache 壓力太大。

引擎層 Rollback 與 stable timestamp

在 3.x 版本里,MongoDB 複製集的回滾動作是在 Server 層面完成,但節點需要回滾時,會根據要回滾的 oplog 不斷應用相反的操作,或從回滾源上讀取最新的版本,整個回滾操作效率很低。

4.0 版本實現了儲存引擎層的回滾機制,當複製集節點需要回滾時,直接呼叫 WiredTiger 介面,將資料回滾到某個穩定版本(實際上就是一個 Checkpoint),這個穩定版本則依賴於 stable timestamp。WiredTiger 會確保 stable timestamp 之後的資料不會寫到 Checkpoint裡,MongoDB 根據複製集的同步狀態,當資料已經同步到大多數節點時(Majority commited),會更新 stable timestamp,因為這些資料已經提交到大多數節點了,一定不會發生 ROLLBACK,這個時間戳之前的資料就都可以寫到 Checkpoint 裡了。

MongoDB 需要確保頻繁(及時)的更新 stable timestamp,否則影響 WT Checkpoint 行為,導致很多記憶體無法釋放。

分散式事務

MongoDB 4.0 支援副本集多文件事務,並計劃在 4.2 版本支援分片叢集事務功能。下圖是從 MongoDB 3.0 引入 WiredTiger 到 4.0 支援多文件事務的功能迭代圖,可以發現一盤大棋即將上線,敬請期待。


相關文章