一文了解MySQL中的多版本併發控制

京東雲開發者發表於2023-04-11

作者:京東零售  李澤陽

最近在閱讀《認知覺醒》這本書,裡面有句話非常打動我:透過自己的語言,用最簡單的話把一件事情講清楚,最好讓外行人也能聽懂。

也許這就是大道至簡,只是我們習慣了煩瑣和複雜。

希望藉助今天這篇文章,能用大白話說清楚這個相對比較底層和複雜的MVCC機制。

在開始之前,先丟擲一個問題:我們都知道,目前(MySQL 5.6以上)資料庫已普遍使用InnoDB儲存引擎,InnoDB相對於MyISAM儲存引擎其中一個好處就是在資料庫級別鎖和表級別鎖的基礎上支援了行鎖,還有就是支援事務,保證一組資料庫操作要麼成功,要麼失敗。基於此,問題來了,在InnoDB預設隔離級別(可重複讀)下,一個事務想要更新一行資料,如果剛好有另外一個事務擁有這個行鎖,那麼這個事務就會進入等待狀態。既然進入等待狀態,那麼等到這個事務獲取到行鎖要更新資料的時候,它讀取到的值是什麼呢?

具體的問題見下圖,我們設定有一張表user,初始化語句如下,試想在這樣的場景下,事務A三次查詢的值分別是什麼?

create table `user` (
`id` bigint not null,
`name` varchar(50) default null,
PROMARY KEY (`id`)
) ENGINE = InnoDB;
insert into user(id,name) values (1,'A');

想要把這件事情回答正確,我們先來鋪墊一下基礎知識。

提到事務,首先會想到的就是ACID(Atomic原子性、Consist一致性、Isolate隔離性、Durable永續性),今天我們主要關注隔離性,當有多個事務同時執行發生併發時,資料庫可能會出現髒讀、不可重複讀和幻讀等問題,為了解決這些問題,“隔離級別”這位大哥上場,包含:讀未提交、讀已提交、可重複讀和序列

但我們都知道,隔離級別越高,執行效率越低。畢竟大哥就是大哥,級別越高,越謹慎,常在河邊走哪能不溼鞋。

我們透過一個例子簡單說一下這四種隔離級別:

• 讀未提交:一個事務還未提交,它的變更就能被其他事務看到。V1為B,V2為B,V3為B。

• 讀已提交:一個事務提交之後,變更結果對其他事務可見。V1為A,V2和V3為B。

• 可重複讀:一個事務執行過程中看到的資料與事務啟動時一致。V1為A,V2為A,V3為B。

• 序列:不管讀和寫,加鎖就完了,就是幹!V1和V2均為A,V3為B。

事務是怎麼實現的呢?實際上,事務執行時,資料庫會建立一個檢視,讀未提交直接返回最新值,沒有檢視概念;序列是直接加鎖避免併發訪問;讀已提交是在每個SQL語句開始執行時建立的檢視。可重複讀的檢視是在事務啟動的時候建立的,整個事務都會使用這個檢視。這樣的話,上面四種不同隔離級別下的V1、V2、V3值便對號入座,有了結果。

**MySQL是怎麼實現的呢?**我們以MySQL預設的可重複讀隔離級別為例,實際上每條行記錄在更新時都會記錄一條回滾日誌,也就是大家常說的undo log。透過回滾操作,都可以得到前一個狀態的值。假設name值從初始值A被依次更新為B、C、D,我們看一下回滾日誌:

當前值是D,但是在查詢這條記錄的時候,不同時刻啟動的事務會有不同的檢視,看到的值也就不一樣。在檢視1、2、3、4裡面,記錄的name值分別是A、B、C、D。同一條行記錄在資料庫中可以存在多個版本,這就是多版本併發控制(MVCC)。對於檢視1,如果想要將name值回到A,那麼就要依次執行圖中所有回滾操作。

到這裡,你已經接觸到了MVCC的概念,也許你已經對文章最開始的問題有了一點點想法,彆著急,我們先來簡單總結下MVCC的特點:

MVCC的出現使得一條行記錄在不同隔離級別下不同的事務操作會形成一條不同版本的鏈路,從而實現在不加鎖的前提下使不同事務的讀寫操作能夠併發安全執行,這個版本鏈就是透過回滾日誌undo log實現的。用大白話說,你這個事務想要查詢一條行記錄,MVCC會透過你這個事務所在檢視確認版本鏈中哪個版本的行資料對你可見。剛才我們提到,四種隔離級別下,只有讀已提交可重複讀會用到檢視。對於讀已提交,MVCC會在每次查詢前都會生成一個檢視,可重複讀隔離級別只會在第一次查詢時生成一個檢視,之後在這個事務中的所有查詢操作都會重複使用這個檢視。行業上,將建立檢視的那一刻稱為快照,晃你一下子,讓你激靈激靈,別發生髒讀,變髒嘍~

想要解決文章最開始的那個問題,我們還得展開說說版本鏈是如何形成的和快照的原理,稍有枯燥,先忍一下,耐心看下去,乖~

對於InnoDB儲存引擎來說,主鍵索引(也稱為聚簇索引)記錄中除了正常的欄位資料外,還包含兩個隱藏列:

(1)trx\_id:每次一個事務想要對主鍵索引進行更新、刪除和新增時,都會把這個事務的事務id賦值給trx\_id欄位。注意事務id嚴格遞增,且查詢操作不會分配事務id,即trx_id = 0;

(2)roll\_point:每次一個事務對主鍵索引進行更新時,都會把舊的版本寫入到undo日誌中,roll\_point相當於一個指標,透過它可以找到這條記錄修改前的資訊。

我們以可重複讀隔離級別為例,為了尚未提交的更新結果對其他事務不可見,InnoDB在建立檢視時,有以下四部分組成:

• m_ids:表示生成檢視時,當前系統中“活躍”的讀寫事務的事務id列表,這裡的活躍大白話就是事務尚未提交;

• min\_trx\_id:表示在生成檢視時,當前系統中活躍的讀寫事務中最小的事務id,即m_ids中的最小值;

• max\_trx\_id:表示生成檢視時系統應該分配給下一個事務的id值;

• creator\_trx\_id:表示生成該檢視的事務id。

概念比較多,舉個例子,現在有事務id分別是1、2、3三個事務,1和2事務尚未提交,3事務已提交,這個時候如果來了一個新事務,那麼它建立的檢視對應這幾個引數分別為:m\_ids包含1、2,min\_trx\_id為1,max\_trx_id為4。

關鍵的知識點來了,如何根據某個事務生成的檢視,判斷版本鏈上的某個版本對這個事務可見呢?

遵循下面步驟:

1、版本鏈上的不同版本trx\_id值如果與這個檢視的creator\_trx_id值相同,說明當前事務在訪問它自己修改過的記錄,所以被訪問的版本對當前事務可見。一家人還是認識一家人的~

2、版本鏈上的不同版本trx\_id值小於這個檢視的min\_trx_id值,說明這個版本的事務在當前事務生成檢視之前就已經提交了,所以被訪問的版本對當前事務可見。

3、版本鏈上的不同版本的trx\_id值大於或等於這個檢視的max\_trx_id值,說明這個版本的事務在當前事務之後才開啟,所以被訪問版本對當前事務不可見。

4、版本鏈上的不同版本的trx\_id值在這個檢視的min\_trx\_id和max\_trx\_id之間,需要進一步判斷被訪問版本trx\_id值是不是在m_ids中,如果在,說明當前事務是活躍的,被訪問版本對當前事務不可見。如果不在,說明被訪問版本的事務已經提交了,被訪問版本對當前事務可見。

比較繞是不是,千萬別暈,兄弟呀~,大白話解釋一下,設定某個事務生成的檢視瞬間(也就是快照),這個事務的id為creator\_trx\_id,那麼有下面三種可能:

1、如果creator\_trx\_id落在綠色部分,表示被訪問的版本是已提交的事務或者就是當前事務自己生成的,這個資料是可見的;

2、如果creator\_trx\_id落在紅色部分,表示被訪問的版本還未開啟,資料不可見;

