MySQL-16.MVCC(多版本併發控制)

长名06發表於2024-07-03

C-16.多版本併發控制

1.什麼是MVCC


MVCC(Multiversion Concurrency Control),多版本併發控制。顧名思義,MVCC是透過資料行的多個版本管理來實現資料庫的併發控制。這項技術使得在InnoDB的事務隔離級別下執行一致性讀操作有了保證。換言之,就是為了查詢一些正在被另一事務更新的行,並且可以看到它們被更新之前的值,這樣在做查詢的時候就不同等待另一個事務釋放鎖。

MVCC沒有正式的標準,在不同的DBMS中MVCC的實現方式可能是不同的,也不是普遍使用的。這裡講解InnoDB中MVCC的實現機制(MySQL其它的儲存引擎並不支援它)。

2.快照讀和當前讀


MVCC在MySQL InnoDB中的實現主要是為了提高資料庫併發效能,用更好的方式去處理讀-寫衝突,做到即使有讀寫衝突時,也能做到不加鎖非阻塞併發讀,而這個讀指的就是快照讀,而非當前讀。當前讀實際上是一種加鎖的操作,是悲觀鎖的實現。而MVCC本質是採用樂觀鎖思想的一種方式。

2.1 快照讀

快照讀又叫一致性讀,讀取的是快照資料。不加鎖的簡單的SELECT都屬於快照讀,即不加鎖的非阻塞讀;

比如這樣:

select * from t where ...

之所以出現快照讀的情況,是基於提高併發效能的考慮,快照讀的實現是基於MVCC,它在很多情況下,避免了加鎖操作,降低了開銷。

既然是基於多版本,那麼快照讀可能讀到的並不一定是資料的最新版本,而有可能是之前的歷史版本。

快照讀的前提是隔離級別不是序列級別,序列級別下的快照讀會退化成當前讀。

2.2 當前讀

當前讀,讀取的是記錄的最新版本(最新資料,而不是歷史版本的資料),讀取時還要保證其他併發事務不能修改當前記錄,會對讀取的記錄進行加鎖。加鎖的SELECT,或者對資料進行增刪改都會進行當前讀。

比如:

select * from t lock in share mode;#共享鎖

select * from t for update;#排他鎖

INSERT INTO t values...; #排他鎖

DELETE FROM t where ...; #排他鎖

update t set ...; #排他鎖

3.複習


3.1 再談隔離級別

事務的4個隔離級別,可能存在的三種併發問題:

注意,髒讀問題因為太過嚴重,在任何隔離級別下,都被解決了。

在MySQL中,預設的隔離級別是可重複讀,可以解決髒讀和不可重複讀的問題,如果僅從定義的角度來看,它並不能解決幻讀問題。如果我們想要解決幻讀問題,就需要採用序列化的方式,也就是將隔離級別提升到最高,但這樣一來就會大幅降低資料庫的事務併發能力。

MVCC可以不採用鎖機制,而是透過樂觀鎖的方式來解決不可重複讀和幻讀問題!它可以在大多數情況下替代行級鎖,降低系統的開銷。

3.2 隱藏欄位、Undo Log版本鏈

回顧一下undo日誌的版本鏈,對於使用InnoDB儲存引擎的表來說,它的聚簇索引記錄中都包含兩個必要的隱藏列。

  • trx_id:每次一個事務對某條聚簇索引記錄進行改動時,都會把該事務的事務id賦值給trx_id隱藏列。
  • roll_pointer:每次對某條聚簇索引記錄進行改動時,都會把舊的版本寫入到undo日誌中,然後這個隱藏列就相當於一個指標,可以透過它來找到該記錄修改前的資訊。

舉例:student1表資料如下

mysql> select * from student1 where id = 1;
+----+---------+--------+
| id | name    | class  |
+----+---------+--------+
|  1 | 張三1   | 一班   |
+----+---------+--------+
1 row in set (0.01 sec)

假設插入該記錄的事務id8,那麼此刻該條記錄的示意圖如下所示:

insert undo只在事務回滾時起作用,當事務提交後,該型別的undo日誌就沒用了,它佔用的Undo LogSegment也會被系統回收(也就是該undo日誌佔用的Undo頁面連結串列要麼被重用,要麼被釋放)。

假設之後兩個事務id分別為1020的事務對這條記錄進行UPDATE操作,操作流程如下:

執行順序 事務10 事務20
1 begin;
2 begin;
3 update student set name = "李四" where id = 1;
4 update student set name = "王五" where id = 1;
5 commit;
6 update student set name = "錢七" where id = 1;
7 update student set name = "宋八" where id = 1;
8 commit;

能不能在兩個事務中交叉更新同一條記錄呢?不能!這不就是一個事務修改了另一個未提交事務修改過的資料,髒寫。

InnoDB使用鎖來保證不會有髒寫情況的發生,也就是在第一個事務更新了某條記錄後,就會給這條記錄加鎖,另一個事務再次更新時就需要等待第一個事務提交了,把鎖釋放之後才可以繼續更新。

每次對記錄進行改動,都會記錄一條undo日誌,每條undo日誌也都有一個roll_pointer屬性(INSERT操作對應的undo日誌沒有該屬性,因為該記錄並沒有更早的版本),可以將這些undo日誌都連起來,串成一個連結串列:

對該記錄每次更新後,都會將舊值放到一條undo日誌中,就算是該記錄的一箇舊版本,隨著更新次數的增多,所有的版本都會被roll_pointer屬性連線成一個連結串列,我們把這個連結串列稱之為版本鏈,版本鏈的頭節點就是當前記錄最新的值。

每個版本中還包含生成該版本時對應的事務id。

4.MVCC實現原理之ReadView


MVCC的實現依賴於:隱藏欄位,Undo Log,Read View。

4.1 什麼是Read View

在MVCC機制中,多個事務對同一個行記錄進行更新會產生多個歷史快照,這些歷史快照儲存在Undo Log裡。如果一個事務想要查詢這個行記錄,需要讀取哪個版本的行記錄呢?這時就需要用到 ReadView了,它幫我們解決了行的可見性問題。

ReadView就是一個事務在使用MVCC機制進行快照讀操作時產生的讀檢視。當事務啟動時,會生成資料庫系統當前的一個快照,InnoDB為每個事務構造了一個陣列,用來記錄並維護系統當前活躍事務的ID(“活躍”指的就是,啟動了但還沒提交)。

4.2 設計思路

使用READ UNCOMMITTED隔離級別的事務,由於可以讀到未提交事務修改過的記錄,所以直接讀取記錄的最新版本就好了。

使用SERIALIZABLE隔離級別的事務,InnoDB規定使用加鎖的方式來訪問記錄。

使用READ COMNITTEDREPEATABLE READ隔離級別的事務,都必須保證讀到已經提交了的事務修改過的記錄。假如另一個事務已經修改了記錄但是尚未提交,是不能直接讀取最新版本的記錄的,核心問題就是需要判斷一下版本鏈中的哪個版本是當前事務可見的,這是ReadView要解決的主要問題。

這個ReadView中主要包含4個比較重要的內容,分別如下:

1.creator_trx_id,建立這個Read View的事務ID。

說明:只有在對錶中的資料做改動時(執行INSERT、UPDATE、DELETE這寫語句時)才會為事務分配事務id,否則在一個只讀事務中的事務id值預設為0。

2.trx_ids,表示在生成ReadView時當前系統中活躍的讀寫事務的事務id列表

3.up_limit_id,活躍的事務中最小的事務ID。

4.low_limit_id,表示生成ReadView時系統中應該分配給下一個事務的id值。low_limit_id是系統最大的事務id值 + 1,這裡要注意是系統中的事務id,需要區別於正在活躍的事務ID。

注意: low_limit_id並不是trx_ids中的最大值,事務id是遞增分配的。比如,現在有id為1,2,3這三個事務,之後id為3的事務提交了。那麼一個新的讀事務在生成ReadView時,trx_ids就包括1和2,up_limit_id的值就是1,low_limit_id的值就是4。

舉例:

trx_ids為trd2、trx3、trx5和trx8的集合,系統的最大事務ID (low_limit_id)為 trx8 + 1(如果之前沒有其他的新增事務),活躍的最小事務ID(up_limit_id)為trx2。

4.3 ReadView的規則

有了這個ReadView,這樣在訪問某條記錄時,只需要按照下邊的步驟判斷記錄的某個版本是否可見。被訪問版本指的是,被查詢資料的生成的版本(包括最新和歷史)。被查詢資料使用快照讀的時候,在當前事務下,就會生成一版ReadView。不好理解,建議看影片。原影片地址

  • 如果被訪問版本的trx_id屬性值與ReadView(當前事務生成的)中的creator_trx_id值相同,意味著當前事務在訪問它自己修改過的記錄,所以該版本可以被當前事務訪問。

  • 如果被訪問版本的trx_id屬性值小於ReadView(當前事務生成的)中的up_limit_id值,表明生成該版本的事務在當前事務生成ReadView前已經提交,所以該版本可以被當前事務訪問。

  • 如果被訪問版本的trx_id屬性值大於或等於ReadView(當前事務生成的)中的low_limit_id值,表明生成該版本的事務在當前事務生成ReadView後才開啟,所以該版本不可以被當前事務訪問。

  • 如果被訪問版本的trx_id屬性值在ReadView(當前事務生成的)的 up_limit_idlow_limit_id之間,那就需要判斷一下trx_id屬性值是不是在trx_ids列表中。

    • 如果在,說明建立ReadView時生成該版本的事務還是活躍的,該版本不可以被訪問。

    • 如果不在,說明建立ReadView時生成該版本的事務已經被提交,該版本可以被訪問。

4.4 MVCC整體操作流程

瞭解了這些概念之後,我們來看下當查詢一條記錄的時候,系統如何透過MVCC找到它:

1.首先獲助事務自己的版本號,也就是事務ID;

2.獲取ReadView;

3.查詢得到的資料,然後與ReadView中的事務版本號進行比較;

4.如果不符合ReadView規則,就需要從Undo Log中獲取歷史快照;

5.最後返回符合規則的資料。

如果某個版本的資料對當前事務不可見的話,那就順著版本鏈找到下一個版本的資料,繼續按照上邊的步驟判斷可見性,依此類推,直到版本鏈中的最後一個版本。如果最後一個版本也不可見的話,那麼就意味著該條記錄對該事務完全不可見,查詢結果就不包含該記錄。

InnoDB中,MVCC是透過Undo Log + Read View進行資料讀取,Undo Log儲存了歷史快照,而Read View規則幫我們判斷當前版本的資料是否可見。

在隔離級別為讀未提及(Read Committed)時,一個事務中的每一次SELECT查詢都會重新獲取一次Read View。

如表所示:

事務 說明
begin;
select * from student where id > 2; 獲取一次Read View
....
select * from student where id > 2; 獲取一次Read View
commit;

注意,此時同樣的查詢語句都會重新獲取一次 Read View,這時如果 Read View 不同,就可能產生不可重複讀或者幻讀的情況。

當隔離級別為可重複讀的時候,就避免了不可重複讀,這是因為一個事務只在第一次 SELECT 的時候會獲取一次 Read View,而後面所有的 SELECT 都會複用這個 Read View,如下表所示:

5.舉例說明


假設現在student1表中只有一條由事務id8的事務插入的一條記錄:

mysql> select * from student1 where id = 1;
+----+---------+--------+
| id | name    | class  |
+----+---------+--------+
|  1 | 張三1   | 一班   |
+----+---------+--------+
1 row in set (0.01 sec)

MVCC只能在READ COMMITTED和REPEATABLE READ兩個隔離級別下工作。接下來看一下READ COMMITTEDREPEATABLE READ所謂的生成ReadView的時機不同到底不同在哪裡。

5.1 READ COMMITTED隔離級別下

READ COMMITTED:每次讀取資料前都生成一個ReadView。

現在有兩個事務id分別為10、20的事務在執行:

#Transaction 10
BEGIN;
update student1 set name = '李四' where id = 1;
update student1 set name = '王五' where id = 1;



