- 前提概要
- 什么是MVCC
- 什么是當前讀和快照讀?
- 當前讀,快照讀和MVCC的關系
- MVCC實現原理
- 隱式字段
- undo日志
- Read View(讀視圖)
- 整體流程
- MVCC相關問題
- RR是如何在RC級的基礎上解決不可重復讀的?
- RC,RR級別下的InnoDB快照讀有什么不同?
一、前提概要
什么是MVCC?
MVCC
MVCC,全稱Multi-Version Concurrency Control,即多版本並發控制。MVCC是一種並發控制的方法,一般在數據庫管理系統中,實現對數據庫的並發訪問,在編程語言中實現事務內存。
MVCC在MySQL InnoDB中的實現主要是為了提高數據庫並發性能,用更好的方式去處理讀-寫沖突,做到即使有讀寫沖突時,也能做到不加鎖,非阻塞並發讀
什么是當前讀和快照讀?
在學習MVCC多版本並發控制之前,我們必須先了解一下,什么是MySQL InnoDB下的當前讀和快照讀?
-
當前讀
像select lock in share mode(共享鎖), select for update ; update, insert ,delete(排他鎖)這些操作都是一種當前讀,為什么叫當前讀?就是它讀取的是記錄的最新版本,讀取時還要保證其他並發事務不能修改當前記錄,會對讀取的記錄進行加鎖。 -
快照讀
像不加鎖的select操作就是快照讀,即不加鎖的非阻塞讀;快照讀的前提是隔離級別不是串行級別,串行級別下的快照讀會退化成當前讀;之所以出現快照讀的情況,是基於提高並發性能的考慮,快照讀的實現是基於多版本並發控制,即MVCC,可以認為MVCC是行鎖的一個變種,但它在很多情況下,避免了加鎖操作,降低了開銷;既然是基於多版本,即快照讀可能讀到的並不一定是數據的最新版本,而有可能是之前的歷史版本
說白了MVCC就是為了實現讀-寫沖突不加鎖,而這個讀指的就是快照讀, 而非當前讀,當前讀實際上是一種加鎖的操作,是悲觀鎖的實現
當前讀,快照讀和MVCC的關系
- 准確的說,MVCC多版本並發控制指的是 “維持一個數據的多個版本,使得讀寫操作沒有沖突” 這么一個概念。僅僅是一個理想概念
- 而在MySQL中,實現這么一個MVCC理想概念,我們就需要MySQL提供具體的功能去實現它,而快照讀就是MySQL為我們實現MVCC理想模型的其中一個具體非阻塞讀功能。而相對而言,當前讀就是悲觀鎖的具體功能實現
- 要說的再細致一些,快照讀本身也是一個抽象概念,再深入研究。MVCC模型在MySQL中的具體實現則是由 3個隱式字段,undo日志 ,Read View 等去完成的,具體可以看下面的MVCC實現原理
MVCC能解決什么問題,好處是?
數據庫並發場景有三種,分別為:
- 讀-讀:不存在任何問題,也不需要並發控制
- 讀-寫:有線程安全問題,可能會造成事務隔離性問題,可能遇到臟讀,幻讀,不可重復讀
- 寫-寫:有線程安全問題,可能會存在更新丟失問題,比如第一類更新丟失,第二類更新丟失
MVCC帶來的好處是?
多版本並發控制(MVCC)是一種用來解決讀-寫沖突的無鎖並發控制,也就是為事務分配單向增長的時間戳,為每個修改保存一個版本,版本與事務時間戳關聯,讀操作只讀該事務開始前的數據庫的快照。 所以MVCC可以為數據庫解決以下問題
- 在並發讀寫數據庫時,可以做到在讀操作時不用阻塞寫操作,寫操作也不用阻塞讀操作,提高了數據庫並發讀寫的性能
- 同時還可以解決臟讀,幻讀,不可重復讀等事務隔離問題,但不能解決更新丟失問題
小結一下咯
總之,MVCC就是因為大牛們,不滿意只讓數據庫采用悲觀鎖這樣性能不佳的形式去解決讀-寫沖突問題,而提出的解決方案,所以在數據庫中,因為有了MVCC,所以我們可以形成兩個組合:
- MVCC + 悲觀鎖
MVCC解決讀寫沖突,悲觀鎖解決寫寫沖突 - MVCC + 樂觀鎖
MVCC解決讀寫沖突,樂觀鎖解決寫寫沖突
這種組合的方式就可以最大程度的提高數據庫並發性能,並解決讀寫沖突,和寫寫沖突導致的問題
二、MVCC的實現原理
MVCC的目的就是多版本並發控制,在數據庫中的實現,就是為了解決讀寫沖突,它的實現原理主要是依賴記錄中的 3個隱式字段,undo日志 ,Read View 來實現的。所以我們先來看看這個三個point的概念
隱式字段
每行記錄除了我們自定義的字段外,還有數據庫隱式定義的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產生一個聚簇索引 -
實際還有一個刪除flag隱藏字段, 既記錄被更新或刪除並不代表真的刪除,而是刪除flag變了
如上圖,DB_ROW_ID是數據庫默認為該行記錄生成的唯一隱式主鍵,DB_TRX_ID是當前操作該記錄的事務ID,而DB_ROLL_PTR是一個回滾指針,用於配合undo日志,指向上一個舊版本
undo日志
undo log主要分為兩種:
- insert undo log
代表事務在insert新記錄時產生的undo log, 只在事務回滾時需要,並且在事務提交后可以被立即丟棄 - update undo log
事務在進行update或delete時產生的undo log; 不僅在事務回滾時需要,在快照讀時也需要;所以不能隨便刪除,只有在快速讀或事務回滾不涉及該日志時,對應的日志才會被purge線程統一清除
purge
- 從前面的分析可以看出,為了實現InnoDB的MVCC機制,更新或者刪除操作都只是設置一下老記錄的deleted_bit,並不真正將過時的記錄刪除。
- 為了節省磁盤空間,InnoDB有專門的purge線程來清理deleted_bit為true的記錄。為了不影響MVCC的正常工作,purge線程自己也維護了一個read view(這個read view相當於系統中最老活躍事務的read view);如果某個記錄的deleted_bit為true,並且DB_TRX_ID相對於purge線程的read view可見,那么這條記錄一定是可以被安全清除的。
對MVCC有幫助的實質是update undo log ,undo log實際上就是存在rollback segment中舊記錄鏈,它的執行流程如下:
一、 比如一個有個事務插入persion表插入了一條新記錄,記錄如下,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,其實在事務提交之后可能就被刪除丟失了,不過這里為了演示,所以還放在這里)
Read View(讀視圖)
什么是Read View?
什么是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所在的舊記錄就是當前事務能看見的最新老版本
那么這個判斷條件是什么呢?
如上,它是一段MySQL判斷可見性的一段源碼,即changes_visible方法(不完全哈,但能看出大致邏輯),該方法展示了我們拿DB_TRX_ID去跟Read View某些屬性進行怎么樣的比較
在展示之前,我先簡化一下Read View,我們可以把Read View簡單的理解成有三個全局屬性
trx_list(名字我隨便取的)
一個數值列表,用來維護Read View生成時刻系統正活躍的事務ID
up_limit_id
記錄trx_list列表中事務ID最小的ID
low_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 是否在活躍事務之中,trx_list.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,維護在一個列表上,假設我們稱為trx_list
-
Read View不僅僅會通過一個列表trx_list來維護事務2執行快照讀那刻系統正活躍的事務ID,還會有兩個屬性up_limit_id(記錄trx_list列表中事務ID最小的ID),low_limit_id(記錄trx_list列表中事務ID最大的ID,也有人說快照讀那刻系統尚未分配的下一個事務ID也就是目前已出現過的事務ID的最大值+1,我更傾向於后者;所以在這里例子中up_limit_id就是1,low_limit_id就是4 + 1 = 5,trx_list集合的值是1,3,Read View如下圖
-
我們的例子中,只有事務4修改過該行記錄,並在事務2執行快照讀前,就提交了事務,所以當前該行當前數據的undo log如下圖所示;我們的事務2在快照讀該行記錄的時候,就會拿該行記錄的DB_TRX_ID去跟up_limit_id,low_limit_id和活躍事務ID列表(trx_list)進行比較,判斷當前事務2能看到該記錄的版本是哪個。
-
所以先拿該記錄DB_TRX_ID字段記錄的事務ID 4去跟Read View的的up_limit_id比較,看4是否小於up_limit_id(1),所以不符合條件,繼續判斷 4 是否大於等於 low_limit_id(5),也不符合條件,最后判斷4是否處於trx_list中的活躍事務, 最后發現事務ID為4的事務不在當前活躍事務列表中, 符合可見性條件,所以事務4修改后提交的最新結果對事務2快照讀時是可見的,所以事務2能讀到的最新數據記錄是事務4所提交的版本,而事務4提交的版本也是全局角度上最新的版本
- 也正是Read View生成時機的不同,從而造成RC,RR級別下快照讀的結果的不同
三、MVCC相關問題
RR是如何在RC級的基礎上解決不可重復讀的?
當前讀和快照讀在RR級別下的區別:
表1:
表2:
而在表2這里的順序中,事務B在事務A提交后的快照讀和當前讀都是實時的新數據400,這是為什么呢?
- 這里與上表的唯一區別僅僅是表1的事務B在事務A修改金額前快照讀過一次金額數據,而表2的事務B在事務A修改金額前沒有進行過快照讀。
所以我們知道事務中快照讀的結果是非常依賴該事務首次出現快照讀的地方,即某個事務中首次出現快照讀的地方非常關鍵,它有決定該事務后續快照讀結果的能力
我們這里測試的是更新,同時刪除和更新也是一樣的,如果事務B的快照讀是在事務A操作之后進行的,事務B的快照讀也是能讀取到最新的數據的
RC,RR級別下的InnoDB快照讀有什么不同?
正是Read View生成時機的不同,從而造成RC,RR級別下快照讀的結果的不同
- 在RR級別下的某個事務的對某條記錄的第一次快照讀會創建一個快照及Read View, 將當前系統活躍的其他事務記錄起來,此后在調用快照讀的時候,還是使用的是同一個Read View,所以只要當前事務在其他事務提交更新之前使用過快照讀,那么之后的快照讀使用的都是同一個Read View,所以對之后的修改不可見;
- 即RR級別下,快照讀生成Read View時,Read View會記錄此時所有其他活動事務的快照,這些事務的修改對於當前事務都是不可見的。而早於Read View創建的事務所做的修改均是可見
- 而在RC級別下的,事務中,每次快照讀都會新生成一個快照和Read View, 這就是我們在RC級別下的事務中可以看到別的事務提交的更新的原因
總之在RC隔離級別下,是每個快照讀都會生成並獲取最新的Read View;而在RR隔離級別下,則是同一個事務中的第一個快照讀才會創建Read View, 之后的快照讀獲取的都是同一個Read View。
簡單的小例子
create table yang(
id int primary key auto_increment,
name varchar(20));
}
假設系統的版本號從1開始.
INSERT
InnoDB為新插入的每一行保存當前系統版本號作為版本號.
第一個事務ID為1;
start transaction; insert into yang values(NULL,'yang') ; insert into yang values(NULL,'long'); insert into yang values(NULL,'fei'); commit;
對應在數據中的表如下(后面兩列是隱藏列,我們通過查詢語句並看不到)

