Mysql MVCC
一、MVCC概述
MVCC
,全稱Multi-Version Concurrency Control
,即多版本併發控制。整個MVCC多併發控制的目的就是為了實現讀-寫衝突不加鎖,提高併發讀寫效能,而這個讀指的就是快照度, 而非當前讀,當前讀實際上是一種加鎖的操作,是悲觀鎖的實現。
- 當前讀
讀取的是記錄資料的最新版本,並且當前讀返回的記錄都會加上鎖,保證其他事務不會再併發的修改這條記錄
- 快照讀
讀取的是記錄資料的可見版本(可能是過期的資料),不用加鎖。
總結來說MVCC的好處:
-
在併發讀寫資料庫時,可以做到在讀操作時不用阻塞寫操作,寫操作也不用阻塞讀操作,提高了資料庫併發讀寫的效能
-
同時還可以解決髒讀,幻讀,不可重複讀等事務隔離問題,但不能解決更新丟失問題
MVCC解決讀寫衝突,悲觀鎖或者樂觀鎖解決寫寫衝突
二、mvcc原理
MVCC的目的就是多版本併發控制,目的是為了解決讀寫衝突
,總的來說MVCC通過儲存資料在某個時間點的快照來實現的,意味著在同一個時刻不同事務看到的相同表裡的資料可能是不同的(即多版本)。如下示例:事務1和事務3可讀到不同的資料快照。
時間點 | 事務1 | 事務2 | 事務3 |
T1 | 開始事務 | 開始事務 | 開始事務 |
T2 | 查詢A的賬戶,金額為100 | ||
T3 | 修改A的賬戶,金額從100改為200 | ||
T4 | 提交事務 | ||
T5 | 查詢A的賬戶,金額為100 | 查詢A的賬戶,金額為200 |
MVCC最大的優點是讀不加鎖,因此讀寫不衝突,併發效能好。InnoDB實現MVCC,多個版本的資料可以共存,它的實現原理主要是依賴記錄中的 3個隱式欄位、
undo日誌
和Read View
來實現的。
1、隱藏欄位
每行記錄除了我們自定義的欄位外,還有資料庫隱式定義的DB_TRX_ID,
DB_ROLL_PTR,
DB_ROW_ID
等欄位
DB_TRX_ID
6byte,最近操作(修改/插入
)事務ID:記錄建立這條記錄或者最後一次修改該記錄的事務ID
DB_ROLL_PTR
7byte,回滾指標,指向這條記錄的上一個版本(儲存於rollback segment裡)
DB_ROW_ID
6byte,隱含的自增ID(隱藏主鍵),如果資料表沒有主鍵,InnoDB會自動以DB_ROW_ID
產生一個聚簇索引
如上圖,DB_ROW_ID
是資料庫預設為該行記錄生成的唯一隱式主鍵,DB_TRX_ID
是當前操作該記錄的事務ID,而DB_ROLL_PTR
是一個回滾指標,用於配合undo日誌,指向上一個舊版本。
2、undo日誌
undo log主要分為兩種:
- insert undo log
代表事務在insert
新記錄時產生的undo log
, 只在事務回滾時需要,並且在事務提交後可以被立即丟棄 - update undo log
事務在進行update
或delete
時產生的undo log
; 不僅在事務回滾時需要,在快照讀時也需要;所以不能隨便刪除,只有在快速讀或事務回滾不涉及該日誌時,對應的日誌才會被purge
執行緒統一清除
因此,對MVCC有幫助的實質是update undo log
,undo log
實際上就是存在rollback segment
中舊記錄鏈,它的執行流程如下:
比如事務0插入person表一條新記錄,name為Jerry, age為24,隱式主鍵
是1,事務ID
和回滾指標
,我們假設為NULL,如下圖
現在來了一個事務1
對該記錄的name
做出了修改,改為Tom
- 在
事務1
修改該行資料時,資料庫會先對該行加排他鎖
- 然後把該行資料拷貝到
undo log
中,作為舊記錄,既在undo log
中有當前行的拷貝副本 - 拷貝完畢後,修改該行name為Tom,並且修改隱藏欄位的事務ID為當前
事務1
的ID, 我們預設從1
開始,之後遞增,回滾指標指向拷貝到undo log
的副本記錄,既表示我的上一個版本就是它 - 事務提交後,釋放鎖
又來了個事務2
修改person表
的同一個記錄,將age
修改為30歲
- 在
事務2
修改該行資料時,資料庫也先為該行加鎖 - 然後把該行資料拷貝到
undo log
中,作為舊記錄,發現該行記錄已經有undo log
了,那麼最新的舊資料作為連結串列的表頭,插在該行記錄的undo log
最前面 - 修改該行
age
為30歲,並且修改隱藏欄位的事務ID為當前事務2
的ID, 那就是2
,回滾指標指向剛剛拷貝到undo log
的副本記錄 - 事務提交,釋放鎖
從上面,我們就可以看出,不同事務或者相同事務的對同一記錄的修改,會導致該記錄的undo log
成為一條記錄版本線性表,既連結串列,undo log
的鏈首就是最新的舊記錄,鏈尾就是最早的舊記錄(該undo log的節點可能是會purge執行緒清除掉,向圖中的第一條insert undo log,其實在事務提交之後可能就被刪除丟失了,不過這裡為了演示,所以還放在這裡)
3、Read View(讀檢視)
Read View就是事務進行快照讀操作的時候生產的讀檢視
(Read View),記錄並維護系統當前活躍事務的ID(當每個事務開啟時,都會被分配一個ID, 這個ID是遞增的,所以最新的事務,ID值越大),所以我們知道 Read View
主要是用來做可見性判斷的, 即當我們某個事務執行快照讀的時候,對該記錄建立一個Read View
讀檢視,把它比作條件用來判斷當前事務能夠看到哪個版本的資料,既可能是當前最新的資料,也有可能是該行記錄的undo log
裡面的某個版本的資料。
Read View
遵循一個可見性演算法,主要是將要被修改的資料
的最新記錄中的DB_TRX_ID
(即當前事務ID)取出來,與系統當前其他活躍事務的ID去對比(由Read View維護),如果DB_TRX_ID
跟Read View的屬性做了某些比較,不符合可見性,那就通過DB_ROLL_PTR
回滾指標去取出Undo Log
中的DB_TRX_ID
再比較,即遍歷連結串列的DB_TRX_ID
(從鏈首到鏈尾,即從最近的一次修改查起),直到找到滿足特定條件的DB_TRX_ID
, 那麼這個DB_TRX_ID所在的舊記錄就是當前事務能看見的最新老版本。
我先簡化一下Read View,我們可以把Read View簡單的理解成有三個全域性屬性
- rw_trx_ids
一個數值列表,用來維護Read View生成時刻系統正活躍的事務IDup_limit_id
記錄rw_trx_ids列表中事務ID最小的IDlow_limit_id
ReadView生成時刻系統尚未分配的下一個事務ID,也就是目前已出現過的事務ID的最大值+1
判斷邏輯如下:
-
首先比較
DB_TRX_ID < up_limit_id
, 如果小於,則當前事務能看到DB_TRX_ID
所在的記錄,如果大於等於進入下一個判斷 -
接下來判斷
DB_TRX_ID 大於等於 low_limit_id
, 如果大於等於則代表DB_TRX_ID
所在的記錄在Read View
生成後才出現的,那對當前事務肯定不可見,如果小於則進入下一個判斷 -
判斷
DB_TRX_ID
是否在活躍事務之中,rw_trx_ids.contains(DB_TRX_ID)
,如果在,則代表我Read View
生成時刻,你這個事務還在活躍,還沒有Commit,你修改的資料,我當前事務也是看不見的;如果不在,則說明,你這個事務在Read View
生成之前就已經Commit了,你修改的結果,我當前事務是能看見的
四、整體流程
我們在瞭解了隱式欄位
,undo log
, 以及Read View
的概念之後,就可以來看看MVCC實現的整體流程是怎麼樣了。
- 當
事務2
對某行資料執行了快照讀
,資料庫為該行資料生成一個Read View
讀檢視,假設當前事務ID為2
,此時還有事務1
和事務3
在活躍中,事務4
在事務2
快照讀前一刻提交更新了,所以Read View記錄了系統當前活躍事務1,3的ID,維護在一個列表rw_trx_ids上
事務1 | 事務2 | 事務3 | 事務4 |
---|---|---|---|
事務開始 | 事務開始 | 事務開始 | 事務開始 |
… | … | … | 修改且已提交 |
進行中 | 快照讀 | 進行中 | |
… | … | … |
- Read View不僅僅會通過一個列表rw_trx_ids來維護
事務2
執行快照讀
那刻系統正活躍的事務ID,還會有兩個屬性up_limit_id
(記錄rw_trx_ids列表中事務ID最小的ID),low_limit_id
(記錄rw_trx_ids列表中事務ID最大的ID,也有人說快照讀那刻系統尚未分配的下一個事務ID也就是目前已出現過的事務ID的最大值+1
,我更傾向於後者;所以在這裡例子中up_limit_id
就是1,low_limit_id
就是4 + 1 = 5,rw_trx_ids集合的值是1,3。
- 我們的例子中,只有
事務4
修改過該行記錄,並在事務2
執行快照讀
前,就提交了事務,所以當前該行資料的undo log
如下圖所示;我們的事務2在快照讀該行記錄的時候,就會拿該行記錄的DB_TRX_ID
去跟up_limit_id
,low_limit_id
和rw_trx_ids
進行比較,判斷當前事務2
能看到該記錄的版本是哪個。
- 所以先拿該記錄
DB_TRX_ID
欄位記錄的事務ID4
去跟Read View
的的up_limit_id
比較,看4
是否小於up_limit_id
(1),所以不符合條件,繼續判斷4
是否大於等於low_limit_id
(5),也不符合條件,最後判斷4
是否處於rw_trx_ids中的活躍事務, 最後發現事務ID為4
的事務不在當前活躍事務列表中, 符合可見性條件,所以事務4
修改後提交的最新結果對事務2
快照讀時是可見的,所以事務2
能讀到的最新資料記錄是事務4
所提交的版本,而事務4提交的版本也是全域性角度上最新的版本
- 也正是Read View生成時機的不同,從而造成RC,RR級別下快照讀的結果的不同
五、mvcc示例解析
下面以RR隔離級別為例,結合前文提到的幾個問題分別說明。
(1)髒讀
時間點 | 事務1 | 事務2 |
T1 | 開始事務 | 開始事務 |
T2 | 修改A的金額,將金額從100改為200 | |
T3 | 查詢A的金額,為100 | |
T4 | 提交事務 |
當事務1在T3時刻讀取A的餘額前,會生成ReadView,由於此時事務2沒有提交仍然活躍,因此其事務id一定在ReadView的rw_trx_ids中,因此根據前面介紹的規則,事務B的修改對ReadView不可見。接下來,事務A根據指標指向的undo log查詢上一版本的資料,得到A的餘額為100。這樣事務1就避免了髒讀。
(2)不可重複讀
時間點 | 事務1 | 事務2 |
T1 | 開始事務 | 開始事務 |
T2 | 快照讀A賬戶,為100 | |
T3 | 修改A的金額,將金額從100改為200 | |
T4 | 提交事務 | |
T5 | 快照讀A的金額,為100 |
當事務1在T2時刻讀取A的金額前,會生成ReadView。此時事務2分兩種情況討論,一種是如圖中所示,事務已經開始但沒有提交,此時其事務id在ReadView的rw_trx_ids中;一種是事務2還沒有開始,此時其事務id大於等於ReadView的low_limit_id。無論是哪種情況,根據前面介紹的規則,事務2的修改對ReadView都不可見。當事務1在T5時刻再次讀取A的餘額時,會根據T2時刻生成的ReadView對資料的可見性進行判斷,從而判斷出事務2的修改不可見;因此事務1根據指標指向的undo log查詢上一版本的資料,得到A的餘額為100,從而避免了不可重複讀。
(3)幻讀
時間點 | 事務1 | 事務2 |
T1 | 開始事務 | 開始事務 |
T2 | 快照讀A賬戶,為100 | |
T3 | 修改A的金額,將金額從100改為200 | |
T4 | 提交事務 | |
T5 | 快照讀A的金額,為100 |
MVCC避免幻讀的機制與避免不可重複讀非常類似。
當事務A在T2時刻讀取0<id<5的使用者餘額前,會生成ReadView。此時事務B分兩種情況討論,一種是如圖中所示,事務已經開始但沒有提交,此時其事務id在ReadView的rw_trx_ids中;一種是事務B還沒有開始,此時其事務id大於等於ReadView的low_limit_id。無論是哪種情況,根據前面介紹的規則,事務B的修改對ReadView都不可見。
當事務A在T5時刻再次讀取0<id<5的使用者餘額時,會根據T2時刻生成的ReadView對資料的可見性進行判斷,從而判斷出事務B的修改不可見。因此對於新插入的資料lisi(id=2),事務A根據其指標指向的undo log查詢上一版本的資料,發現該資料並不存在,從而避免了幻讀。