1. 程式人生 > >解密未來資料庫設計:MongoDB新儲存引擎WiredTiger實現(事務篇)

解密未來資料庫設計:MongoDB新儲存引擎WiredTiger實現(事務篇)

導語:計算機硬體在飛速發展,資料規模在急速膨脹,但是資料庫仍然使用是十年以前的架構體系,WiredTiger 嘗試打破這一切,充分利用多核與大記憶體時代,開發一種真正滿足未來大資料管理所需的資料庫。本文由袁榮喜向「高可用架構」投稿,介紹對 WiredTiger 原始碼學習過程中對資料庫設計的感悟。

袁榮喜,學霸君工程師,2015年加入學霸君,負責學霸君的網路實時傳輸和分散式系統的架構設計和實現,專注於基礎技術領域,在網路傳輸、資料庫核心、分散式系統和併發程式設計方面有一定了解。

WiredTiger 從被 MongoDB 收購到成為 MongoDB 的預設儲存引擎的一年半,得到了迅猛的發展,也逐步被外部熟知。

資料庫

現代計算機近 20 年來 CPU 的計算能力和記憶體容量飛速發展,但磁碟的訪問速度並沒有得到相應的提高,WT 就是在這樣的一個情況下研發出來,它設計了充分利用 CPU 平行計算的記憶體模型的無鎖並行框架,使得 WT 引擎在多核 CPU 上的表現優於其他儲存引擎。

未來資料庫

針對磁碟儲存特性,WT 實現了一套基於 BLOCK/Extent 的友好的磁碟訪問演算法,使得 WT 在資料壓縮和磁碟 I/O 訪問上優勢明顯。實現了基於 snapshot 技術的 ACID 事務,snapshot 技術大大簡化了 WT 的事務模型,摒棄了傳統的事務鎖隔離又同時能保證事務的 ACID。WT 根據現代記憶體容量特性實現了一種基於 Hazard Pointer 的 LRU cache 模型,充分利用了記憶體容量的同時又能擁有很高的事務讀寫併發。

在本文中,我們主要針對 WT 引擎的事務來展開分析,來看看它的事務是如何實現的。說到資料庫事務,必然先要對事務這個概念和 ACID 簡單的介紹。

基本概念:事務與 ACID

什麼是事務?

事務就是通過一系列操作來完成一件事情,在進行這些操作的過程中,要麼這些操作完全執行,要麼這些操作全不執行,不存在中間狀態,事務分為事務執行階段和事務提交階段。一般說到事務,就會想到它的特性— ACID,那麼什麼是 ACID 呢?我們先用一個現實中的例子來說明:AB 兩同學賬號都有 1,000 塊錢,A 通過銀行轉賬向 B 轉了 100,這個事務分為兩個操作,即從 A 同學賬號扣除 100,向 B 同學賬號增加 100。

原子性(Atomicity)

組成事務的系列操作是一個整體,要麼全執行,要麼不執行。通過上面例子就是從 A 同學扣除錢和向 B 同學增加 100 是一起發生的,不可能出現扣除了 A 的錢,但沒增加 B 的錢的情況。

一致性(Consistency):

在事務開始之前和事務結束以後,資料庫的完整性和狀態沒有被破壞。這個怎麼理解呢?就是 A、B 兩人在轉賬錢的總和是 2,000,轉賬後兩人的總和也必須是 2,000。不會因為這次轉賬事務破壞這個狀態。

隔離性(Isolation):

多個事務在併發執行時,事務執行的中間狀態是其他事務不可訪問的。A 轉出 100 但事務沒有確認提交,這時候銀行人員對其賬號查詢時,看到的應該還是 1,000,不是 900。

永續性(Durability)

事務一旦提交生效,其結果將永久儲存,不受任何故障影響。A 轉賬一但完成,那麼 A 就是 900,B 就是 1,100,這個結果將永遠儲存在銀行的資料庫中,直到他們下次交易事務的發生。

WT 如何實現事務

知道了基本的事務概念和 ACID 後,來看看 WT 引擎是怎麼來實現事務和 ACID。要了解實現先要知道它的事務的構造和使用相關的技術,WT 在實現事務的時使用主要是使用了三個技術:

  • snapshot(事務快照)
  • MVCC(多版本併發控制)
  • redo log(重做日誌)

