MVCC(轉)
什麼是 MVCC
MVCC (Multiversion Concurrency Control)
中文全程叫多版本併發控制,是現代資料庫(包括 MySQL
、Oracle
、PostgreSQL
等)引擎實現中常用的處理讀寫衝突的手段,目的在於提高資料庫高併發場景下的吞吐效能。
如此一來不同的事務在併發過程中,SELECT
操作可以不加鎖而是通過 MVCC
機制讀取指定的版本歷史記錄,並通過一些手段保證保證讀取的記錄值符合事務所處的隔離級別,從而解決併發場景下的讀寫衝突。
下面舉一個多版本讀的例子,例如兩個事務 A
和 B
按照如下順序進行更新和讀取操作
在事務 A
提交前後,事務 B
讀取到的 x
B
在不同的隔離級別下,讀取到的值不一樣。
- 如果事務
B
的隔離級別是讀未提交(RU),那麼兩次讀取均讀取到x
的最新值,即20
。 - 如果事務
B
的隔離級別是讀已提交(RC),那麼第一次讀取到舊值10
,第二次因為事務A
已經提交,則讀取到新值 20。 - 如果事務
B
的隔離級別是可重複讀或者序列(RR,S),則兩次均讀到舊值10
,不論事務A
是否已經提交。
可見在不同的隔離級別下,資料庫通過 MVCC
和隔離級別,讓事務之間並行操作遵循了某種規則,來保證單個事務內前後資料的一致性。
為什麼需要 MVCC
InnoDB
相比 MyISAM
有兩大特點,一是支援事務而是支援行級鎖,事務的引入帶來了一些新的挑戰。相對於序列處理來說,併發事務處理能大大增加資料庫資源的利用率,提高資料庫系統的事務吞吐量,從而可以支援可以支援更多的使用者。但併發事務處理也會帶來一些問題,主要包括以下幾種情況:
- 更新丟失(
Lost Update
):當兩個或多個事務選擇同一行,然後基於最初選定的值更新該行時,由於每個事務都不知道其他事務的存在,就會發生丟失更新問題 —— 最後的更新覆蓋了其他事務所做的更新。如何避免這個問題呢,最好在一個事務對資料進行更改但還未提交時,其他事務不能訪問修改同一個資料。
- 髒讀(
Dirty Reads
):一個事務正在對一條記錄做修改,在這個事務並提交前,這條記錄的資料就處於不一致狀態;這時,另一個事務也來讀取同一條記錄,如果不加控制,第二個事務讀取了這些尚未提交的髒資料,並據此做進一步的處理,就會產生未提交的資料依賴關係。這種現象被形象地叫做 “髒讀”
- 不可重複讀(
Non-Repeatable Reads
):一個事務在讀取某些資料已經發生了改變、或某些記錄已經被刪除了!這種現象叫做“不可重複讀”。
- 幻讀(
Phantom Reads
):一個事務按相同的查詢條件重新讀取以前檢索過的資料,卻發現其他事務插入了滿足其查詢條件的新資料,這種現象就稱為 “幻讀”。
以上是併發事務過程中會存在的問題,解決更新丟失可以交給應用,但是後三者需要資料庫提供事務間的隔離機制來解決。實現隔離機制的方法主要有兩種:
- 加讀寫鎖
- 一致性快照讀,即
MVCC
但本質上,隔離級別是一種在併發效能和併發產生的副作用間的妥協,通常資料庫均傾向於採用 Weak Isolation
。
InnoDB 中的 MVCC
本文聚焦於 MySQL
中的 MVCC
實現,從 《高效能 MySQL》
一書中對 MVCC
的介紹可知:
MySQL
中InnoDB
引擎支援MVCC
- 應對高併發事務,
MVCC
比單純的加行鎖更有效, 開銷更小 MVCC
在讀已提交(Read Committed)
和可重複讀(Repeatable Read)
隔離級別下起作用MVCC
既可以基於樂觀鎖又可以基於悲觀鎖來實現
InnoDB MVCC 實現原理
InnoDB
中 MVCC
的實現方式為:每一行記錄都有兩個隱藏列:DATA_TRX_ID
、DATA_ROLL_PTR
(如果沒有主鍵,則還會多一個隱藏的主鍵列)。
DATA_TRX_ID
記錄最近更新這條行記錄的事務 ID
,大小為 6
個位元組
DATA_ROLL_PTR
表示指向該行回滾段(rollback segment)
的指標,大小為 7
個位元組,InnoDB
便是通過這個指標找到之前版本的資料。該行記錄上所有舊版本,在 undo
中都通過連結串列的形式組織。
DB_ROW_ID
行標識(隱藏單調自增 ID
),大小為 6
位元組,如果表沒有主鍵,InnoDB
會自動生成一個隱藏主鍵,因此會出現這個列。另外,每條記錄的頭資訊(record header
)裡都有一個專門的 bit
(deleted_flag
)來表示當前記錄是否已經被刪除。
如何組織 Undo Log 鏈
關於 Redo Log 和 Undo Log 的相關概念可見之前的文章 InnoDB 中的 redo 和 undo log
上文提到,在多個事務並行操作某行資料的情況下,不同事務對該行資料的 UPDATE 會產生多個版本,然後通過回滾指標組織成一條 Undo Log
鏈,這節我們通過一個簡單的例子來看一下 Undo Log
鏈是如何組織的,DATA_TRX_ID
和 DATA_ROLL_PTR
兩個引數在其中又起到什麼樣的作用。
還是以上文 MVCC
的例子,事務 A
對值 x
進行更新之後,該行即產生一個新版本和舊版本。假設之前插入該行的事務 ID
為 100
,事務 A
的 ID
為 200
,該行的隱藏主鍵為 1
。
事務 A
的操作過程為:
- 對
DB_ROW_ID = 1
的這行記錄加排他鎖 - 把該行原本的值拷貝到
undo log
中,DB_TRX_ID
和DB_ROLL_PTR
都不動 - 修改該行的值這時產生一個新版本,更新
DATA_TRX_ID
為修改記錄的事務ID
,將DATA_ROLL_PTR
指向剛剛拷貝到undo log
鏈中的舊版本記錄,這樣就能通過DB_ROLL_PTR
找到這條記錄的歷史版本。如果對同一行記錄執行連續的UPDATE
,Undo Log
會組成一個連結串列,遍歷這個連結串列可以看到這條記錄的變遷 - 記錄
redo log
,包括undo log
中的修改
那麼 INSERT
和 DELETE
會怎麼做呢?其實相比 UPDATE
這二者很簡單,INSERT
會產生一條新紀錄,它的 DATA_TRX_ID
為當前插入記錄的事務 ID
;DELETE
某條記錄時可看成是一種特殊的 UPDATE
,其實是軟刪,真正執行刪除操作會在 commit
時,DATA_TRX_ID
則記錄下刪除該記錄的事務 ID
。
如何實現一致性讀 —— ReadView
在 RU
隔離級別下,直接讀取版本的最新記錄就 OK,對於 SERIALIZABLE
隔離級別,則是通過加鎖互斥來訪問資料,因此不需要 MVCC
的幫助。因此 MVCC
執行在 RC
和 RR
這兩個隔離級別下,當 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
的大小關係來決定版本記錄的可見性,具體判斷流程如下:
- 如果被訪問版本的
trx_id
小於m_ids
中的最小值up_limit_id
,說明生成該版本的事務在ReadView
生成前就已經提交了,所以該版本可以被當前事務訪問。
- 如果被訪問版本的
trx_id
大於m_ids
列表中的最大值low_limit_id
,說明生成該版本的事務在生成ReadView
後才生成,所以該版本不可以被當前事務訪問。需要根據Undo Log
鏈找到前一個版本,然後根據該版本的 DB_TRX_ID 重新判斷可見性。
- 如果被訪問版本的
trx_id
屬性值在m_ids
列表中最大值和最小值之間(包含),那就需要判斷一下trx_id
的值是不是在m_ids
列表中。如果在,說明建立ReadView
時生成該版本所屬事務還是活躍的,因此該版本不可以被訪問,需要查詢 Undo Log 鏈得到上一個版本,然後根據該版本的DB_TRX_ID
再從頭計算一次可見性;如果不在,說明建立ReadView
時生成該版本的事務已經被提交,該版本可以被訪問。
- 此時經過一系列判斷我們已經得到了這條記錄相對
ReadView
來說的可見結果。此時,如果這條記錄的delete_flag
為true
,說明這條記錄已被刪除,不返回。否則說明此記錄可以安全返回給客戶端。
舉個例子
RC 下的 MVCC 判斷流程
我們現在回看剛剛的查詢過程,為什麼事務 B
在 RC
隔離級別下,兩次查詢的 x
值不同。RC
下 ReadView
是在語句粒度上生成的。
當事務 A
未提交時,事務 B
進行查詢,假設事務 B
的事務 ID
為 300
,此時生成 ReadView
的 m_ids
為 [200,300],而最新版本的 trx_id
為 200
,處於 m_ids
中,則該版本記錄不可被訪問,查詢版本鏈得到上一條記錄的 trx_id 為 100
,小於 m_ids
的最小值 200
,因此可以被訪問,此時事務 B
就查詢到值 10
而非 20
。
待事務 A
提交之後,事務 B
進行查詢,此時生成的 ReadView
的 m_ids
為 [300],而最新的版本記錄中 trx_id
為 200
,小於 m_ids
的最小值 300
,因此可以被訪問到,此時事務 B
就查詢到 20
。
RR 下的 MVCC 判斷流程
如果在 RR
隔離級別下,為什麼事務 B
前後兩次均查詢到 10
呢?RR
下生成 ReadView
是在事務開始時,m_ids 為 [200,300],後面不發生變化,因此即使事務 A
提交了,trx_id
為 200
的記錄依舊處於 m_ids
中,不能被訪問,只能訪問版本鏈中的記錄 10
。
一個爭論點
其實並非所有的情況都能套用 MVCC
讀的判斷流程,特別是針對在事務進行過程中,另一個事務已經提交修改的情況下,這時不論是 RC
還是 RR
,直接套用 MVCC
判斷都會有問題,例如 RC
下:
事務 A
的 trx_id = 200
,事務 B
的 trx_id = 300
,且事務 B
修改了資料之後在事務 A
之前提交,此時 RC
下事務 A
讀到的資料為事務 B
修改後的值,這是很顯然的。下面我們套用下 MVCC
的判斷流程,考慮到事務 A
第二次 SELECT
時,m_ids
應該為 [200],此時該行資料最新的版本 DATA_TRX_ID = 300
比 200
大,照理應該不能被訪問,但實際上事務 A
選取了這條記錄返回。
這裡其實應該結合 RC
的本質來看,RC
的本質就是事務中每一條 SELECT
語句均可以看到其他已提交事務對資料的修改,那麼只要該事物已經提交其結果就是可見的,與這兩個事務開始的先後順序無關,不完全適用於 MVCC 讀。
RR
級別下還是用之前那張圖:
這張圖的流程中,事務 B
的 trx_id = 300
比事務 A
200
小,且事務 B
先於事務 A
提交,按照 MVCC
的判斷流程,事務 A
生成的 ReadView
為 [200],最新版本的行記錄 DATA_TRX_ID = 300
比 200
大,照理不能訪問到,但是事務 A
實際上讀到了事務 B
已經提交的修改。這裡還是結合 RR
本質進行解釋,RR
的本質是從第一個 SELECT
語句生成 ReadView
開始,任何已經提交過的事務的修改均可見。
總結
RC
、RR
兩種隔離級別的事務在執行普通的讀操作時,通過訪問版本鏈的方法,使得事務間的讀寫操作得以併發執行,從而提升系統性能。RC
、RR
這兩個隔離級別的一個很大不同就是生成 ReadView
的時間點不同,RC
在每一次 SELECT
語句前都會生成一個 ReadView
,事務期間會更新,因此在其他事務提交前後所得到的 m_ids
列表可能發生變化,使得先前不可見的版本後續又突然可見了。而 RR
只在事務的第一個 SELECT
語句時生成一個 ReadView
,事務操作期間不更新。