對線面試官:通過MVCC資料庫事務的一致性

PHPer技術棧發表於2022-05-16

undo 日誌連結串列

我們在前面介紹 undo 日誌的時候提到過,當 InnoDB 引擎底層開啟一個新事務的時候,會分配一個全域性唯一的事務 ID,該事務 ID 寫入 undo 日誌的同時也會儲存到資料表記錄簇擁索引的 trx_id 隱藏列中:

-w531

另一個隱藏列 roll_pointer 指標會指向該記錄上一個版本的 undo 日誌,發生事務回滾時我們可以通過該指標找到這條記錄要回滾到的版本。

這麼說有點抽象,我們舉個具體的例子,還是以小明和小強之間的支付寶錢包轉賬為例。假設當前小明賬戶餘額如下(470 元):

-w853

分別在兩個事務中執行更新操作:

SQL 語句執行序列 事務 A 事務 B
1 BEGIN;
2 BEGIN;
3 UPDATE walltes SET balance = balance - 2000 WHERE id = 1;
4 UPDATE walltes SET balance = balance + 5000 WHERE id = 1;
5 COMMIT;
5 UPDATE walltes SET balance = balance + 8000 WHERE id = 1;
6 COMMIT;

事務 A 對應的操作是小明先花了 20 塊錢,錢包又充值了 50 塊錢,假設事務 ID 是 1000,事務 B 對應的操作時小強給小明轉賬了 80 塊錢,假設 事務 ID 是 1200。

現在我們繪製出 wallets 簇擁索引中這條的記錄和對應的 undo 日誌,相應的結構如下所示:

-w875

可以看到,每次記錄更新後,上一個版本的值就會被存放到 undo 日誌中,並且將當前最新記錄的 roll_pointer 指標指向該 undo 日誌,這樣一來,所有的 roll_pointer 指標串成一個連結串列,該連結串列被稱作版本鏈,版本鏈的頭節點就是當前記錄最新版本的值。

版本鏈本質上就是個 undo 日誌連結串列

在繼續深入介紹之前,我們先來看看不同隔離級別下當前事務可以讀取到的最新版本記錄的區別:

  • READ UNCOMMITTED:對於使用該隔離級別的事務來說,由於可以讀到未提交事務修改過的記錄,所以直接讀取記錄的最新版本就好了,不管這個事務是不是當前事務(由於存在髒讀問題,一般不會使用這種隔離級別);
  • SERIALIZABLE:對於使用該隔離級別的事務來說,InnoDB 底層會使用加鎖的方式來訪問記錄,具體細節後面講到鎖的時候介紹(由於效能問題,一般也不會使用這種隔離級別);
  • READ COMMITTED 和 REPEATABLE READ:對於使用這兩種隔離級別的事務來說,只能讀取已提交事務的修改記錄,也就是說如果另一個事務修改了記錄但尚未提交,是不能直接讀取它的最新版本記錄的。

排除 READ UNCOMMITTED 和 SERIALIZABLE,我們重點關注 READ COMMITTED 和 REPEATABLE READ,MySQL 底層是如何保證這兩種隔離級別事務可以正確讀取對應的版本記錄的呢?

結合上面的版本鏈就很好理解了:只需要判斷版本鏈中哪個版本是當前事務可見的即可。

為了解決這個問題,InnoDB 引擎設計了 ReadView(可讀檢視) 的概念。

ReadView

判斷記錄的可見性

ReadView 實際上是當前系統中所有活躍事務的列表,主要包含以下組成部分:

  • m_ids:在生成 ReadView 時當前系統中活躍的事務 ID 列表;
  • min_trx_id:在生成 ReadView 時當前系統中活躍的事務中最小的事務 ID,也就是 m_ids 中的最小值;
  • max_trx_id:在生成 ReadView 時系統中應該分配給下一個事務的 ID 值;
  • creator_trx_id:生成 ReadView 的事務對應的事務 ID,也就是當前事務 ID。

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

  • 如果被訪問版本的 trx_id 屬性值與 ReadView 中的 creator_trx_id 值相同,意味著當前事務在訪問它自己修改過的記錄,所以該版本記錄可以被當前事務訪問。
  • 如果被訪問版本的 trx_id 屬性值小於 ReadView 中的 min_trx_id 值,表明生成該版本的事務在當前事務生成 ReadView 前已經提交,所以該版本記錄可以被當前事務訪問。
  • 如果被訪問版本的 trx_id 屬性值大於或等於 ReadView 中的 max_trx_id 值,表明生成該版本的事務在當前事務生成 ReadView 後才開啟,所以該版本記錄不可以被當前事務訪問。
  • 如果被訪問版本的 trx_id 屬性值在 ReadView 的 min_trx_idmax_trx_id 之間,那就需要判斷一下 trx_id 屬性值是不是在 m_ids 列表中,如果在,說明建立 ReadView 時生成該版本的事務還是活躍的,該版本不可以被訪問;如果不在,說明建立 ReadView 時生成該版本的事務已經被提交,該版本記錄可以被訪問。
  • 如果某個版本的記錄對當前事務不可見的話,那就順著版本鏈找到下一個版本的資料,繼續按照上邊的步驟判斷可見性,依此類推,直到版本鏈中的最後一個版本。如果最後一個版本也不可見的話,那麼就意味著該條記錄對該事務完全不可見,查詢結果就不包含該記錄。

