MySQL多版本併發控制——MVCC機制分析

數小錢錢的種花兔 發表於 2021-01-15

MVCC,即多版本併發控制(Multi-Version Concurrency Control)指的是,通過版本鏈維護一個資料的多個版本,使得讀寫操作沒有衝突,可保證不同事務讀寫、寫讀操作併發執行,提高系統效能。

實際上,innodb中“讀已提交”和“可重複讀”這兩種隔離級別的事務在查詢資料時訪問版本鏈的過程,是基於這套原理。本文將總結MVCC機制底層原理,並解釋它是如何解決“髒讀”和“不可重複讀”問題的。

感覺現在每總結一個知識點,總是會引出一堆相關知識,學習真的是永無止境~。首先介紹一下幾種併發事務問題,和四種隔離級別,這與後文原理介紹密不可分。而且,畢竟都是面試高頻考點,尊重一下。

併發事務帶來的問題

  • 髒讀:表示一個事務讀到另一個事務未提交的資料。若另一個事務回滾,那本事務讀到的資料跟資料庫中的不一致;
  • 可重複讀:表示一個事務讀到另一個事務已提交的資料。本事務在另一個事務提交前和提交後讀到的資料不一致;
  • 幻讀:其它事務插入資料的前後,當前事務兩次讀取的資料不一致;
  • 丟棄修改:兩個事務先同時讀取一個資料,讀到一樣的資料,然後事務一先修改,事務二再修改,事務一的修改被丟棄。

事務的四種隔離級別

  • 讀未提交 READ-UNCOMMITTED:一個事務能讀到其它事務未提交的資料,即髒讀。也會出現不可重複讀和幻讀。
  • 讀已提交 READ-COMMITTED:一個事務只能讀到其它事務已提交的資料,不會出現髒讀,但是有幻讀和不可重複讀
    • 其它事務提交修改語句的前後,當前事務兩次讀取的資料可能不一樣。不稱之為,不可重複讀;
    • 其它事務提交插入語句前後,當前事務可能會把新插入的資料也讀出來。稱之為,幻讀;
  • 可重複讀 REPEATABLE-READ(MySQL預設使用的隔離級別):對一個資料讀取多次記錄是相同的。sql標準裡,REPEATABLE-READ禁止了髒讀和不可重複讀,可能會有“幻讀”。但是MySQL中REPEATABLE-READ也禁止了幻讀
  • 序列化 SERIALIZABLE:前三種都允許讀-讀、讀-寫、寫-讀的併發操作,但SERIALIZABLE中不允許讀-寫、寫-讀的併發操作,而是序列的,不會出現各種問題

innodb中採用了next-key-lock鎖演算法避免了幻讀,使得“可重複讀”級別也達到了“序列化”級別的效果

MVCC機制

我們先設定一個場景:

假設資料庫表中存在一條記錄row_old,這時事務A和事務B同時begin,事務A將該記錄修改為了row_new,事務B讀取行記錄,事務A提交,事務B再次讀取這條行記錄。

本文中將使用該場景來分析“髒讀”和“不可重複讀”現象。

若事務B在A提交前讀到row_new,即出現“髒讀”現象;若事務B在A提交後讀到row_new,即出現“不可重複讀”現象。

但是,正常情況是,無論事務A是否提交,事務B讀取該條記錄,都只能讀出row_old。

什麼方法可以達到這種效果呢?可以很直觀地想到,將事務A修改後的版本存起來。那麼又有一系列問題,如何存,用什麼結構來存?版本鏈便是為此而引入的。

版本鏈

版本鏈,實際上就是一條儲存多個版本行記錄的連結串列。資料庫中的每一行資料都對應一個版本鏈。連結串列中每一個結點代表一個行記錄。行記錄中有兩個重要的隱藏欄位:

  • trx_id:記錄修改成當前版本的事務編號;
  • roll_pointer:指向上一個版本的指標,即回滾指標。

版本鏈的最底層即為資料表中最原始的行記錄,上層儲存各個事務修改後的行記錄,逐個用回滾指標相連線。版本鏈示意圖如下所示:
image
還有一個問題,版本鏈是儲存在哪的?沒錯,我們熟悉的undo log回滾日誌就是用來儲存版本鏈的 。