#Transaction 20
BEGIN;
#修改了別的表記錄

說明:事務執行過程中,只有在第一次真正修改記錄時(比如使用INSERT、DELETE、UPDATE語句),才會被分配一個單獨的事務id,這個事務id是遞增的。所以我們才在事務20中更新一些別的表的記錄,目的是讓它分配事務id。

此時,表student中id1的記錄得到的版本連結串列如下所示:

假設現在有一個使用READ COMMITTED隔離級別的事務開始執行:

# 使用READ COMMITTED隔離級別的事務
BEGIN;

# SELECT1:Transaction 10、20未提交
SELECT * FROM student WHERE id = 1; # 得到的列name的值為'張三'

這個SELECT1的執行過程如下:

步驟1:在執行SELECT語句時會先生成一個ReadView,ReadView的trx_ids列表的內容就是[10,20]up_limit_id10low_limit_id21 , creator_trx_id0

步驟2:從版本鏈中挑選可見的記錄,從圖中看出,最新版本的列name的內容是'王五',該版本的trx_id值為10,在trx_ids列表內,所以不符合可見性要求,根據roll_pointer跳到下一個版本。

步驟3∶下一個版本的列name的內容是'李四',該版本的trx_id值也為10,也在trx_ids列表內,所以也不符合要求,繼續跳到下一個版本。
步驟4:下一個版本的列name的內容是'張三',該版本的trx_id值為8,小於ReadView中的up_limit_id10,所以這個版本是符合要求的,最後返回給使用者的版本就是這條列name'張三'的記錄。

之後,我們把事務id為10的事務提交一下:

# Transaction 10
BEGIN;

UPDATE student SET name="李四" WHERE id=1;
UPDATE student SET name="王五" WHERE id=1;

COMMIT;

然後再到事務id為20的事務中更新一下表studentid1的記錄:

# Transaction 20
BEGIN;
# 更新了一些別的表的記錄
...
UPDATE student SET name="錢七" WHERE id=1;
UPDATE student SET name="宋八" WHERE id=1;

此刻,表student中 id 為 1 的記錄的版本鏈就長這樣:

然後再到剛才使用READ COMMITTED隔離級別的事務中繼續查詢這個id為1的記錄,如下:

# 使用READ COMMITTED隔離級別的事務
BEGIN;

# SELECT1:Transaction 10、20均未提交
SELECT * FROM student WHERE id = 1; # 得到的列name的值為'張三'

# SELECT2:Transaction 10提交,Transaction 20未提交
SELECT * FROM student WHERE id = 1; # 得到的列name的值為'王五'

這個SELECT2的執行過程如下:

步驟1:在執行SELECT語句時會又會單獨生成一個ReadView,該ReadView的trx_ids列表的內容就是[20],up_limit_id20low_limit_id21, creator_trx_id0

步驟2:從版本鏈中挑選可見的記錄,從圖中看出,最新版本的列name的內容是'宋八',該版本的trx_id值為20,在trx_ids列表內,所以不符合可見性要求,根據roll_pointer跳到下一個版本。

步驟3:下一個版本的列name的內容是'錢七',該版本的trx_id值為20,也在trx_ids列表內所以也不符合要求,繼續跳到下一個版本。

步驟4∶下一個版本的列name的內容是'王五',該版本的trx_id值為10,小於ReadView中的up_limit_id20,所以這個版本是符合要求的,最後返回給使用者的版本就是這條列name為'王五'的記錄。

以此類推,如果之後事務id20的記錄也提交了,再次在使用READ COMMITTED隔離級別的事務中查詢表studentid值為1的記錄時,得到的結果就是'宋八'了,具體流程我們就不分析了。

強調:使用READ COMMITTED隔離級別的事務在每次查詢開始時都會生成一個獨立的ReadView,

5.2 REPEATABLE READ隔離級別的事務

和READ COMMITED類似,只是在REPEATABLE READ隔離級別下,只有第一次訪問相同的資料時,會生成一次Read View,所以在執行SELECT2語句時,查出的資料還是'張三'

5.3 如何解決幻讀

接下來說明InnoDB 是如何解決幻讀的。