3、如果creator\_trx\_id落在黃色部分,包括兩種情況:

若creator\_trx\_id在m_ids集合中,表示被訪問的版本尚未提交,資料不可見;

若creator\_trx\_id不在m_ids集合中,表示被訪問的版本已經已經提交了,資料可見。

知道了這個之後,我們就可以回答文章最開始那個問題了,在隔離級別為可重複讀的情況下(這裡的隱含條件就是可重複讀隔離級別只會在第一次查詢時生成一個檢視,之後在這個事務中的所有查詢操作都會重複使用這個檢視)分析一波:

以文章開頭的例子,設定事務B的事務id=100,事務C的事務id=200,當事務B尚未提交時,id=1這條記錄的版本鏈是這樣的:

這個時候我們看一下事務A第一個select語句,注意查詢操作的事務trx\_id=0,在執行select語句時會建立一個檢視,這個檢視的m\_ids={100},min\_trx\_id=100,max\_trx\_id=101,creator\_trx\_id=0。

然後在版本鏈中挑選可見的資料記錄,從圖中可以看到最新版本的name值是B,最新版本的trx\_id值為100,在m\_ids集合中,這個版本資料不可見,根據roll_point跳到下一個版本;

下一個版本的name值是A,這個版本的trx\_id=99,小於min\_trx_id,這個版本資料是可見的,所以返回name為A的記錄,即V1為A。

我們繼續,事務B這時進行了commit提交,此時事務C已經開啟,那麼事務A第二個select語句不會建立一個新的檢視,而是重新利用第一次建立的檢視。最新版本的trx\_id為100,在m\_ids中,資料不可見,即V2=A;

接下來,事務C進行了更新操作,此時版本鏈發生的改變如下:

事務C接著進行了commit提交,此時事務A第三次select語句也不會建立一個新的檢視,最新版本的trx\_id為200,大於max\_trx_id,資料不可見,即V3=A。

到這裡,MVCC就結束啦,留一個小問題,如果是讀已提交隔離級別,那麼文章開頭的例子中V1、V2、V3的值又分別是什麼呢?答案在最後哦。

最後,我們再來總結一下MVCC的作用,使用可重複讀隔離級別的事務在查詢時,僅會使用第一次select時生成的檢視,相比於讀已提交隔離級別每次查詢都會生成一個新的檢視,可重複讀在查詢時使用的檢視版本不會那麼新,因此有些已經提交的事務對行記錄進行修改時對查詢事務就不可見,進而避免了不可重複讀現象的發生,同時也避免了髒讀。


小問題答案:

讀已提交隔離級別下,每次select查詢都會生成一個新的檢視,基於此,分析如下:

事務A第一個select語句,注意查詢操作的事務trx\_id=0,在執行select語句時會建立一個檢視,這個檢視的m\_ids={100},min\_trx\_id=100,max\_trx\_id=101,creator\_trx\_id=0。

然後在版本鏈中挑選可見的資料記錄,從圖中可以看到最新版本的name值時B,最新版本的trx\_id值為100,在m\_ids集合中,這個版本資料不可見,根據roll_point跳到下一個版本;

下一個版本的name值是A,這個版本的trx\_id=99,小於min\_trx_id,這個版本資料是可見的,所以返回name為A的記錄,即V1為A。

事務B這時進行了commit提交,此時事務C已經開啟,那麼事務A第二個select語句會建立一個新的檢視,這個檢視的m\_ids={200},min\_trx\_id=200,max\_trx\_id=201,creator\_trx\_id=0。版本鏈沒有發生變化,最新版本trx\_id值為100,小於min\_trx\_id,資料可見,即V2=B;

事務C接著進行了commit提交,此時事務A第三次select語句會建立一個新的檢視,這個檢視的m\_ids={},min\_trx\_id不存在,max\_trx\_id=201,creator\_trx\_id=0。在版本鏈中挑選可見的資料記錄,從圖中可以看到最新版本的name值為C,最新版本的trx\_id值為200,小於max\_trx\_id且不在m_ids中,則資料可見,即V3=C。

相關文章