SELECT
InnoDB會根據以下兩個條件檢查每行記錄:
a.InnoDB只會查找版本早於當前事務版本的數據行(也就是,行的系統版本號小於或等於事務的系統版本號),這樣可以確保事務讀取的行,要么是在事務開始前已經存在的,要么是事務自身插入或者修改過的.
b.行的刪除版本要么未定義,要么大於當前事務版本號,這可以確保事務讀取到的行,在事務開始之前未被刪除.
只有a,b同時滿足的記錄,才能返回作為查詢結果.
DELETE
InnoDB會為刪除的每一行保存當前系統的版本號(事務的ID)作為刪除標識.
看下面的具體例子分析:
第二個事務,ID為2;
start transaction; select * from yang; //(1) select * from yang; //(2) commit;
假設1
假設在執行這個事務ID為2的過程中,剛執行到(1),這時,有另一個事務ID為3往這個表里插入了一條數據;
第三個事務ID為3;
start transaction; insert into yang values(NULL,'tian'); commit;
這時表中的數據如下:

然后接着執行事務2中的(2),由於id=4的數據的創建時間(事務ID為3),執行當前事務的ID為2,而InnoDB只會查找事務ID小於等於當前事務ID的數據行,所以id=4的數據行並不會在執行事務2中的(2)被檢索出來,在事務2中的兩條select 語句檢索出來的數據都只會下表:

假設2
假設在執行這個事務ID為2的過程中,剛執行到(1),假設事務執行完事務3后,接着又執行了事務4;
第四個事務:
start transaction; delete from yang where id=1; commit;
此時數據庫中的表如下:

接着執行事務ID為2的事務(2),根據SELECT 檢索條件可以知道,它會檢索創建時間(創建事務的ID)小於當前事務ID的行和刪除時間(刪除事務的ID)大於當前事務的行,而id=4的行上面已經說過,而id=1的行由於刪除時間(刪除事務的ID)大於當前事務的ID,所以事務2的(2)select * from yang也會把id=1的數據檢索出來.所以,事務2中的兩條select 語句檢索出來的數據都如下:
UPDATE
InnoDB執行UPDATE,實際上是新插入了一行記錄,並保存其創建時間為當前事務的ID,同時保存當前事務ID到要UPDATE的行的刪除時間.
假設3
假設在執行完事務2的(1)后又執行,其它用戶執行了事務3,4,這時,又有一個用戶對這張表執行了UPDATE操作:
第5個事務:
start transaction; update yang set name='Long' where id=2; commit;
根據update的更新原則:會生成新的一行,並在原來要修改的列的刪除時間列上添加本事務ID,得到表如下:

繼續執行事務2的(2),根據select 語句的檢索條件,得到下表:

還是和事務2中(1)select 得到相同的結果.