為了實現這三個技術,它還定義了一個基於這三個技術的事務物件和全域性事務管理器。事務物件描述如下

wt_transaction{

transaction_id:    本次事務的全域性唯一的ID,用於標示事務修改資料的版本號

snapshot_object:   當前事務開始或者操作時刻其他正在執行且並未提交的事務集合,用於事務隔離

operation_array:   本次事務中已執行的操作列表,用於事務回滾。

redo_log_buf:      操作日誌緩衝區。用於事務提交後的持久化

State:             事務當前狀態

}

WT 的多版本併發控制

WT 中的 MVCC 是基於 key/value 中 value 值的連結串列,這個連結串列單元中儲存有當先版本操作的事務 ID 和操作修改後的值。描述如下:

wt_mvcc{

transaction_id:    本次修改事務的ID

value:             本次修改後的值

}

資料庫

WT 中的資料修改都是在這個連結串列中進行 append 操作,每次對值做修改都是 append 到連結串列頭上,每次讀取值的時候讀是從連結串列頭根據值對應的修改事務 transaction_id 和本次讀事務的 snapshot 來判斷是否可讀,如果不可讀,向連結串列尾方向移動,直到找到讀事務能都的資料版本。樣例如下:

MongoDB新儲存引擎WiredTiger實現

圖1,點選圖片可以全屏縮放

上圖中,事務 T0 發生的時刻最早,T5 發生的時刻最晚。T1/T2/T4 是對記錄做了修改。那麼在 MVCC list 當中就會增加 3 個版本的資料,分別是 11/12/14。如果事務都是基於 snapshot 級別的隔離,T0 只能看到 T0 之前提交的值 10,讀事務 T3 訪問記錄時它能看到的值是 11,T5 讀事務在訪問記錄時,由於 T4 未提交,它也只能看到 11 這個版本的值。這就是 WT 的 MVCC 基本原理。

WT 事務 snapshot

上面多次提及事務的 snapshot,那到底什麼是事務的 snapshot 呢?其實就是事務開始或者進行操作之前對整個 WT 引擎內部正在執行或者將要執行的事務進行一次快照,儲存當時整個引擎所有事務的狀態,確定哪些事務是對自己見的,哪些事務都自己是不可見。說白了就是一些列事務 ID 區間。WT 引擎整個事務併發區間示意圖如下:

WT 事務 snapshot

圖2,點選圖片可以全屏縮放

WT 引擎中的 snapshot_oject 是有一個最小執行事務 snap_min、一個最大事務 snap max 和一個處於 [snap_min, snap_max] 區間之中所有正在執行的寫事務序列組成。如果上圖在 T6 時刻對系統中的事務做一次 snapshot,那麼產生的

snapshot_object = {

snap_min=T1,

snap_max=T5,

snap_array={T1, T4, T5},

};

T6 能訪問的事務修改有兩個區間:所有小於 T1 事務的修改 [0, T1) 和 [snap_min, snap_max]  區間已經提交的事務 T2 的修改。換句話說,凡是出現在 snap_array 中或者事務 ID 大於 snap_max 的事務的修改對事務 T6 是不可見的。如果 T1 在建立 snapshot 之後提交了,T6 也是不能訪問到 T1 的修改。這個就是 snapshot 方式隔離的基本原理。

全域性事務管理器

通過上面的 snapshot 的描述,我們可以知道要建立整個系統事務的快照截圖,就需要一個全域性的事務管理來進行事務快照時的參考,在 WT 引擎中是如何定義這個全域性事務管理器的呢?在 CPU 多核多執行緒下,它是如何來管理事務併發的呢?下面先來分析它的定義:

wt_txn_global{

current_id:       全域性寫事務ID產生種子,一直遞增

oldest_id:        系統中最早產生且還在執行的寫事務ID

transaction_array: 系統事務物件陣列,儲存系統中所有的事務物件

scan_count:  正在掃描transaction_array陣列的執行緒事務數,用於建立snapshot過程的無鎖併發

}

transaction_array 儲存的是圖 2 正在執行事務的區間的事務物件序列。在建立 snapshot 時,會對整個 transaction_array 做掃描,確定 snap_min/snap_max/snap_array 這三個引數和更新 oldest_id,在掃描的過程中,凡是 transaction_id 不等於 WT_TNX_NONE 都認為是在執行中且有修改操作的事務,直接加入到 snap_array 當中。整個過程是一個無鎖操作過程,這個過程如下:

全域性事務管理器

圖3,點選圖片可以全屏縮放

建立 snapshot 快照的過程在 WT 引擎內部是非常頻繁,尤其是在大量自動提交型的短事務執行的情況下,由建立 snapshot 動作引起的 CPU 競爭是非常大的開銷,所以這裡 WT 並沒有使用 spin lock,而是採用了上圖的一個無鎖併發設計,這種設計遵循了我們開始說的併發設計原則。

事務 ID

從 WT 引擎建立事務 snapshot 的過程中,現在可以確定,snapshot 的物件是有寫操作的事務,純讀事務是不會被 snapshot 的,因為 snapshot 的目的是隔離 MVCC list 中的記錄,通過 MVCC 中 value 的事務 ID 與讀事務的 snapshot 進行版本讀取,與讀事務本身的 ID 是沒有關係。

在 WT 引擎中,開啟事務時,引擎會將一個 WT_TNX_NONE(= 0) 的事務 ID 設定給開啟的事務,當它第一次對事務進行寫時,會在資料修改前通過全域性事務管理器中的 current_id 來分配一個全域性唯一的事務 ID。這個過程也是通過 CPU 的 CAS_ADD 原子操作完成的無鎖過程。

WT 的事務過程

一般事務是兩個階段:事務執行和事務提交。在事務執行前,我們需要先建立事務物件並開啟它,然後才開始執行,如果執行遇到衝突和或者執行失敗,我們需要回滾事務(rollback)。如果執行都正常完成,最後只需要提交(commit)它即可。

從上面的描述可以知道事務過程有:建立開啟、執行、提交和回滾。從這幾個過程中來分析 WT 是怎麼實現這幾個過程的。

事務開啟

WT 事務開啟過程中,首先會為事務建立一個事務物件並把這個物件加入到全域性事務管理器當中,然後通過事務配置資訊確定事務的隔離級別和 redo log 的刷盤方式並將事務狀態設為執行狀態,最後判斷如果隔離級別是 ISOLATION_SNAPSHOT(snapshot 級的隔離),在本次事務執行前建立一個系統併發事務的 snapshot。至於為什麼要在事務執行前建立一個 snapshot,在後面 WT 事務隔離章節詳細介紹。

事務執行

事務在執行階段,如果是讀操作,不做任何記錄,因為讀操作不需要回滾和提交。如果是寫操作,WT 會對每個寫操作做詳細的記錄。在上面介紹的事務物件(wt_transaction)中有兩個成員,一個是操作 operation_array,一個是 redo_log_buf。這兩個成員是來記錄修改操作的詳細資訊,在 operation_array 的陣列單元中,包含了一個指向 MVCC list 對應修改版本值的指標。詳細的更新操作流程如下:

  1. 建立一個 MVCC list 中的值單元物件(update)
  2. 根據事務物件的 transaction id 和事務狀態判斷是否為本次事務建立了寫的事務 ID,如果沒有,為本次事務分配一個事務 ID,並將事務狀態設成 HAS_TXN_ID 狀態。
  3. 將本次事務的 ID 設定到 update 單元中作為 MVCC 版本號。
  4. 建立一個 operation 物件,並將這個物件的值指標指向 update,並將這個 operation 加入到本次事務物件的 operation_array。
  5. 將 update 單元加入到 MVCC list 的連結串列頭上。
  6. 寫入一條 redo log 到本次事務物件的 redo_log_buf 當中。

示意圖如下:

資料庫

事務提交

WT 引擎對事務的提交過程比較簡單,先將要提交的事務物件中的 redo_log_buf 中的資料寫入到 redo log file(重做日誌檔案)中,並將 redo log file 持久化到磁碟上。清除提交事務物件的 snapshot object,再將提交的事務物件中的 transaction_id 設定為 WT_TNX_NONE,保證其他事務在建立系統事務 snapshot 時本次事務的狀態是已提交的狀態。

事務回滾

WT 引擎對事務的回滾過程也比較簡單,先遍歷整個operation_array,對每個陣列單元對應 update 的事務 id 設定以為一個 WT_TXN_ABORTED(= uint64_max),標示 MVCC 對應的修改單元值被回滾,在其他讀事務進行 MVCC 讀操作的時候,跳過這個放棄的值即可。整個過程是一個無鎖操作,高效、簡潔。

