mysql MVCC 介紹

hahadelphi發表於2021-09-09

簡介

MVCC (multiversion concurrency control),多版本併發控制,主要是透過在每一行記錄中增加三個欄位,與 undo log 中相關記錄配合使用,同時加上可見性演算法,使得各個事務可以在不加鎖的情況下能夠同時地讀取到某行記錄上的準確值(這個值對不同的事務而言可能是不同的)。使用 MVCC,在不加鎖的情況下也能讀取到準確的資料,大大提高了併發效率。

事務

提到 MVCC,必須提到事務。關於事務,有四個特性,即我們常說的 ACID。

  • 原子性(Atomicity):表示事務要麼全部執行,要麼全部不執行,這是一個不可分割的最小單元
  • 一致性(Consistency):表示事務總是從一個一致的狀態轉移到另一個一致的狀態
  • 隔離性(Isolation):表示各個事務之間相關隔離,互不影響
  • 永續性(Durability):指一個事務一旦被提交,它對資料庫的改變就是永久性的,即使後續資料庫發生故障也不會有影響

而事務隔離性又分為四種級別:讀未提交(read uncommitted)、讀提交(read committed)、可重複讀(repeatable read)、序列化(serializable)。

  • 讀未提交:指一個事務還沒有提交,它本身所做的修改就能被其他事務所看到。在這種情況下,會產生髒讀、幻讀和不可重複讀的問題。
  • 讀提交:指一個事務提交之後,它本身所做的修改就能被其他事務所看到。在這種情況下,解決了「讀未提交」的髒讀問題,但是仍然會產生幻讀和不可重複讀的問題。
  • 可重複讀:指在同一個事務之中,讀到的資料是一致的。這種隔離級別下,可以解決髒讀和不可重複讀的問題,但是仍然存在幻讀的問題。
  • 序列化:指多個事務中,如果讀寫鎖衝突時,後訪問的事務必須等前一個事務執行完成後才能繼續執行。這種隔離級別最高,也解決了髒讀、幻讀和不可重複讀的問題。但是其也大大限制了併發的程度。

關於這四種隔離級別的差異,可以透過以下例子(例子來源於:)來加以說明。

假設存在一張表,裡面只有一個欄位和一條記錄,值是 1,現在發生以下的操作

時刻 事務A 事務B
t1 啟動事務,查詢得到值 1
t2 啟動事務
t3 查詢得到值 1
t4 將 1 改成 2
t5 查詢得到值 V1
t6 提交事務
t7 查詢得到值 V2
t8 提交事務
t9 查詢得到值 V3

針對不同的隔離級別,V1、V2、V3 讀到的值不同。

在「讀未提交」的隔離級別下,由於 t4 時刻事務 B 將值改成了 2,雖然 B 還沒提交事務,但是此時的修改對其他事務是可見的,所以 V1、V2、V3 查詢到的值都是 2。

在「讀提交」的隔離級別下,t4 時刻修改了值,但是在 t5 時刻,事務 B 還沒有提交,此時事務 A 讀取到的值還是老的值,所以 V1 是 1,而在 t7 時刻,由於事務 B 已經在 t6 時刻提交了,此時事務 B 所做的修改對其他的事務都可見,所以事務 A 在 t7 時刻能看到事務 B 的修改,此時 V2 的值為 2,當然 V3 的值也為 2。

在「可重複讀」的隔離級別下,遵循 “事務在執行期間看到的資料必須是前後一致” 的要求,所以無論事務 B 是否修改值,也無論事務 B 是否提交,事務 A 在沒提交前讀到的值都是相同的,即 V1 和 V2 的值都是 1,當 A 事務提交後,再次查詢時,事務 B 的修改就能被 A 看到了,所以 V3 的值為 2。

在「序列化」的隔離級別下,當事務 B 在 t4 時刻執行更新時,由於與事務 A 操作的是同一行,且出現讀寫衝突,此時事務 B 被會阻塞,等待事務 A 執行完畢後,再執行事務 B,所以 V1 和 V2 的值是 1,V3 的值是 2。

