《MySQL 進階篇》二十一:MVCC

ACatSmiling發表於2024-09-22

Author: ACatSmiling

Since: 2024-09-21

什麼是 MVCC

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

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

快照讀與當前讀

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

快照讀

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

SELECT * FROM player WHERE ...;

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

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

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

當前讀

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

SELECT * FROM student LOCK IN SHARE MODE; # 共享鎖

SELECT * FROM student FOR UPDATE; # 排他鎖

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

DELETE FROM student WHERE ...; # 排他鎖

UPDATE student SET ...; # 排他鎖

注意:InnoDB 增刪改操作預設加 X 鎖,讀操作預設不加鎖。

知識點回顧

再談隔離級別

事務有 4 個隔離級別,可能存在三種併發問題:(準確來說是四種,還有一種是髒寫)

image-20240720235523060

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

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

image-20240720235653609

MySQL 中,是遵循上圖的處理方式,可重複讀和序列化兩種隔離級別,都可以解決幻讀的問題。

  • 如果隔離級別是可重複讀,採用的是 MVCC 的方式,這是 MySQL 預設的隔離級別。
  • 如果隔離級別是序列化,採用的是加鎖的方式。
  • 如果採用加鎖的方式,使用的是間隙鎖解決幻讀問題。

隱藏欄位、undo log 版本鏈

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

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

舉例:student 表資料如下。

mysql> SELECT * FROM student;
+----+--------+--------+
| id | name   | class  |
+----+--------+--------+
|  1 | 張三   | 一班    |
+----+--------+--------+
1 row in set (0.07 sec)

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

image-20240721000255201

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

假設之後兩個事務 id 分別為 10、20 的事務對這條記錄進行 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 log,每條 undo log 也都有一個 roll_pointer 屬性(INSERT 操作對應的 undo log 沒有該屬性,因為 INSERT 記錄沒有更早的版本,它自己是起始的版本),可以將這些 undo log 都連起來,串成一個連結串列:

image-20240721001216698

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

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

MVCC 實現原理之 ReadView

MVCC 的實現依賴於:隱藏欄位、undo log 版本鏈、ReadView。

什麼是 ReadView

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

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

ReadView 和事務是一對一的關係。

設計思路

使用 READ UNCONNMITTED 隔離級別的事務,由於可以讀到未提交事務修改過的記錄,所以直接讀取的記錄就是最新版本了。此時,不需要使用 MVCC,也就不需要 ReadView。

使用 SERIALIZABLE 隔離級別的事務,InnoDB 規定使用加鎖的方式來訪問記錄。此時,不需要使用 MVCC,也就不需要 ReadView。

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

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

  1. creator_trx_id:建立這個 ReadView 的事務 ID。
  2. trx_ids:表示在生成 ReadView 時,當前系統中活躍的讀寫事務的事務 id 列表。
  3. up_limit_id:活躍的事務中最小的事務 ID。
  4. low_limit_id:表示生成 ReadView 時,系統中應該分配給下一個事務的 id 值。low_limit_id 是當前系統最大的事務 id 值,這裡要注意是系統中的事務 id,需要區別於正在活躍的事務 id。

注意:low_limit_id 並不是 trx_ids 中的最大值,實際上,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 為 trx2、trx3、trx5 和 trx8 的集合,系統的最大事務 id(low_limit_id)為 trx8 + 1(如果在此之前沒有其他的新增事務),活躍的最小事務 id(up_limit_id)為 trx2。

image-20240721085100350

ReadView 的規則

有了這個 ReadView,這樣在訪問某條記錄時,只需要按照下邊的步驟判斷該記錄在 undo log 版本鏈中的某個版本是否可見:

  • 如果被訪問版本的 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_id 和 low_limit_id 之間,那就需要判斷一下 trx_id 屬性值是不是在 trx_ids 列表中。
    • 如果在,說明建立 ReadView 時生成該版本的事務還是活躍的,該版本不可以被當前事務訪問
    • 如果不在,說明建立 ReadView 時生成該版本的事務已經被提交,該版本可以被當前事務訪問

此處被訪問版本,是指 undo log 版本鏈中的版本。

MVCC 整體操作流程

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

  1. 首先,獲取事務自己的版本號,也就是事務 id;
  2. 獲取(生成)ReadView;
  3. 查詢得到的資料,然後與 ReadView 中的事務版本號進行比較;
  4. 如果不符合 ReadView 規則(當前版本不能被訪問),就需要從 undo log 中獲取歷史快照;
  5. 最後返回符合規則的資料。

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

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