WT 的事務隔離

傳統的資料庫事務隔離分為:

  • Read-Uncommited(未提交讀)
  • Read-Commited(提交讀)
  • Repeatable-Read(可重複讀)
  • Serializable(序列化)

WT 引擎並沒有按照傳統的事務隔離實現這四個等級,而是基於 snapshot 的特點實現了自己的 Read-Uncommited、Read-Commited 和一種叫做 snapshot-Isolation(快照隔離)的事務隔離方式。

在 WT 中不管是選用的是那種事務隔離方式,它都是基於系統中執行事務的快照來實現的。那來看看 WT 是怎麼實現上面三種方式?

優秀資料庫

圖5,點選圖片可以全屏縮放

Read-uncommited

Read-Uncommited(未提交讀)隔離方式的事務在讀取資料時總是讀取到系統中最新的修改,哪怕是這個修改事務還沒有提交一樣讀取,這其實就是一種髒讀。WT 引擎在實現這個隔方式時,就是將事務物件中的 snap_object.snap_array 置為空即可,在讀取 MVCC list 中的版本值時,總是讀取到 MVCC list 連結串列頭上的第一個版本資料。

舉例說明,在圖 5 中,如果 T0/T3/T5 的事務隔離級別設定成 Read-uncommited 的話,T1/T3/T5 在 T5 時刻之後讀取系統的值時,讀取到的都是 14。一般資料庫不會設定成這種隔離方式,它違反了事務的 ACID 特性。可能在一些注重效能且對髒讀不敏感的場景會採用,例如網頁 cache。

Read-Commited

Read-Commited(提交讀)隔離方式的事務在讀取資料時總是讀取到系統中最新提交的資料修改,這個修改事務一定是提交狀態。這種隔離級別可能在一個長事務多次讀取一個值的時候前後讀到的值可能不一樣,這就是經常提到的“幻象讀”。在 WT 引擎實現 read-commited 隔離方式就是事務在執行每個操作前都對系統中的事務做一次快照,然後在這個快照上做讀寫。

還是來看圖 5,T5 事務在 T4 事務提交之前它進行讀取前做事務

snapshot={

snap_min=T2,

snap_max=T4,

snap_array={T2,T4},

};

在讀取 MVCC list 時,12 和 14 修改對應的事務 T2/T4 都出現在 snap_array 中,只能再向前讀取 11,11 是 T1 的修改,而且 T1 沒有出現在 snap_array,說明 T1 已經提交,那麼就返回 11 這個值給 T5。

之後事務 T2 提交,T5 在它提交之後再次讀取這個值,會再做一次

snapshot={

snap_min=T4,

snap_max=T4,

snap_array={T4},

},

這時在讀取 MVCC list 中的版本時,就會讀取到最新的提交修改 12。

Snapshot-Isolation

Snapshot-Isolation(快照隔離)隔離方式是讀事務開始時看到的最後提交的值版本修改,這個值在整個讀事務執行過程只會看到這個版本,不管這個值在這個讀事務執行過程被其他事務修改了幾次,這種隔離方式不會出現“幻象讀”。WT 在實現這個隔離方式很簡單,在事務開始時對系統中正在執行的事務做一個 snapshot,這個 snapshot 一直沿用到事務提交或者回滾。還是來看圖 5, T5 事務在開始時,對系統中的執行的寫事務做

snapshot={

snap_min=T2,

snap_max=T4,

snap_array={T2,T4}

},

在他讀取值時讀取到的是 11。即使是 T2 完成了提交,但 T5 的 snapshot 執行過程不會更新,T5 讀取到的依然是 11。

這種隔離方式的寫比較特殊,就是如果有對事務看不見的資料修改,事務嘗試修改這個資料時會失敗回滾,這樣做的目的是防止忽略不可見的資料修改。

通過上面對三種事務隔離方式的分析,WT 並沒有使用傳統的事務獨佔鎖和共享訪問鎖來保證事務隔離,而是通過對系統中寫事務的 snapshot 來實現。這樣做的目的是在保證事務隔離的情況下又能提高系統事務併發的能力。

記憶體設計如何保證 Durability:事務日誌