一致性檢視

如果當前事務修改一條記錄,這條更新過的記錄被記錄到版本鏈中,對於當前事務而言,由於自身事務id和版本鏈中最新一條行記錄的trx_id相匹配,所以可以將其讀取出來。但是對於其它事務而言,是不希望能讀出這條記錄的,而是希望它能順著版本鏈,找出自己需要的版本的行記錄。

那麼如何找到正確的版本?這裡涉及到一個快照機制。事務在執行select語句時,會生成一個一致性檢視:read-view,相當於一個快照,記錄正在活躍的事務的編號。

read-view裡面包含一個陣列,m_ids,該陣列記錄(產生快照的這一時刻)版本鏈中未提交的每個版本的trx_id組成的序列。同時,read-view還會記錄一個最大已建立事務id,即 max_id,以及陣列中最小id即 min_id。查詢版本鏈時,會將行記錄中的trx_id與read-view中的max_id、min_id、m_ids[]等進行比對。依據如下版本比對規則來進行比對。

版本鏈比對規則

  1. 如果trx_id小於min_id,說明該版本是已提交事務生成的,資料可見;
  2. 如果trx_id大於max_id,說明該版本是將來啟動的事務生成的,資料不可見;
  3. 如果min_id<=trx_id<=max_id,就包括兩種情況:
    • trx_id在m_ids陣列中:表示這個版本是未提交事務生成的,資料不可見,本事務可見;
    • trx_id不在m_ids陣列中:表示這個版本是已提交事務生成的,資料可見。

補充:刪除的原理
刪除可以認為是update的特殊情況。假如要刪除一行記錄,會將版本鏈上最新一條記錄複製一份,將行格式頭資訊中(record header)裡面的(deleted flag)標誌位置為true,表示當前記錄已被刪除。若順著版本鏈訪問到這條記錄,(deleted flag)標誌位為true,表示記錄已刪除,不返回資料。

相關分析

“髒讀”分析

讓我們再回到前文提到的場景:事務A將行記錄row_old修改為了row_new,未提交時,row_new行記錄已經加入到了版本鏈,並且記錄了事務A的id。此時事務B開始查詢,生成快照read-view,其中的m_ids記錄了未提交版本的trx_id,包括row_new的id。當查詢到row_new時,其trx_id在m_ids陣列中,根據版本鏈比對規則,其對B事務不可見,因此繼續向下查詢,直到找出row_old。

綜上所述,read-view快照機制加上版本鏈匹配規則,可以杜絕“髒讀”現象。

“讀已提交”和“可重複讀”區別

根據上文的分析,我們對MVCC機制有了一個清晰的瞭解。在“讀已提交”隔離級別就是基於這個原理來解決“髒讀”問題的。而“可重複讀”隔離級別卻與之不盡相同,差別如下:

  • 讀已提交:每次select時都會生成一個readView;
  • 可重複讀:只在事務的第一次select操作前生成一個readView,之後的查詢都重複使用這個readView。

“不可重複讀”分析

再次回到上文中提到的情景,假設事務A修改將row_old修改為row_new,未提交時,事務B開始執行select,生成read-view,這時事務A進行提交,然後事務B再次select,這時依然沿用上一次的read-view,row_new的id依然是記錄在m_ids陣列中的,所以事務B只能讀取到row_old,兩次讀取都只能讀出row_old。

這裡我希望再補充一種情況:B事務尚未提交結束時,再開啟一個事務C,修改row_new為row_new_c,並提交,這時版本鏈中新增一個row_new_c結點,記錄C的id。事務B再次select,依然只能讀取到row_old。因為在版本鏈中遍歷至row_new_c時,會觸發“版本對比規則”的第二條,該條記錄對事務B不可見,因此繼續向下查詢直到找出row_old。

所以,綜上所述,無論版本鏈發生何種改變,只要在單次事務中read-view固定不變,讀取到的資料一定是維持在同一個版本。在“可重複讀”級別中,就是通過沿用第一次read-view快照的方法,解決了“不可重複讀”問題。