MVCC

更新操作

在資料庫表的記錄中,每一個記錄都會新增三個欄位:

  • DB_TRX_ID:6個位元組,表示最近一次修改本記錄的事務ID

  • DB_ROLL_PTR :7 個位元組,回滾指標,指向回滾段中的 undo log record,用於找出這個記錄的上個修改版本的資料。

  • DB_ROW_ID:6 個位元組,一個單調遞增的 ID,確定表中記錄的唯一性。

當對某個記錄進行更新時,會將當前記錄寫入 undo log 中,並更新當前記錄中 DB_ROLL_PTR 欄位值,使其指向剛才的 undo log record,然後更新當前記錄相關欄位值,同時更新 DB_TRX_ID 欄位,記錄執行更新操作的事務 ID。簡略的更新過程大致如下所示

圖片描述

查詢操作

由上面的更新操作可以得知,資料庫表記錄始終記錄著最新的更新結果,那對於「可重複讀」和「讀提交」的隔離級別的事務,它是如何保證在開啟本事務後,其他事務對記錄進行了更新操作,而本事務仍然能夠讀取到準確的值(不是表記錄的最新值,而是歷史版本的值)的?從更新操作中可以得知,透過迴圈遍歷 DB_ROLL_PTR 可以拿到當前記錄的歷史版本(當然,只是活躍的事務,如果當前記錄沒有相關事務在操作,則會清理 undo log,就不能拿到歷史版本資料了) 。但是這麼多歷史版本的資料,究竟哪個版本的資料才是當前事務所要的呢?這時就要判斷當前版本的資料是否對當前事務可見了。

在開啟事務時,會將當前活躍的事務(已經開啟了事務,但是還沒有提交)的事務 ID 放在一個陣列裡面,同時記錄陣列裡面最小的事務 ID 為「低水位」,記錄當前系統已經建立的事務ID 的最大值加一為「高水位」。這三者組成了一個事務的一致性檢視(read-view)。當事務要查詢某個記錄的資料時,實際上就是拿該記錄的事務ID(包括歷史版本的事務ID)和這個一致性檢視進行比較,直到某個版本的資料是可見的為止。其查詢過程如下:

  • 讀取的記錄的事務ID小於低水位,說明這個版本的資料在開啟本事務前已經提交,是可見的,直接返回這個資料
  • 讀取的記錄的事務ID大於高水位,說明這個版本的資料在開啟本事務後提交的,不可見,從記錄中取出 DB_ROLL_PTR 指向的記錄並讀取其事務 ID,開始下一輪的判斷
  • 讀取的記錄的事務ID介於低水位和高水位中間,此時判斷事務ID是否在一致性檢視的事務陣列中:
    • 如果不在,說明這個版本的資料在開啟本事務前已經提交,是可見的,直接返回這個資料
    • 如果在,說明這個版本的資料是由開啟事務後的其他活躍事務提交的,對本事務是不可見的,因此需要從記錄中取出 DB_ROLL_PTR 指向的記錄並讀取其事務 ID,開始下一輪的判斷

其判斷過程的流程圖大致如下所示
圖片描述

關於判斷資料可見性,除了上述用高水位、低水位和事務檢視陣列結合判斷之外,可以簡化成以下規則判斷:

  • 對於當前事務中的資料,可見
  • 對於其他事務中的資料
    • 如果版本未提交,不可見
    • 如果版本已經提交,且是在建立本事務檢視後提交的,不可見
    • 如果版本已經提交,且是在建立本事務檢視前提交的,可見

例子

現在用一個例子(此例子來自:)來對上述查詢過程進行說明。假設在「可重複讀」的隔離級別下,有以下的表結構和資料。

mysql> CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `k` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);

假設進行以下的操作(事務C 的 update 操作完即自動提交事務),在進行以下操作前,假設當前活躍的事務 ID 為 99,記錄(1,1)的 DB_TRX_ID 值是 90。則事務 A 的檢視陣列是 [99, 100],事務 B 的檢視陣列是 [99, 100, 101],事務 C 的檢視陣列是 [99, 100, 101, 102]