通過上面的分析可以知道 WT 在事務的修改都是在記憶體中完成的,事務提交時也不會將修改的 MVCC list 當中的資料刷入磁碟,WT 是怎麼保證事務提交的結果永久儲存呢?

WT 引擎在保證事務的持久可靠問題上是通過 redo log(重做操作日誌)的方式來實現的,在本文的事務執行和事務提交階段都有提到寫操作日誌。WT 的操作日誌是一種基於 K/V 操作的邏輯日誌,它的日誌不是基於 btree page 的物理日誌。說的通俗點就是將修改資料的動作記錄下來,例如:插入一個 key = 10, value = 20 的動作記錄在成:

{

Operation = insert,(動作)

Key = 10,

Value = 20

};

將動作記錄的資料以 append 追加的方式寫入到 wt_transaction 物件中 redo_log_buf 中,等到事務提交時將這個 redo_log_buf 中的資料已同步寫入的方式寫入到 WT 的重做日誌的磁碟檔案中。如果資料庫程式發生異常或者崩潰,可以通過上一個 checkpoint(檢查點)位置重演磁碟上這個磁碟檔案來恢復已經提交的事務來保證事務的永續性。

解密未來資料庫設計

如何通過操作日誌實現 Durability?

根據上面的描述,有幾個問題需要搞清楚:

1、操作日誌格式怎麼設計?

2、在事務併發提交時,各個事務的日誌是怎麼寫入磁碟的?

3、日誌是怎麼重演的?它和 checkpoint 的關係是怎樣的?

在分析這三個問題前先來看 WT 是怎麼管理重做日誌檔案的,在 WT 引擎中定義一個叫做 LSN 序號結構,操作日誌物件是通過 LSN 來確定儲存的位置的,LSN 就是 Log Sequence Number(日誌序列號),它在 WT 的定義是檔案序號加檔案偏移,

wt_lsn{

file:      檔案序號,指定是在哪個日誌檔案中

offset:    檔案內偏移位置,指定日誌物件檔案內的儲存文開始位置

}

WT 就是通過這個 LSN 來管理重做日誌檔案的。

日誌格式設計

WT 引擎的操作日誌物件(以下簡稱為 logrec)對應的是提交的事務,事務的每個操作被記錄成一個 logop 物件,一個 logrec 包含多個 logop,logrec 是一個通過精密序列化事務操作動作和引數得到的一個二進位制 buffer,這個 buffer的資料是通過事務和操作型別來確定其格式的。

WT 中的日誌分為 4 類,分別是:

  • 建立 checkpoint 的操作日誌(LOGREC_CHECKPOINT)
  • 普通事務操作日誌(LOGREC_COMMIT)
  • btree page 同步刷盤的操作日誌(LOGREC_FILE_SYNC)
  • 提供給引擎外部使用的日誌(LOGREC_MESSAGE)

這裡介紹和執行事務密切先關的 LOGREC_COMMIT,這類日誌裡面由根據 K/V 的操作方式分為:

  • LOG_PUT(增加或者修改K/V操作)
  • LOG_REMOVE(單 KEY 刪除操作)
  • 範圍刪除日誌

這幾種操作都會記錄操作時的 key,根據操作方式填寫不同的其他引數,例如:update 更新操作,就需要將 value 填上。除此之外,日誌物件還會攜帶 btree 的索引檔案 ID、提交事務的 ID 等,整個 logrec 和 logop 的關係結構圖如下:

日誌格式設計

圖6,點選圖片可以全屏縮放

對於上圖中的 logrec header 中的為什麼會出現兩個長度欄位:logrec 磁碟上的空間長度和在記憶體中的長度,因為 logrec 在刷入磁碟之前會進行空間壓縮,磁碟上的長度和記憶體中的長度就不一樣。壓縮是根據系統配置可選的。

WAL 與無鎖設計的日誌寫併發

WT 引擎在採用 WAL(Write-Ahead Log)方式寫入日誌,WAL 通俗點說就是說在事務所有修改提交前需要將其對應的操作日誌寫入磁碟檔案。在事務執行的介紹小節中我們介紹是在什麼時候寫日誌的,這裡我們來分析事務日誌是怎麼寫入到磁碟上的,整個寫入過程大致分為下面幾個階段:

1、事務在執行第一個寫操作時,先會在事務物件(wt_transaction)中的 redo_log_buf 的緩衝區上建立一個 logrec 物件,並將 logrec 中的事務型別設定成 LOGREC_COMMIT。

