1. 程式人生 > 實用技巧 >MVCC(轉)

MVCC(轉)

什麼是 MVCC

MVCC (Multiversion Concurrency Control) 中文全程叫多版本併發控制,是現代資料庫(包括 MySQLOraclePostgreSQL 等)引擎實現中常用的處理讀寫衝突的手段,目的在於提高資料庫高併發場景下的吞吐效能

如此一來不同的事務在併發過程中,SELECT 操作可以不加鎖而是通過 MVCC 機制讀取指定的版本歷史記錄,並通過一些手段保證保證讀取的記錄值符合事務所處的隔離級別,從而解決併發場景下的讀寫衝突。

下面舉一個多版本讀的例子,例如兩個事務 AB 按照如下順序進行更新和讀取操作

在事務 A 提交前後,事務 B 讀取到的 x

的值是什麼呢?答案是:事務 B 在不同的隔離級別下,讀取到的值不一樣。

  1. 如果事務 B 的隔離級別是讀未提交(RU),那麼兩次讀取均讀取到 x 的最新值,即 20
  2. 如果事務 B 的隔離級別是讀已提交(RC),那麼第一次讀取到舊值 10,第二次因為事務 A 已經提交,則讀取到新值 20。
  3. 如果事務 B 的隔離級別是可重複讀或者序列(RR,S),則兩次均讀到舊值 10,不論事務 A 是否已經提交。

可見在不同的隔離級別下,資料庫通過 MVCC 和隔離級別,讓事務之間並行操作遵循了某種規則,來保證單個事務內前後資料的一致性。

為什麼需要 MVCC

InnoDB 相比 MyISAM 有兩大特點,一是支援事務而是支援行級鎖,事務的引入帶來了一些新的挑戰。相對於序列處理來說,併發事務處理能大大增加資料庫資源的利用率,提高資料庫系統的事務吞吐量,從而可以支援可以支援更多的使用者。但併發事務處理也會帶來一些問題,主要包括以下幾種情況:

  1. 更新丟失(Lost Update):當兩個或多個事務選擇同一行,然後基於最初選定的值更新該行時,由於每個事務都不知道其他事務的存在,就會發生丟失更新問題 —— 最後的更新覆蓋了其他事務所做的更新。如何避免這個問題呢,最好在一個事務對資料進行更改但還未提交時,其他事務不能訪問修改同一個資料。
  2. 髒讀(Dirty Reads):一個事務正在對一條記錄做修改,在這個事務並提交前,這條記錄的資料就處於不一致狀態;這時,另一個事務也來讀取同一條記錄,如果不加控制,第二個事務讀取了這些尚未提交的髒資料,並據此做進一步的處理,就會產生未提交的資料依賴關係。這種現象被形象地叫做 “髒讀”

  3. 不可重複讀(Non-Repeatable Reads):一個事務在讀取某些資料已經發生了改變、或某些記錄已經被刪除了!這種現象叫做“不可重複讀”。
  4. 幻讀(Phantom Reads):一個事務按相同的查詢條件重新讀取以前檢索過的資料,卻發現其他事務插入了滿足其查詢條件的新資料,這種現象就稱為 “幻讀”

以上是併發事務過程中會存在的問題,解決更新丟失可以交給應用,但是後三者需要資料庫提供事務間的隔離機制來解決。實現隔離機制的方法主要有兩種:

  1. 加讀寫鎖
  2. 一致性快照讀,即 MVCC

但本質上,隔離級別是一種在併發效能和併發產生的副作用間的妥協,通常資料庫均傾向於採用 Weak Isolation

InnoDB 中的 MVCC

本文聚焦於 MySQL 中的 MVCC 實現,從 《高效能 MySQL》一書中對 MVCC 的介紹可知:

  1. MySQLInnoDB 引擎支援 MVCC
  2. 應對高併發事務, MVCC 比單純的加行鎖更有效, 開銷更小
  3. MVCC 在讀已提交(Read Committed)和可重複讀(Repeatable Read)隔離級別下起作用
  4. MVCC 既可以基於樂觀鎖又可以基於悲觀鎖來實現

InnoDB MVCC 實現原理

InnoDBMVCC 的實現方式為:每一行記錄都有兩個隱藏列:DATA_TRX_IDDATA_ROLL_PTR(如果沒有主鍵,則還會多一個隱藏的主鍵列)。

DATA_TRX_ID

記錄最近更新這條行記錄的事務 ID,大小為 6 個位元組

DATA_ROLL_PTR

表示指向該行回滾段(rollback segment)的指標,大小為 7 個位元組,InnoDB 便是通過這個指標找到之前版本的資料。該行記錄上所有舊版本,在 undo 中都通過連結串列的形式組織。

