你真的懂MVCC嗎?來手動實踐一下?
MVCC 是什麼?
資料庫併發控制——鎖
Multiversion (version) concurrency control (MCC or MVCC) 多版本併發控制 ,它是資料庫管理系統一種常見的併發控制。
我們知道併發控制常用的是鎖,當執行緒要對一個共享資源進行操作的時候,加鎖是一種非常簡單粗暴的方法(事務開始時給 DQL 加讀鎖,給 DML 加寫鎖),這種鎖是一種 悲觀 的實現方式,也就是說這會給其他事務造成堵塞,從而影響資料庫效能。
我來解釋一下 樂觀鎖 和 悲觀鎖 的概念。我覺得它倆主要是概念的理解。
- 悲觀鎖: 當一個執行緒需要對共享資源進行操作的時候,首先對共享資源進行加鎖,當該執行緒持有該資源的鎖的時候,其他執行緒對該資源進行操作的時候會被 阻塞
- 樂觀鎖:當一個執行緒需要對一個共享資源進行操作的時候,不對它進行加鎖,而是在操作完成之後進行判斷。(比如樂觀鎖會通過一個版本號控制,如果操作完成後通過版本號進行判斷在該執行緒操作過程中是否有其他執行緒已經對該共享資源進行操作了,如果有則通知操作失敗,如果沒有則操作成功),當然除了 版本號 還有 CAS,如果不瞭解的可以去學習一下,這裡不做過多涉及。
資料庫併發控制——MVCC
很多人認為 MVCC 就是一種 樂觀鎖 的實現形式,而我認為 MVCC 只是一種 樂觀 的實現形式,它是通過 一種 可見性演演算法 來實現資料庫併發控制。
MVCC 的兩種讀形式
在講 MVCC 的實現原理之前,我覺很有必要先去了解一下 MVCC 的兩種讀形式。
-
快照讀:讀取的只是當前事務的可見版本,不用加鎖。而你只要記住 簡單的 select 操作就是快照讀(select * from table where id = xxx)。
-
當前讀:讀取的是當前版本,比如 特殊的讀操作,更新/插入/刪除操作
比如:
select * from table where xxx lock in share mode, select * from table where xxx for update, update table set.... insert into table (xxx,xxx) values (xxx,xxx) delete from table where id = xxx 複製程式碼
MVCC 的實現原理
MVCC 使用了“三個隱藏欄位”來實現版本併發控制,我查了很多資料,看到有很多部落格上寫的是通過 一個建立事務id欄位和一個刪除事務id欄位 來控制實現的。但後來發現並不是很正確,我們先來看一看 MySQL 在建表的時候 innoDB 建立的真正的三個隱藏列吧。
RowID | DB_TRX_ID | DB_ROLL_PTR | id | name | password |
---|---|---|---|---|---|
自動建立的id | 事務id | 回滾指標 | id | name | password |
- RowID:隱藏的自增ID,當建表沒有指定主鍵,InnoDB會使用該RowID建立一個聚簇索引。
- DB_TRX_ID:最近修改(更新/刪除/插入)該記錄的事務ID。
- DB_ROLL_PTR:回滾指標,指向這條記錄的上一個版本。
其實還有一個刪除的flag欄位,用來判斷該行記錄是否已經被刪除。
而 MVCC 使用的是其中的 事務欄位,回滾指標欄位,是否刪除欄位。我們來看一下現在的表格(isDelete是我自己取的,按照官方說法是在一行開頭的content裡面,這裡其實位置無所謂,你只要知道有就行了)。
isDelete | DB_TRX_ID | DB_ROLL_PTR | id | name | password |
---|---|---|---|---|---|
true/false | 事務id | 回滾指標 | id | name | password |
那麼如何通過這三個欄位來實現 MVCC 的 可見性演演算法 呢?
還差點東西! undoLog(回滾日誌) 和 read-view(讀檢視)。
-
undoLog: 事務的回滾日誌,是 可見性演演算法 的非常重要的部分,分為兩類。
- insert undo log:事務在插入新記錄產生的undo log,當事務提交之後可以直接丟棄
- update undo log:事務在進行 update 或者 delete 的時候產生的 undo log,在快照讀的時候還是需要的,所以不能直接刪除,只有當系統沒有比這個log更早的read-view了的時候才能刪除。ps:所以長事務會產生很多老的檢視導致undo log無法刪除 大量佔用儲存空間。
-
read-view: 讀檢視,是MySQL秒級建立檢視的必要條件,比如一個事務在進行 select 操作(快照讀)的時候會建立一個 read-view ,這個read-view 其實只是三個欄位。
- alive_trx_list(我自己取的):read-view生成時刻系統中正在活躍的事務id。
- up_limit_id:記錄上面的 alive_trx_list 中的最小事務id。
- low_limit_id:read-view生成時刻,目前已出現的事務ID的最大值 + 1。
這時候,萬事俱備,只欠東風了。下面我來介紹一下,最重要的 可見性演演算法。
其實主要思路就是:當生成read-view的時候如何去拿獲取的 DB_TRX_ID 去和 read-view 中的三個屬性(上面講了)去作比較。我來說一下三個步驟,如果不是很理解可以參考著我後面的實踐結合著去理解。
- 首先比較這條記錄的 DB_TRX_ID 是否是 小於 up_limit_id 或者 等於當前事務id。如果滿足,那麼說明當前事務能看到這條記錄。如果大於則進入下一輪判斷
- 然後判斷這條記錄的 DB_TRX_ID 是否 大於等於 low-limit-id。如果大於等於則說明此事務無法看見該條記錄,不然就進入下一輪判斷。
- 判斷該條記錄的 DB_TRX_ID 是否在活躍事務的陣列中,如果在則說明這條記錄還未提交對於當前操作的事務是不可見的,如果不在則說明已經提交,那麼就是可見的。
如果此條記錄對於該事務不可見且 ROLL_PTR 不為空那麼就會指向回滾指標的地址,通過undolog來查詢可見的記錄版本。
下面我畫了一個可見性的演演算法的流程圖
實踐
準備資料
首先我建立了一個非常簡單的表,只有id和name的學生表。
id | name |
---|---|
學生id | 學生姓名 |
這個時候我們將我們需要的隱藏列也標識出來,就變成了這樣
isDelete | id | name | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|---|
是否被刪除 | 學生id | 學生姓名 | 建立刪除更新該記錄的事務id | 回滾指標 |
這個時候插入三行資料,將表的資料變成下面這個樣子。
isDelete | id | name | DB_TRX_ID | DB_ROLL_PTR |
---|---|---|---|---|
false | 1 | 小明 | 1 | null |
false | 2 | 小方 | 1 | null |
false | 3 | 小張 | 1 | null |
示例一
使用過 MySQL 的都知道,因為隔離性,事務 B 此時獲取到的資料肯定是這樣的。
id | name |
---|---|
1 | 小明 |
2 | 小方 |
3 | 小張 |
為什麼事務A未提交的修改對於事務B是不可見的,MVCC 是如何做到的?我們用剛剛的可見性演演算法來實驗一下。
首先事務A開啟了事務(當然這不算開啟,在RR模式下 真正獲取read-view的是在進行第一次進行快照讀的時候)。我們假設事務A的事務id為2,事務B的id為3。
然後事務A進行了更新操作,如圖所示,更新操作建立了一個新的版本並且新版本的回滾指標指向了舊的版本(注意 undo log其實存放的是邏輯日誌,這裡為了方便我直接寫成物理日誌)。
最後 事務B 進行了快照讀,注意,這是我們分析的重點。
首先,在進行快照讀的時候我們會建立一個 read-view (忘記回去看一下那三個欄位)
這個時候我們的 read-view 是
up-limit-id = 2
alive-trx-list = [2,3]
low-limit-id = 4
然後我們獲取那兩個沒有被修改的記錄(沒有順序,這裡為了一起解釋方便)
我們獲取到(2,小方)和(3,小張)這兩條記錄,發現他們兩的 DB_TRX_ID = 1
我們先判斷 DB_TRX_ID 是否小於 up-limit-id 或者等於當前事務id
發現 1<2 小於 up-limit-id ,則可見 直接返回檢視。
然後我們獲取更改了的資料行
複製程式碼
其實你也發現了這是一個連結串列,此時連結串列頭的 DB_TRX_ID 為 2
我們進行判斷 2 < 2 不符合,進入下一步判斷
判斷 DB_TRX_ID >= low_limit_id 發現此時是 2 >= 4 不符合 故再進入下一步
此時判斷 Db_TRX_ID 是否在 alive_trx_list 活躍事務列表中,發現這個 DB_TRX_ID
在活躍列表中,所以只能說明該行記錄還未提交,不可見。
最終判斷不可見之後通過回滾指標檢視舊版本,發現此時 DB_TRX_ID 為1
故再次進行判斷 DB_TRX_ID < up-limit-id,此時 1 < 2 符合 ,所以可見並返回
所以最終返回的是
複製程式碼
id | name |
---|---|
1 | 小明 |
2 | 小方 |
3 | 小張 |
我們再來驗證一下,這個時候我們將事務A提交,重新建立一個事務C並select。
我們預期的結果應該是這樣的
id | name |
---|---|
1 | 小強 |
2 | 小方 |
3 | 小張 |
這個操作的流程圖如下
這個時候我們再來分析一下 事務c產生的 read-view。
這個時候事務A已經提交,所以事務A不在活躍事務陣列中,此時 read-view 的三個屬性應該是
up-limit-id = 3
alive-trx-list = [3,4]
low-limit-id = 5
複製程式碼
- 跟上面一樣,我們首先獲取(2,小張)這兩條記錄,發現他們兩的 DB_TRX_ID = 1,此時 1 < up-limit-id = 3,故符合可見性,則返回。
- 然後我們獲取剛剛被修改的id為1的記錄行,發現連結串列頭部的 DB_TRX_ID 為 2,此時 2 < up-limit-id = 3 故也符合可見性,則返回。
所以最終返回的就是
id | name |
---|---|
1 | 小強 |
2 | 小方 |
3 | 小張 |
示例二
為了加深理解,我們再使用一個相對來說比較複雜的示例來驗證 可見性演演算法 。
首先我們在事務A中刪除一條記錄,這個時候就變成了下面的樣子。
然後事務B進行了插入,這樣就變成了下面這樣。
然後事務B進行了 select 操作,我們可以發現 這個時候整張表其實會變成這樣讓這個 select 操作進行選取。
此時的 read-view 為
up-limit-id = 2
alive-trx-list = [2,3,4]
low-limit-id = 5
複製程式碼
這個時候我們進行 快照讀,首先對於前面兩條小明和小方的記錄是一樣的,此時 DB_TX_ID 為 1,我們可以判斷此時 DB_TX_ID = 1 < up-limit-id = 2 成立故返回。然後判斷小張這條記錄,首先也是 DB_TX_ID = 2 < up-limit-id = 2 不成立故進入下一輪,DB_TX_ID = 2 >= low-limit-id 不成立再進入最後一輪判斷是否在活躍事務列表中,發現 DB_TX_ID = 2 在 alive-trx-list = [2,4] 中故不可見(如果可見則會知道前面的刪除標誌是已經刪除,則返回的是空),則根據回滾指標找到上一個版本記錄,此時 DB_TX_ID = 1 和上面一樣可見則返回該行。
最後一個判斷小亮這條記錄,因為 DB_TX_ID = current_tx_id(當前事務id) 所以可見並返回。
這個時候返回的表則是這樣的
id | name |
---|---|
1 | 小明 |
2 | 小方 |
3 | 小張 |
4 | 小亮 |
然後是事務A進行了select的操作,我們可以得知現在的 read-view 為
up-limit-id = 2
alive-trx-list = [2,4]
low-limit-id = 5
複製程式碼
然後此時所見和上面也是一樣的
這個時候我們進行 快照讀,首先對於前面兩條小明和小方的記錄是一樣的,此時 DB_TX_ID 為 1,我們可以判斷此時 DB_TX_ID = 1 < up-limit-id = 2 成立故返回。然後判斷小張這條記錄,首先 DB_TX_ID = 2 = current_tx_id = 2 成立故返回發現前面的 isDelete 標誌為true 則說明已被刪除則返回空,對於第四條小亮的也是一樣判斷 DB_TX_ID = 4 < up-limit-id = 2 不成立進入下一步判斷 DB_TX_ID = 4 >= low-limit-id = 5 不成立進入最後一步發現在活躍事務陣列中故不可見且此條記錄回滾指標為null所以返回空。
那麼此時返回的列表應該就是這樣了
id | name |
---|---|
1 | 小明 |
2 | 小方 |
雖然要分析很多,但多多益善嘛,多熟悉熟悉就能更深刻理解這個演演算法了。
之後是事務C進行 快照讀 操作。首先此時檢視還是這個樣子
然後對於事務C的 read-view 為
up-limit-id = 2
alive-trx-list = [2,4]
low-limit-id = 5
複製程式碼
小明和小方的兩條記錄和上面一樣是可見的這裡我就不重複分析了,然後對於小張這條記錄 DB_TX_ID = 2 < up-limit-id = 2 || DB_TX_ID == curent_tx_id = 4 不成立故進入下一輪發現 DB_TX_ID >= low-limit-id = 5 更不成立故進入最後一輪發現 DB_TX_ID = 2 在活躍事務陣列中故不可見,然後通過回滾指標判斷 DB_TX_ID = 1 的小張記錄發現可見並返回。最後的小亮也是如此 最後會發現 DB_TX_ID = 3 也在活躍事務陣列中故不可見。
所以事務C select 的結果為
id | name |
---|---|
1 | 小明 |
2 | 小方 |
3 | 小張 |
後面事務A和事務B都進行了提交的動作,並且有一個事務D進行了快照讀,此時檢視還是如此
但此時的 read-view發生了變化
up-limit-id = 4
alive-trx-list = [4,5]
low-limit-id = 6
複製程式碼
我們首先判斷小明和小方的記錄——可見(不解釋了),小張的記錄 DB_TX_ID = 2 < up-limit-id = 4 成立故可見,因為前面 isDelete 為 true 則說明刪除了返回空,然後小亮的記錄 DB_TX_ID = 3 < up-limit-id = 4 成立故可見則返回。所以這次的 select 結果應該是這樣的
id | name |
---|---|
1 | 小明 |
2 | 小方 |
4 | 小亮 |
最後(真的最後了,不容易吧!),事務C有一次進行了 select 操作。因為在 RR 模式下 read-view 是在第一次快照讀的時候確定的,所以此時 read-view是不會更改的,然後前面檢視也沒有進行更改,所以此時即使前面事務A 事務B已經進行了提交,對於這個時候的事務C的select結果是沒有影響的。故結果應該為
id | name |
---|---|
1 | 小明 |
2 | 小方 |
3 | 小張 |
總結
我們來總結一下吧。
其實 MVCC 是通過 "三個" 隱藏欄位 (事務id,回滾指標,刪除標誌) 加上undo log和可見性演演算法來實現的版本併發控制。
為了你再次深入理解這個演演算法,我再把這張圖掛上來