2、然後在事務執行的每個寫操作前生成一個 logop 物件,並加入到事務對應的 logrec 中。

3、在事務提交時,把 logrec 對應的內容整體寫入到一個全域性 log 物件的 slot buffer 中並等待寫完成訊號。

4、Slot buffer 會根據併發情況合併同時發生的提交事務的 logrec,然後將合併的日誌內容同步刷入磁碟(sync file),最後告訴這個 slot buffer 對應所有的事務提交刷盤完成。

5、提交事務的日誌完成,事務的執行結果也完成了持久化。

整個過程的示意圖如下:

WAL 與無鎖設計的日誌寫併發

圖7,點選圖片可以全屏縮放

WT 為了減少日誌刷盤造成寫 IO,對日誌刷盤操作做了大量的優化,實現一種類似 MySQL 組提交的刷盤方式。

這種刷盤方式會將同時發生提交的事務日誌合併到一個 slot buffer 中,先完成合並的事務執行緒會同步等待一個完成刷盤訊號,最後完成日誌資料合併的事務執行緒將 slot buffer 中的所有日誌資料 sync 到磁碟上並通知在這個 slot buffer 中等待其他事務執行緒刷盤完成。

併發事務的 logrec 合併到 slot buffer 中的過程是一個完全無鎖的過程,這減少了必要的 CPU 競爭和作業系統上下文切換。為了這個無鎖設計 WT 在全域性的 log 管理中定義了一個 acitve_ready_slot 和一個 slot_pool 陣列結構,大致如下定義:

wt_log{

. . .

active_slot:準備就緒且可以作為合併logrec的slot buffer物件

slot_pool:系統所有slot buffer物件陣列,包括:正在合併的、準備合併和閒置的slot buffer。

}

slot buffer 物件是一個動態二進位制陣列,可以根據需要進行擴大。定義如下:

wt_log_slot{

. . .

state:          當前 slot 的狀態,ready/done/written/free 這幾個狀態

buf: 快取合併 logrec 的臨時緩衝區

group_size: 需要提交的資料長度

slot_start_offset: 合併的logrec存入log file中的偏移位置

. . .

}

通過一個例子來說明這個無鎖過程,假如在系統中 slot_pool 中的 slot 個數為16,設定的 slot buffer 大小為 4KB,當前 log 管理器中的 active_slot 的 slot_start_offset=0,有 4 個事務(T1、T2、T3、T4)同時發生提交,他們對應的日誌物件分別是 logrec1、logrec2、logrec3 和 logrec4。

Logrec1 size = 1KB,  logrec2 szie = 2KB, logrec3 size = 2KB, logrec4 size = 5KB。他們合併和寫入的過程如下:

1、T1事 務在提交時,先會從全域性的 log 物件中的 active_slot 發起一次 JOIN 操作,join 過程就是向 active_slot 申請自己的合併位置和空間,logrec1_size + slot_start_offset < slot_size 並且 slot 處於 ready 狀態,那 T1 事務的合併位置就是 active_slot[0, 1KB],slot_group_size = 1KB

2、這是 T2 同時發生提交也要合併 logrec,也重複第 1 部 JOIN 操作,它申請到的位置就是 active_slot [1KB, 3KB], slot_group_size = 3KB

3、在T1事務 JOIN 完成後,它會判斷自己是第一個 JOIN 這個 active_slot 的事務,判斷條件就是返回的寫入位置 slot_offset=0。如果是第一個它立即會將 active_slot 的狀態從 ready 狀態置為 done 狀態,並未後續的事務從 slot_pool 中獲取一個空閒的 active_slot_new 來頂替自己合併資料的工作。

4、與此同時 T2 事務 JOIN 完成之後,它也是進行這個過程的判斷,T2 發現自己不是第一個,它將會等待 T1 將 active_slot 置為 done.

5、T1 和 T2 都獲取到了自己在 active_slot 中的寫入位置,active_slot 的狀態置為 done 時,T1 和 T2 分別將自己的 logrec 寫入到對應 buffer 位置。假如在這裡 T1 比 T2 先將資料寫入完成,T1 就會等待一個 slot_buffer 完全刷入磁碟的訊號,而 T2 寫入完成後會將 slot_buffer 中的資料寫入 log 檔案,並對 log 檔案做 sync 刷入磁碟的操作,最高發送訊號告訴 T1 同步刷盤完成,T1 和 T2 各自返回,事務提交過程的日誌刷盤操作完成。