DB_ROW_ID

行標識(隱藏單調自增 ID),大小為 6 位元組,如果表沒有主鍵,InnoDB 會自動生成一個隱藏主鍵,因此會出現這個列。另外,每條記錄的頭資訊(record header)裡都有一個專門的 bitdeleted_flag)來表示當前記錄是否已經被刪除。

如何組織 Undo Log 鏈

關於 Redo Log 和 Undo Log 的相關概念可見之前的文章 InnoDB 中的 redo 和 undo log

上文提到,在多個事務並行操作某行資料的情況下,不同事務對該行資料的 UPDATE 會產生多個版本,然後通過回滾指標組織成一條 Undo Log 鏈,這節我們通過一個簡單的例子來看一下 Undo Log 鏈是如何組織的,DATA_TRX_IDDATA_ROLL_PTR 兩個引數在其中又起到什麼樣的作用。

還是以上文 MVCC 的例子,事務 A 對值 x 進行更新之後,該行即產生一個新版本和舊版本。假設之前插入該行的事務 ID100,事務 AID200,該行的隱藏主鍵為 1

事務 A 的操作過程為:

  1. DB_ROW_ID = 1 的這行記錄加排他鎖
  2. 把該行原本的值拷貝到 undo log 中,DB_TRX_IDDB_ROLL_PTR 都不動
  3. 修改該行的值這時產生一個新版本,更新 DATA_TRX_ID 為修改記錄的事務 ID,將 DATA_ROLL_PTR 指向剛剛拷貝到 undo log 鏈中的舊版本記錄,這樣就能通過 DB_ROLL_PTR 找到這條記錄的歷史版本。如果對同一行記錄執行連續的 UPDATEUndo Log 會組成一個連結串列,遍歷這個連結串列可以看到這條記錄的變遷
  4. 記錄 redo log,包括 undo log 中的修改

那麼 INSERTDELETE 會怎麼做呢?其實相比 UPDATE 這二者很簡單,INSERT 會產生一條新紀錄,它的 DATA_TRX_ID 為當前插入記錄的事務 IDDELETE 某條記錄時可看成是一種特殊的 UPDATE,其實是軟刪,真正執行刪除操作會在 commit 時,DATA_TRX_ID 則記錄下刪除該記錄的事務 ID

如何實現一致性讀 —— ReadView

RU 隔離級別下,直接讀取版本的最新記錄就 OK,對於 SERIALIZABLE 隔離級別,則是通過加鎖互斥來訪問資料,因此不需要 MVCC 的幫助。因此 MVCC 執行在 RCRR 這兩個隔離級別下,當 InnoDB 隔離級別設定為二者其一時,在 SELECT 資料時就會用到版本鏈

核心問題是版本鏈中哪些版本對當前事務可見?

InnoDB 為了解決這個問題,設計了 ReadView(可讀檢視)的概念。

RR 下的 ReadView 生成

RR 隔離級別下,每個事務 touch first read 時(本質上就是執行第一個 SELECT 語句時,後續所有的 SELECT 都是複用這個 ReadView,其它 update, delete, insert 語句和一致性讀 snapshot 的建立沒有關係),會將當前系統中的所有的活躍事務拷貝到一個列表生成ReadView

下圖中事務 A 第一條 SELECT 語句在事務 B 更新資料前,因此生成的 ReadView 在事務 A 過程中不發生變化,即使事務 B 在事務 A 之前提交,但是事務 A 第二條查詢語句依舊無法讀到事務 B 的修改。

下圖中,事務 A 的第一條 SELECT 語句在事務 B 的修改提交之後,因此可以讀到事務 B 的修改。但是注意,如果事務 A 的第一條 SELECT 語句查詢時,事務 B 還未提交,那麼事務 A 也查不到事務 B 的修改。

RC 下的 ReadView 生成

RC 隔離級別下,每個 SELECT 語句開始時,都會重新將當前系統中的所有的活躍事務拷貝到一個列表生成 ReadView。二者的區別就在於生成 ReadView 的時間點不同,一個是事務之後第一個 SELECT 語句開始、一個是事務中每條 SELECT 語句開始。