事務A(事務ID:100) 事務B(事務ID:101) 事務C(事務ID:102)
start transaction with consistent snapshot;
start transaction with consistent snapshot;
update t set k = k + 1 where id = 1;
update t set k = k + 1 where id = 1;
select k from t where id = 1;
select k from t where id = 1;
commit;
commit;

當事務 A 執行查詢語句時,其查詢資料邏輯圖(此圖來自:)如下所示

圖片描述

其查詢過程如下,首先,獲取記錄的事務ID(101),比高水位大,不可見,所以取出記錄的上一個歷史版本,獲取其事務ID(102),比高水位大,不可見,再獲取記錄的上一個歷史版本,獲取其事務ID(90),比低水位小,可見,所以返回這個記錄中的 k 欄位的值 1。

當然,也可以用簡化版本來判斷。過程如下,首先,獲取記錄(1,3),還沒有提交,不可見,取出上一個歷史版本(1,2),(1,2)已經提交,但是在本事務檢視建立後提交的,不可見,繼續取出上一個歷史版本(1,1),(1,1)已經提交,且是在本事務檢視建立前提交的,可見,所以最終返回 k 的值是 1。

此處需要額外關注的是,事務 B 的更新操作,是在當前記錄的最新值上更新的,並不是在歷史資料上更新的,否則會丟失事務 B 的更新操作。其實,更新資料都是先讀後寫的,而且這個讀,是讀的當前值,稱為“當前讀”。

如果是在「讀提交」的隔離級別下,處理邏輯類似,只是生成一致性檢視的情況不同:

  • 在「可重複度」隔離級別下,只需要在事務開始的時間建立一致性檢視,之後事務裡的其他查詢都共用這個一致性檢視
  • 在「讀提交」隔離級別下,每一個語句執行前都會重新算出一個新的檢視

所以上述例子,如果是在「讀提交」隔離級別下,事務 A 在執行查詢語句時,會建立新的一致性檢視,此時一致性檢視中的活躍事務ID陣列是 [99, 100, 101],其查詢過程如下,讀取當前記錄事務 ID(101),在檢視陣列中,不可見,取出上一個歷史版本記錄,讀取事務ID(102),介於低水位和高水位之間,且不在檢視陣列中,可見,所以返回記錄的 k 值 2。

其他

  • 四種隔離級別,只有「讀提交」和「可重複度」兩個隔離級別能夠使用 MVCC,因此也只有這兩個隔離級別會建立一致性檢視(read-view)。因為「讀提交」隔離級別下每次都是讀取的最新記錄,所以不用 MVCC,也不用建立一致性檢視;「序列化」隔離級別,則是用加鎖方式來實現併發的,也不用 MVCC ,所以也不用建立一致性檢視。關於「可重複度」和「讀提交」兩個隔離級別下一致性檢視的差別,主要體現在:「可重複度」隔離級別下的一致性檢視是在啟動事務時建立的,建立後,本事務共用一個檢視;而「可讀提交」隔離級別下的一致性檢視是在執行 SQL 時建立的,每一個 SQL 都會單獨建立一個檢視,並不會共用。
  • 當前讀(current read),每次讀取的都是記錄的最新資料,主要包含以下 SQL 語句
    • select … lock in share mode
    • select … for update
    • insert
    • update
    • delete
  • 快照讀(snapshot read),可能讀取記錄的歷史版本資料,主要用於 MVCC 中的簡單的 select (不包括 select … lock in share mode,select … for update),保證事務讀取的一致性。

參考資料

[1] 林曉斌. 事務隔離:為什麼你改了我還看不見?[J/OL]. ,2018-11-19
[2] 林曉斌. 事務隔離:事務到底是隔離的還是不隔離的?[J/OL]. ,2018-11-30
[3] MySQL官方文件: https://dev.mysql.com/doc/refman/8.0/en/innodb-multi-versioning.html

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2471/viewspace-2824096/,如需轉載,請註明出處,否則將追究法律責任。

相關文章