在隔離級別為讀已提交(READ COMMITTED)時,一個事務中的每一次 SELECT 查詢都會重新獲取一次 ReadView。示例:

事務 說明
BEGIN;
SELECT * FROM student WHERE id > 2; 獲取一次 Read View
SELECT * FROM student WHERE id > 2; 獲取一次 Read View
COMMIT;

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

當隔離級別為可重複讀(REPEATABLE READ)的時候,就避免了不可重複讀,這是因為一個事務只在第一次 SELECT 的時候會獲取一次 ReadView,而後面所有的 SELECT 都會複用這個 ReadView。示例:

image-20240721101450225

舉例說明

假設現在 student 表中只有一條由事務 id 為 8 的事務插入的一條記錄:

mysql> SELECT * FROM student;
+----+--------+--------+
| id | name   | class  |
+----+--------+--------+
|  1 | 張三   | 一班    |
+----+--------+--------+
1 row in set (0.07 sec)

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

關於不同隔離級別下 ReadView 的事務 id,可以概括如下:

  • 對於 RC 隔離級別:
  • 在一個事務中,每次查詢會建立 id 為 0 的 ReadView。
  • 一旦有修改操作,會切換到以當前事務 id 為 creator_trx_id 的新 ReadView。
  • 對於 RR 隔離級別:
  • 在一個事務中,只有第一次的查詢會建立一個 Read View。
  • 這個 ReadView 的 creator_trx_id 就是當前的事務 id。

RR 要求整個事務的查詢都要一致,所以只有第一次查詢才會生成一個 ReadView。而 RC 可以在同一事務內讀取不同版本的資料,所以每次修改和查詢都會生成新的 ReadView。

READ COMMITTED 隔離級別下

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

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

# Transaction 10
BEGIN;
UPDATE student SET name = "李四" WHERE id = 1;
UPDATE student SET name = "王五" WHERE id = 1;

# Transaction 20
BEGIN;
# 更新了一些別的表的記錄 (為了分配事務 id)
...

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

此刻,表 student 中 id 為 1 的記錄得到的 undo log 版本鏈如下所示:

image-20240721102342138

假設現在有一個使用 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_id 為 10,low_limit_id 為 21,creator_trx_id 為 0。
  2. 步驟二:從 undo log 版本鏈中挑選可見的記錄,從圖中看出,最新版本的列 name 的內容是 '王五',該版本的 trx_id 值為 10,在 trx_ids 列表內(說明 ReadView 生成時,trx_id 為 10 的事務還是活躍的),所以不符合可見性要求,根據 roll_pointer 跳到下一個版本。
  3. 步驟三:下一個版本的列 name 的內容是 '李四',該版本的 trx_id 值也為 10,也在 trx_ids 列表內,所以也不符合要求,繼續跳到下一個版本。
  4. 步驟四:下一個版本的列 name 的內容是 '張三',該版本的 trx_id 值為 8,小於 ReadView 中的 up_limit_id 值 10,所以這個版本是符合要求的,最後,返回給使用者的版本就是這條列 name 為 '張三' 的記錄。

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

# Transaction 10
BEGIN;

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

COMMIT;

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

# Transaction 20
BEGIN;

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

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

image-20240721103731212

然後,再到剛才使用 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_id 為 20,low_limit_id 為 21,creator_trx_id 為 0。
  2. 步驟二:從 undo log 版本鏈中挑選可見的記錄,從圖中看出,最新版本的列 name 的內容是 '宋八',該版本的 trx_id 值為20,在 trx_ids 列表內,所以不符合可見性要求,根據 roll_pointer 跳到下一個版本。
  3. 步驟三:下一個版本的列 name 的內容是 '錢七',該版本的 trx_id 值為 20,也在 trx_ids 列表內,所以也不符合要求,繼續跳到下一個版本。
  4. 步驟四:下一個版本的列 name 的內容是 '王五',該版本的 trx_id 值為 10,小於 ReadView 中的 up_limit_id 值 20,所以這個版本是符合要求的,最後,返回給使用者的版本就是這條列 name 為 '王五' 的記錄。

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

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

REPEATABLE READ 隔離級別下

REPEATABLE READ:只會在第一次執行查詢語句時生成一個 ReadView,之後的查詢就不會重複生成了,而是複用這個 ReadView。

比如,系統裡有兩個事務 id 分別為 10、20 的事務在執行:

# Transaction 10
BEGIN;
UPDATE student SET name = "李四" WHERE id = 1;
UPDATE student SET name = "王五" WHERE id = 1;