那 ReadView 又是何時生成的呢?

對於 READ COMMITTED 和 REPEATABLE READ 兩種隔離級別而言,最大的區別就是 ReadView 的生成時機不同。

ReadView 的生成時機

  • 在 READ COMMITTED 隔離級別下,每個 SELECT 語句開始時,都會重新將當前系統中的所有的活躍事務拷貝到一個列表生成 ReadView。

  • 在 REPEATABLE READ 隔離級別下,每個事務執行第一個 SELECT 語句時,會將當前系統中的所有的活躍事務拷貝到一個列表生成 ReadView,後續所有的 SELECT 都是複用這個 ReadView。

所以結合 READ COMMITTED 隔離級別下 ReadView 的生成時機,以及如何基於 ReadView 判斷記錄的可見性,也就不難理解為什麼 READ COMMITTED 隔離級別下會出現不可重複讀了吧。因為每次 SELECT 語句執行之前都會重新生成新的 ReadView,對應的 m_ids 會不斷納入新提交事務的 ID,從而導致每次 SELECT 的查詢結果不一樣,進而出現不可重複讀。

而 REPEATABLE READ 隔離級別下,只有第一次 SELECT 才會生成 ReadView,後續 SELECT 都會複用這個 ReadView,也就不存在新提交事務對這個 ReadView 的影響了。

MVCC 機制

所謂的 MVCC(Multi-Version Concurrency Control,多版本併發控制)指的就是在使用 READ COMMITTD 和 REPEATABLE READ 這兩種隔離級別的情況下,事務在執行普通SELECT 操作時訪問資料庫記錄的版本鏈的過程,這樣一來,我們就可以不通過加鎖,而是通過 MVCC 機制使得不同事務的讀寫操作可以併發執行,從而提升 MySQL 系統在併發場景下的吞吐效能。

以下面兩個事務為例:

事務 A 可以和事務 B 併發執行,在事務 A 中可以讀取到事務 B 提交的更改,而不需要在事務 B 執行之後再執行事務 A(這是序列化),並且不管是 READ COMMITTED 和 REPEATABLE READ 隔離級別,都可以讀取到,因為事務 A 第一次執行 SELECT 語句的時候,事務 B 已經提交了,此時生成的 ReadView 不包含事務 B 對應的事務 ID。

可以看到,在不同的隔離級別下,MySQL 通過 MVCC 讓事務之間的並行操作遵循了某種規則,從而保證單個事務內前後資料的一致性。這個規則就是當前事務的查詢可以看到自己之前所有已提交的事務所做的更改,而看不到未提交的事務所做的更改或者在查詢開始之後提交的事務所做的更改,這種基於時間點的查詢快照也被稱作一致性快照讀

有人會說 READ COMMITTED 隔離級別下是可以看到後續事務提交的更改的,這是因為該隔離級別下每次 SELECT 查詢都會重新整理並讀取最新的資料庫快照(已提交事務所做的更改);而在 REPEATABLE READ 隔離級別下,同一個事務中所有 SELECT 查詢都會基於第一次查詢生成的快照,如果要重新整理快照,必須提交該事務然後通過新的查詢獲取最新查詢快照(這裡的查詢快照可以對應前面的 ReadView)。從這個角度看 READ COMMITTD 和 REPEATABLE READ 兩種隔離級別的區別在於查詢快照重新整理策略不同,不過本質上和 ReadView 生成時機不同是一樣的。

  • 注:在 MySQL InnoDB 引擎中,只有 READ COMMITTD 和 REPEATABLE READ 這兩種隔離級別才可以使用 MVCC,應對高併發事務,MVCC 比單純的加行鎖更有效,開銷更小。
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章