ReadView 中是當前活躍的事務 ID 列表,稱之為 m_ids,其中最小值為 up_limit_id,最大值為 low_limit_id,事務 ID 是事務開啟時 InnoDB 分配的,其大小決定了事務開啟的先後順序,因此我們可以通過 ID 的大小關係來決定版本記錄的可見性,具體判斷流程如下:

  1. 如果被訪問版本的 trx_id 小於 m_ids 中的最小值 up_limit_id,說明生成該版本的事務在 ReadView 生成前就已經提交了,所以該版本可以被當前事務訪問。
  2. 如果被訪問版本的 trx_id 大於 m_ids 列表中的最大值 low_limit_id,說明生成該版本的事務在生成 ReadView 後才生成,所以該版本不可以被當前事務訪問。需要根據 Undo Log 鏈找到前一個版本,然後根據該版本的 DB_TRX_ID 重新判斷可見性。
  3. 如果被訪問版本的 trx_id 屬性值在 m_ids 列表中最大值和最小值之間(包含),那就需要判斷一下 trx_id 的值是不是在 m_ids 列表中。如果在,說明建立 ReadView 時生成該版本所屬事務還是活躍的,因此該版本不可以被訪問,需要查詢 Undo Log 鏈得到上一個版本,然後根據該版本的 DB_TRX_ID 再從頭計算一次可見性;如果不在,說明建立 ReadView 時生成該版本的事務已經被提交,該版本可以被訪問。
  4. 此時經過一系列判斷我們已經得到了這條記錄相對 ReadView 來說的可見結果。此時,如果這條記錄的 delete_flagtrue,說明這條記錄已被刪除,不返回。否則說明此記錄可以安全返回給客戶端。

舉個例子

RC 下的 MVCC 判斷流程

我們現在回看剛剛的查詢過程,為什麼事務 BRC 隔離級別下,兩次查詢的 x 值不同。RCReadView 是在語句粒度上生成的。

當事務 A 未提交時,事務 B 進行查詢,假設事務 B 的事務 ID300,此時生成 ReadViewm_ids 為 [200,300],而最新版本的 trx_id200,處於 m_ids 中,則該版本記錄不可被訪問,查詢版本鏈得到上一條記錄的 trx_id 為 100,小於 m_ids 的最小值 200,因此可以被訪問,此時事務 B 就查詢到值 10 而非 20

待事務 A 提交之後,事務 B 進行查詢,此時生成的 ReadViewm_ids 為 [300],而最新的版本記錄中 trx_id200,小於 m_ids 的最小值 300,因此可以被訪問到,此時事務 B 就查詢到 20

RR 下的 MVCC 判斷流程

如果在 RR 隔離級別下,為什麼事務 B 前後兩次均查詢到 10 呢?RR 下生成 ReadView 是在事務開始時,m_ids 為 [200,300],後面不發生變化,因此即使事務 A 提交了,trx_id200 的記錄依舊處於 m_ids 中,不能被訪問,只能訪問版本鏈中的記錄 10

一個爭論點

其實並非所有的情況都能套用 MVCC 讀的判斷流程,特別是針對在事務進行過程中,另一個事務已經提交修改的情況下,這時不論是 RC 還是 RR,直接套用 MVCC 判斷都會有問題,例如 RC 下:

事務 Atrx_id = 200,事務 Btrx_id = 300,且事務 B 修改了資料之後在事務 A 之前提交,此時 RC 下事務 A 讀到的資料為事務 B 修改後的值,這是很顯然的。下面我們套用下 MVCC 的判斷流程,考慮到事務 A 第二次 SELECT 時,m_ids 應該為 [200],此時該行資料最新的版本 DATA_TRX_ID = 300200 大,照理應該不能被訪問,但實際上事務 A 選取了這條記錄返回。

這裡其實應該結合 RC 的本質來看,RC 的本質就是事務中每一條 SELECT 語句均可以看到其他已提交事務對資料的修改,那麼只要該事物已經提交其結果就是可見的,與這兩個事務開始的先後順序無關,不完全適用於 MVCC 讀

RR 級別下還是用之前那張圖:

這張圖的流程中,事務 Btrx_id = 300 比事務 A 200 小,且事務 B 先於事務 A 提交,按照 MVCC 的判斷流程,事務 A 生成的 ReadView 為 [200],最新版本的行記錄 DATA_TRX_ID = 300200 大,照理不能訪問到,但是事務 A 實際上讀到了事務 B 已經提交的修改。這裡還是結合 RR 本質進行解釋,RR 的本質是從第一個 SELECT 語句生成 ReadView 開始,任何已經提交過的事務的修改均可見。

總結

RCRR 兩種隔離級別的事務在執行普通的讀操作時,通過訪問版本鏈的方法,使得事務間的讀寫操作得以併發執行,從而提升系統性能。RCRR 這兩個隔離級別的一個很大不同就是生成 ReadView 的時間點不同,RC 在每一次 SELECT 語句前都會生成一個 ReadView,事務期間會更新,因此在其他事務提交前後所得到的 m_ids 列表可能發生變化,使得先前不可見的版本後續又突然可見了。而 RR 只在事務的第一個 SELECT 語句時生成一個 ReadView,事務操作期間不更新。