# Transaction 20
BEGIN;
# 更新了一些別的表的記錄
...

此刻,表 student 中 id 為 1 的記錄得到的版本連結串列如下所示:

image-20240721110043183

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

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

# SELECT1 操作,此時,Transaction 10 和 20 未提交
SELECT * FROM student WHERE id = 1; # 得到的列 name 的值為'張三'

這個 SELECT1 的執行過程如下:

  1. 步驟一:在執行 SELECT 語句時會先生成一個 ReadView,ReadView 的 trx_ids 列表的內容就是 [10, 20],up_limit_id 為 10,low_limit_id 為 21,creator_trx_id 為 0。
  2. 步驟二:然後從 undo log 版本鏈中挑選可見的記錄,從圖中看出,最新版本的列 name 的內容是 '王五',該版本的 trx_id 值為 10,在 trx_ids 列表內,所以不符合可見性要求,根據 roll_pointer 跳到下一個版本。
  3. 步驟三:下一個版本的列 name 的內容是 '李四',該版本的 trx_id 值也為 10,也在 trx_ids 列表內,所以也不符合要求,繼續跳到下一個版本。
  4. 步驟四:下一個版本的列 name 的內容是 '張三',該版本的 trx_id 值為 8,小於 ReadView 中的 up_limit_id 值10,所以這個版本是符合要求的,最後,返回給使用者的版本就是這條列 name 為 '張三 ' 的記錄。

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

# Transaction 10
BEGIN;

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

COMMIT;

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

# Transaction 20
BEGIN;

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

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

image-20240721110539834

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

# 使用 REPEATABLE READ 隔離級別的事務
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. 步驟一:因為當前事務的隔離級別為 REPEATABLE READ,而之前在執行 SELECT1 時已經生成過 ReadView 了,所以此時直接複用之前的 ReadView,之前的 ReadView 的 trx_ids 列表的內容就是 [10, 20],up_limit_id 為 10,low_limit_id 為 21,creator_trx_id 為 0。
  2. 步驟二:然後從 undo log 版本鏈中挑選可見的記錄,從圖中可以看出,最新版本的列 name 的內容是 '宋八',該版本的 trx_id 值為 20,在 trx_ids 列表內,所以不符合可見性要求,根據 roll_pointer 跳到下一個版本。
  3. 步驟三:下一個版本的列 name 的內容是 '錢七',該版本的 trx_id 值為 20,也在 trx_ids 列表內,所以也不符合要求,繼續跳到下一個版本。
  4. 步驟四:下一個版本的列 name 的內容是 '王五',該版本的 trx_id 值為 10,而 trx_ids 列表中是包含值為 10 的事務 id 的,所以該版本也不符合要求。同理,下一個列 name 的內容是 '李四' 的版本也不符合要求,繼續跳到下一個版本。
  5. 步驟五:下一個版本的列 name 的內容是 '張三',該版本的 trx_id 值為 8,小於 ReadView 中的 up_limit_id 值 10,所以這個版本是符合要求的,最後,返回給使用者的版本就是這條列 name 為 '張三' 的記錄。

兩次 SELECT 查詢得到的結果是重複的,記錄的列 name 值都是 '張三',這就是可重複讀的含義。如果我們之後再把事務 id 為 20 的記錄提交了,然後再到剛才使用 REPEATABLE READ 隔離級別的事務中,繼續查詢這個 id 為 1 的記錄,得到的結果還是 '張三',具體執行過程大家可以自己分析一下。

如何解決幻讀

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

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

image-20240721120852189

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

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

SELECT * FROM student WHERE id >= 1;

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

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

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

步驟二:接著事務 B,往表 student 中新插入兩條資料,並提交事務。

INSERT INTO student(id, name) VALUES(2, '李四');
INSERT INTO student(id, name) VALUES(3, '王五');

此時,表 student 中就有三條資料了,對應的 undo log 如下圖所示:

image-20240721121549931

步驟三:接著事務 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 看見。

如下圖所示:

image-20240721122343548

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

總結

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

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

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

說明:之前說執行 DELETE 語句或者更新主鍵的 UPDATE 語句,並不會立即把對應的記錄完全從頁面中刪除,而是執行一個所謂的delete mark操作(標記 0 -> 1),相當於只是對記錄打上了一個刪除標誌位,這主要就是為 MVCC 服務的。另外後面回滾也可能用到這個 delete mark。

透過 MVCC 可以解決:

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

原文連結

https://github.com/ACatSmiling/zero-to-zero/blob/main/RelationalDatabase/mysql-advanced.md

相關文章