假設現在表 student 中只有一條資料,資料內容中,主鍵 id=1,隱藏的 trx_id=10,它的 undo log 如下圖所示。


假設現在有事務 A 和事務 B 併發執行,事務 A的事務 id 為20事務 B的事務 id 為30

步驟1:事務 A 開始第一次查詢資料,查詢的 SQL 語句如下。

select * from student where id >= 1;

在開始查詢之前,MySQL 會為事務 A 產生一個 ReadView,此時 ReadView 的內容如下: trx_ids= [20,30]up_limit_id=20low_limit_id=31creator_trx_id=20

由於此時表 student 中只有一條資料,且符合 where id>=1 條件,因此會查詢出來。然後根據 ReadView機制,發現該行資料的trx_id=10,小於事務 A 的 ReadView 裡 up_limit_id,這表示這條資料是事務 A 開啟之前,其他事務就已經提交了的資料,因此事務 A 可以讀取到。

結論:事務 A 的第一次查詢,能讀取到一條資料,id=1。

步驟2:接著事務 B(trx_id=30),往表 student 中新插入兩條資料,並提交事務。

insert into student(id,name) values(2,'李四');
insert into student(id,name) values(3,'王五');


步驟3:接著事務 A 開啟第二次查詢,根據可重複讀隔離級別的規則,此時事務 A 並不會再重新生成ReadView。此時表 student 中的 3 條資料都滿足 where id>=1 的條件,因此會先查出來。然後根據ReadView 機制,判斷每條資料是不是都可以被事務 A 看

1)首先 id=1 的這條資料,前面已經說過了,可以被事務 A 看到。

2)然後是 id=2 的資料,它的 trx_id=30,此時事務 A 發現,這個值處於 up_limit_id 和 low_limit_id 之間,因此還需要再判斷 30 是否處於 trx_ids 陣列內。由於事務 A 的 trx_ids=[20,30],因此在陣列內,這表示 id=2 的這條資料是與事務 A 在同一時刻啟動的其他事務提交的,所以這條資料不能讓事務 A 看到。

3)同理,id=3 的這條資料,trx_id 也為 30,因此也不能被事務 A 看見。


結論:最終事務 A 的第二次查詢,只能查詢出 id=1 的這條資料。這和事務 A 的第一次查詢的結果是一樣的,因此沒有出現幻讀現象,所以說在 MySQL 的可重複讀隔離級別下,不存在幻讀問題。

6.總結


這裡介紹了MVCCREAD COMMITTDREPEATABLE READ這兩種隔離級別的事務在執行快照讀操乍時訪問記錄的版本鏈的過程。這樣使不同事務的讀-寫寫-讀操作併發執行,從而提升系統效能。

核心點在於ReadView的原理,READ COMNITTDREPEATABLE READ這兩個隔離級別的一個很大不同就是生成ReadView的時機不同:

  • READ COMMITTD在每一次進行普通SELECT操作前都會生成一個ReadView。
  • REPEATABLE READR只在第一次進行普通SELECT操作前生成一個ReadView,之後的查詢操作都重複使用這個ReadView就好了。

說明:我們之前說執行DELETE語句或者更新主鍵的UPDATE語句並不會立即把對應的記錄完全從頁面中刪除,而是執行一個所謂的delete mark操作,相當於只是對記錄打上了一個刪除標誌位,這主要就是為MVCC服務的。

透過MVCC 我們可以解決:

  • 讀寫之間阻塞的問題。透過MVCC可以讓讀寫互相不阻塞,即讀不阻塞寫,寫不阻塞讀,這樣就可以提升事務併發處理能力。
  • 降低了死鎖的機率。這是因為MVCC採用了樂觀鎖的方式,讀取資料時並不需要加鎖,對於寫操作,也只鎖定必要的行。
  • 解決快照讀的問題。當我們查詢資料庫在某個時間點的快照時,只能看到這個時間點之前事務提交更新的結果,而不能看到這個時間點之後事務提交的更新結果。

只是為了記錄自己的學習歷程,且本人水平有限,不對之處,請指正。

相關文章