MVCC多版本併發控制,是一種資料庫管理系統併發控制的方法。MVCC多版本併發控制下,資料庫中的資料會有多個版本,分別對應不同的事務,從而達到事務之間併發資料的隔離。MVCC最大的優勢是讀不加鎖,讀寫不衝突,在讀多寫少場景中,讀寫不衝突可以大幅提升資料庫的併發效能。
MVCC多版本併發控制
在MYSQL中,MyISAM儲存引擎使用的是表鎖,InnoDB儲存引擎使用的是行鎖。而InnoDB的事務分為四個隔離級別,其中預設的隔離級別是可重複讀,可重複讀要求兩個並行的事務之間資料的修改互不影響,通過新增行鎖的方式雖然可以實現兩個事務之間資料據的修改互不影響,但是者兩個事務之間存在鎖等待的情況,影響資料庫效率。所以InnoDB的可重複讀沒有采用行鎖,而是使用了更為強大的MVCC。
MVCC只有在可重複讀和讀已提交的隔離級別下生效,其它兩個隔離級別和MVCC不相容,因為讀未提交總是讀最新的資料行,和事務版本無關,序列化則是會對所有讀取的行加鎖。由於可重複讀的情況比較複雜,並且是MySQL的預設隔離級別,所以本文會用可重複讀來講解MVCC的原理。
可重複讀
資料庫有四種隔離級別:讀未提交/讀已提交/可重複讀/序列化,可重複度是MySQL的預設事務隔離級別,它確保同一事務的多個例項在併發讀取資料時,會看到一致的資料行。
資料行的一致性包含兩部分:
- 情況1:已有資料的內容變更,在同一個事務中多次查詢,查詢結果應該相同,如果在當前事務中進行了修改,查詢結果應該和當前事務中的修改結果相同;
- 情況2:資料行的增減,同一個事務只能檢視到事務開啟之前資料庫中資料,或者由事務本身新增/刪除的結果集,無法看到開啟事務期間其它事務新增或刪除的結果集;
InnoDB預設的隔離級別是可重複讀,可以解決以上兩種情況的資料行一致性問題。其中解決情況1中的資料行一致性問題就是通過MVCC多版本併發控制實現的。
InnoDB用過Gap鎖實現情況2中的資料行一致性問題,不過本文不會對Gap鎖進行介紹。
MVCC的作用
MVCC可以確保同一個事務,在事務起始到結束讀到的某一個資料是一致的,並且多個事務之間互不阻塞。我們以一張使用者表為例,說明MVCC版本控制的作用。
首先我們需要建立使用者表,並向其中插入一條使用者資料,SQL語句如下:
create table user_info
(
age int ,
name varchar(255)
);
insert into user_info(age,name) value (23,'張三');
假設有A,B,C三個事務,這三個事務中在不同時刻對讀取了插入使用者的資訊,並對使用者資訊進行了修改,時間線如下:
- T1時刻,事務A開始,事務A讀取
age=23
的使用者,該使用者的name
為張三
; - T2時刻,事務B開始,事務B讀取
age=23
的使用者,該使用者的name
為張三
; - T3時刻,事務A修改
age=23
的使用者,把name
修改為李四
; - T4時刻,事務A讀取
age=23
的使用者,該使用者的name
為李四
,事務A提交事務; - T5時刻,事務B讀取
age=23
的使用者,該使用者的name
為張三
,事務B提交事務; - T6時刻,事務C開始,事務C讀取
age=23
的使用者,該使用者的name
為李四
,事務C提交事務;
MVCC的作用可以在T5時刻體現出來,此時事務A已經提交,並且修改age=23
的使用者的name
為李四
,但是事務B看不到這次修改,事務B看到的age=23
的使用者的name
為張三
。這是因為在可重複度的隔離級別下,InnoDB事務讀取到的資料是快照讀
,即事務B開始時為資料生成一個快照,事務B讀到的資料始終都是這個快照,與快照讀
對應的是當前讀
:
- 當前讀:讀取的是記錄的最新版本,讀取時還要保證其他併發事務不能修改當前記錄,會對讀取的記錄進行加鎖;
- 快照讀:MVCC使用的就是快照讀,在事務啟動時為資料生成快照,快照讀可以避免了加鎖操作,提升資料庫效能;
MVCC原理
MVCC的目的就是多版本併發控制,在InnoDB中引入MVCC就是為了解決讀寫衝突,MVCC主要包含三部分內容:資料庫中的3個隱藏欄位、UndoLog日誌 、ReadView讀檢視,這三部分在MVCC中的作用分別如下所示:
- 隱藏欄位:為資料新增額外的版本資訊,是MVCC版本控制的基石;
- UndoLog:儲存了多個版本的資料,不同版本資料隱藏欄位的內容不同;
- ReadView:判斷當前事務應該讀取哪個版本的資料;
隱藏欄位
隱藏欄位意味著我們通過SQL語句查詢不到這些欄位,但是這些欄位在資料庫中實際存在並佔用了儲存空間。為了實現MVCC版本控制,InnoDB為每一行資料新增了以下3個隱藏欄位:
DB_TRX_ID
:6位元組,最後修改本記錄的事務ID;DB_ROLL_PTR
:7位元組,回滾指標,指向這條記錄的上一個版本(儲存於Rollback Segment);DB_ROW_ID
:6位元組,隱藏主鍵,如果資料表沒有顯式主鍵,InnoDB用DB_ROW_ID構建聚簇索引;
我們使用以下SQL建立使用者表,並向表中插入一條資料,新表會預設包含三個隱藏欄位,表結構如下表所示。
create table user_info
(
age int,
name varchar(255)
);
insert into user_info(age,name) value (23,'張三');
|age|name|DB_TRX_ID|DB_ROLL_PTR|DB_ROW_ID|
|--|--|--|--||
|23|張三|1|0x222333|1|
UndoLog日誌
我在另外一篇文章中介紹過UndoLog日誌,從名字也可以看出來,UndoLog日誌主要用於回滾事務。但是InnoDB中的MVCC的快照讀也使用了UndoLog。UndoLog可以分為兩大類:
- Insert UndoLog:事務中的Insert語句對應的UndoLog,只在事務回滾時需要,所以事務提交後可以被立即丟棄;
- Update UndoLog:事務在進行Update或Delete時產生的UndoLog; 不僅在事務回滾時需要,在快照讀時也需要;所以不能隨便刪除,只有在快照讀或事務回滾不涉及該日誌時,對應的日誌才會被Purge執行緒統一清除;
Purge執行緒:InnoDB中,被刪除的資料不會直接刪除,而是先標記為刪除,無用的Update UndoLog也不會立即刪除。這些資料都是通過InnoDB中的後臺任務Purge執行緒進行刪除的。
下文中我們以上文中的使用者表以及資料為例,解釋Update UndoLog的工作流程,如下為起始時user_info
表空間的資料狀態:
-
T1時刻,事務A開始,事務Id為2,事務A讀取
age=23
的使用者,該使用者的name
為張三
;此時沒有修改資料庫資料,沒有生成UndoLog,表空間無變化; -
T2時刻,事務B開始,事務Id為3,事務B讀取
age=23
的使用者,該使用者的name
為張三
;此時沒有修改資料庫資料,沒有生成UndoLog,表空間無變化; -
T3時刻,事務A修改
age=23
的使用者,把name
修改為李四
;此時由於事務A尚未提交,所以會給事務A生成一條UndoLog,UndoLog中儲存了事務A修改前的資料,表空間中最新資料中的回滾指標指向這條日誌; -
T4時刻,事務A讀取
age=23
的使用者,由於表資料中的記錄的事務ID和事務A的事務ID一致,所以事務A會讀取到表資料中的記錄,讀取到使用者的name
為李四
,事務A提交事務; -
T5時刻,事務B讀取
age=23
的使用者,由於表空間中資料不滿足可見性條件(下一節具體介紹),所以事務B會查詢表資料的UndoLog,UndoLog中的資料滿足可見性條件,所以查詢到UndoLog中的使用者,使用者的name
為張三
,事務B提交事務; -
T6時刻,事務C開始,事務ID為3,事務C讀取
age=23
的使用者,由於事務C開始時事務A已經提交,所以事務C可以查詢到已提交的資料,事務C讀取到使用者的name
為李四
; -
T7時刻,事務C開始,事務ID為3,事務C修改
age=23
的使用者,把name
修改為王五
;此時由於事務C尚未提交,所以會給事務C生成一條UndoLog,UndoLog中儲存了事務C修改前的資料;
從上面的例子可以看出,不同事務或者相同事務的對同一記錄的修改,會導致該記錄的UndoLog成為一條記錄版本線性連結串列,UndoLog的鏈首就是最新的舊記錄,鏈尾就是最早的舊記錄(UndoLog的節點可能會被Purge執行緒清除掉)
UndoLog是為回滾而用,具體內容就是複製事務前的資料庫記錄行到UndoBuffer,在適合的時間把UndoBuffer中的內容重新整理到磁碟。UndoBuffer與RedoBuffer一樣,也是環形緩衝,但當緩衝滿的時候,UndoBuffer中的內容會也會被重新整理到磁碟;與RedoLog不同的是,磁碟上不存在單獨的UndoLog檔案,所有的UndoLog均存放在主ibd資料檔案中(表空間),即使客戶端設定了每表一個資料檔案也是如此。
ReadView讀檢視
ReadView就是事務進行快照讀操作的時候生產的讀檢視,在該事務執行的快照讀的那一刻,會生成資料庫系統當前的一個快照,記錄並維護系統當前活躍事務的ID(當每個事務開啟時,都會被分配一個ID, 這個ID是遞增的,所以最新的事務,ID值越大)
所以我們知道ReadView主要是用來做可見性判斷的, 即當我們某個事務執行快照讀的時候,對該記錄建立一個ReadView讀檢視,把它比作條件用來判斷當前事務能夠看到哪個版本的資料,既可能是當前最新的資料,也有可能是該行記錄的UndoLog裡面的某個版本的資料。
ReadView遵循一個可見性演算法,主要是將要被修改的資料的最新記錄中的DB_TRX_ID(即當前事務ID)取出來,與系統當前其他活躍事務的ID去對比(由ReadView維護),如果DB_TRX_ID跟ReadView的屬性做了某些比較,不符合可見性,那就通過DB_ROLL_PTR回滾指標去取出UndoLog中的DB_TRX_ID再比較,即遍歷連結串列的DB_TRX_ID(從鏈首到鏈尾,即從最近的一次修改查起),直到找到滿足特定條件的DB_TRX_ID, 那麼這個DB_TRX_ID所在的舊記錄就是當前事務能看見的最新老版本。
ReadView判斷可見性的原理如下,在InnoDB中,建立一個新事務之後,當新事務讀取資料時,資料庫為該事務生成一個ReadView讀檢視,InnoDB會將當前系統中的活躍事務列表建立一個副本儲存到ReadView。當使用者在這個事務中要讀取某行記錄的時候,InnoDB會將該行當前的版本號與該ReadView進行比較。具體的演算法如下:
- 設該行的當前事務ID為cur_trx_id,ReadView中最早的事務ID為min_trx_id, 最遲的事務ID為max_trx_id;
- 如果cur_trx_id < min_trx_id,那麼表明該行記錄所在的事務已經在本次新事務建立之前就提交了,所以該行記錄的當前值是可見的。跳到步驟6.
- 如果cur_trx_id > max_trx_id,那麼表明該行記錄所在的事務在本次新事務建立之後才開啟,所以該行記錄的當前值不可見.跳到步驟5;
- 如果min_trx_id<= cur_trx_id <= max_trx_id, 那麼表明該行記錄所在事務在本次新事務建立的時候處於活動狀態,從min_trx_id到max_trx_id進行遍歷,如果cur_trx_id等於他們之中的某個事務id的話,那麼不可見。跳到步驟5;
- 從該行記錄的DB_ROLL_PTR指標所指向的回滾段中取出最新的UndoLog的版本號,將它賦值該cur_trx_id,然後跳到步驟2;
- 將該可見行的值返回;
總結一下:MVCC版本控制中,以事務第一次快照讀為分界線,事務後續只能查詢到第一次快照讀及之前提交的資料版本,之後提交的資料版本不可見。
讀已提交和可重複度
讀已提交和可重複度隔離級別下的InnoDB快照讀有什麼不同?答案是:ReadView生成時機的不同,從而造成讀已提交和可重複度級別下快照讀的結果的不同:
- 可重複讀隔離級別下,事務第一次快照讀會生成ReadView時,ReadView會記錄此時所有其他活動事務的快照,這些事務的修改對於當前事務都是不可見的。而早於ReadView建立的事務所做的修改均是可見;
- 讀已提交隔離級別下的,事務每次快照讀都會新生成一個快照和ReadView, 這就是我們在RC級別下的事務中可以看到別的事務提交的更新的原因;
總之在讀已提交隔離級別下,是每個快照讀都會生成並獲取最新的ReadView;而在可重複讀隔離級別下,則是同一個事務中的第一個快照讀才會建立ReadView, 之後的快照讀獲取的都是同一個ReadView。
MVCC與幻讀
幻讀是指,同一個事務裡面連續執行兩次同樣的SQL語句,可能導致不同結果的問題,第二次SQL語句可能會返回之前不存在的行。舉例說明:T1時刻事務A和事務B同時開啟,分別進行了快照讀,然後事務A向資料庫中插入一條新的記錄,如果事務B可以讀到這條記錄,就出現了"幻讀",因為B第一次快照讀沒有讀到這條資料。
MVCC是否可以解決幻讀問題呢?答案是有的情況下可以解決,有的情況下不可以解決。如果事務B中的讀是快照讀,那麼MVCC版本控制可以解決幻讀問題;如果事務B中使用的是當前讀,那麼MVCC無法解決幻讀問題。
- 快照讀是基於MVCC和UndoLog來實現的,適用於簡單Select語句;
- 當前讀是基於Gap鎖來實現的,適用於Insert,Update,Delete,Select ... For Update, Select ... Lock In Share Mode語句,以及加鎖了的Select語句;
事實上,MVCC對於所有的當前讀都無效,比如事務A修改資料之後,事務B去Update對應的資料,Update語句篩選條件針對的是資料庫中當前的資料,而不是快照資料。
我是御狐神,歡迎大家關注我的微信公眾號:wzm2zsd
參考文件
MySQL之MVCC與幻讀
正確的理解MySQL的MVCC及實現原理
MySQL資料庫事務各隔離級別加鎖情況--read committed && MVCC
本文最先發布至微信公眾號,版權所有,禁止轉載!