那這裡有幾種其他的情況,假如在第 2 步執行的完成後,T3 也進行 JOIN 操作,這個時候 slot_size(4KB) < slot_group_size(3KB)+ logrec_size(2KB),T3 不 JOIN 當時的 active_slot,而是自旋等待 active_slot_new 頂替 active_slot 後再 JOIN 到 active_slot_new。

如果在第 2 步時,T4 也提交,因為 logrec4(5KB) > slot_size(4KB),T4 就不會進行 JOIN 操作,而是直接將自己的 logrec 資料寫入 log 檔案,並做 sync 刷盤返回。在返回前因為發現有 logrec4 大小的日誌資料無法合併,全域性 log 物件會試圖將 slot buffer 的大小放大兩倍,這樣做的目的是儘量讓下面的事務提交日誌能進行 slot 合併寫。

WT 引擎之所以引入 slot 日誌合併寫的原因就是為了減少磁碟的 I/O 訪問,通過無鎖的操作,減少全域性日誌緩衝區的競爭。

事務恢復

從上面關於事務日誌和 MVCC list 相關描述我們知道,事務的 redo log 主要是防止記憶體中已經提交的事務修改丟失,但如果所有的修改都存在記憶體中,隨著時間和寫入的資料越來越多,記憶體就會不夠用,這個時候就需要將記憶體中的修改資料寫入到磁碟上。

一般在 WT 中是將整個 BTREE 上的 page 做一次 checkpoint 並寫入磁碟。WT 中的 checkpoint 是 append 方式管理,也就是說 WT 會儲存多個 checkpoint 版本。不管從哪個版本的 checkpoint 開始都可以通過重演 redo log 來恢復記憶體中已提交的事務修改。整個重演過程就是就是簡單的對 logrec 中各個操作的執行。

這裡值得提一下的是因為 WT 儲存多個版本的 checkpoint,那麼它會將 checkpoint 做為一種元資料寫入到元資料表中,元資料表也會有自己的 checkpoint 和 redo log,但是儲存元資料表的 checkpoint 是儲存在 WiredTiger.wt 檔案中,系統重演普通表的提交事務之前,先會重演元資料事務提交修改。後文會單獨用一個篇幅來說明 btree、checkpoint 和元資料表的關係和實現。

WT 的 redo log 是通過配置開啟或者關閉的,MongoDB 並沒有使用 WT 的 redo log 來保證事務修改不丟,而是採用了 WT 的 checkpoint 和 MongoDB 複製集的功能結合來保證資料的完整性。

大致的細節是如果某個 MongoDB 例項宕機了,重啟後通過 MongoDB 的複製協議將自己最新 checkpoint 後面的修改從其他的 MongoDB 例項複製過來。

後記

雖然 WT 實現了多操作事務模型,然而 MongoDB 並沒有提供事務,這或許和 MongoDB 本身的架構和產品定位有關係。但是 MongoDB 利用了 WT 的短事務的隔離性實現了文件級行鎖,對 MongoDB 來說這是大大的進步。

可以說 WT 在事務的實現上另闢蹊徑,整個事務系統的實現沒有用繁雜的事務鎖,而是使用 snapshot 和 MVCC 這兩個技術輕鬆的而實現了事務的 ACID,這種實現也大大提高了事務執行的併發性。

除此之外,WT 在各個事務模組的實現多采用無鎖併發,充分利用 CPU 的多核能力來減少資源競爭和 I/O 操作,可以說 WT 在實現上是有很大創新的。通過對 WiredTiger 的原始碼分析和測試,也讓我獲益良多,不僅僅瞭解了資料庫儲存引擎的最新技術,也對 CPU 和記憶體相關的併發程式設計有了新的理解,很多的設計模式和併發程式架構可以直接借鑑到現實中的專案和產品中。

後續的工作是繼續對 Wiredtiger 做更深入的分析、研究和測試,並把這些工作的心得體會分享出來,讓更多的工程師和開發者瞭解這個優秀的儲存引擎。

文/袁榮喜

高可用架構「ArchNotes」微信公眾號

高可用架構