认真学习MySQL中的MVCC机制
什么是MVCC?MVCC(Multiversion Concurrency Control),多版本并發控制。顧名思義,MVCC是通過數據行的多個版本管理來實現數據庫的并發控制。這項技術使得在InnoDB的事務隔離級別下執行一致性操作有了保證。換言之,就是為了查詢一些正在被另一個事務更新的行,并且可以看到它們被更新之前的值,這樣在做查詢的時候就不用等待另一個事務釋放鎖。
MVCC的實現原理依賴于:隱藏字段、undo log 、Read View。多版本通過Undo log體現,并發控制通過Read View體現。
MVCC沒有正式的標準,在不同的DBMS中MVCC的實現方式可能是不同的,也不是普遍使用的。本文學習InnoDB中MVCC的實現機制(MySQL其他的存儲引擎并不支持它)。
【1】快照度和當前度
MVCC在MySQL InnoDB中的實現主要是為了提高數據庫并發性能,用更好的方式去處理讀-寫沖突,做到即使有讀寫沖突時,也能做到不加鎖,非阻塞并發讀,而這個讀指的就是快照讀,而非當前讀。當前讀實際上是一種加鎖的操作,是悲觀鎖的實現。而MVCC本質是采用樂觀鎖思想的一種方式。
① 快照讀
快照讀又叫一致性讀,讀取的是快照數據。不加鎖的簡單的select都屬于快照讀,即不加鎖的非阻塞讀。比如下例:
select * from player where ...之所以出現快照讀的情況,是基于提高并發性能的考慮,快照讀的實現是基于MVCC,它在很多情況下,避免了加鎖操作,降低了開銷。
既然是基于多版本,那么快照讀可能讀到的并不一定是數據的最新版本,而有可能是之前的歷史版本。
快照讀的前提是隔離級別不是串行級別,串行級別下的快照讀會退化成當前讀。
② 當前讀
當前讀讀取的是記錄的最新版本(最新數據,而不是歷史版本的數據),讀取時還要保證其他并發事務不能修改當前記錄,會對讀取的記錄進行加鎖。加鎖的select,或者對數據進行增刪改都會進行當前讀。
示例:
select * from student lock in share mode; # 共享鎖select * from student for update ;#排他鎖insert into student values ... #排他鎖delete from student where ... #排他鎖update student set ... #排他鎖【2】MVCC
① 回顧隔離級別
我們知道事務有4個隔離級別,可能存在三種并發問題:
在MySQL中,默認的隔離級別是可重復讀,可以解決臟讀和不可重復讀的問題。如果僅從定義的角度來看,它并不能解決幻讀問題。如果我們想要解決幻讀問題,就需要采用串行化的方式,也就是將隔離級別提升到最高,但這樣一來就會大幅度降低數據庫的事務并發能力。
MVCC可以不采用鎖機制,而是通過樂觀鎖的方式來解決不可重復讀和幻讀問題。它可以在大多數情況下替代行鎖,降低系統的開銷。
② 隱藏字段和Undo Log版本鏈
回顧一下undo日志的版本鏈,對于使用InnoDB存儲引擎的表來說,它的聚簇索引記錄中都包含兩個必要的隱藏列(其實還可能有一個隱藏的ID-row_id是在沒有自定義主鍵以及Unique鍵的情況下才會存在的)。
- trx_id:每次一個事務對某條聚簇索引記錄進行改動時,都會把該事務的事務id賦值給trx_id 隱藏列。
- roll_pointer:每次對某條聚簇索引記錄進行改動時,都會把舊的版本寫入到undo日志中,然后這個隱藏列就相當于一個指針,可以通過它來找到該記錄修改前的信息。
舉例:student表數據如下
假設插入該記錄的事務id為8,那么此刻該條記錄的示意圖如下所示:
insert undo只在事務回滾時起作用,當事務提交后,該類型的undo日志就沒用了,它占用的undo log segment也會被系統回收(也就是該undo 日志占用的undo頁面鏈表要么被重用,要么被釋放)。
假設之后兩個事務id分別為10/20的事務對這條記錄進行update操作,操作流程如下:
| 1 | begin; | |
| 2 | begin; | |
| 3 | update student set name=‘李四’ where id=1 | |
| 4 | update student set name=‘王五’ where id=1 | |
| 5 | commit; | |
| 6 | update student set name=‘錢七’ where id=1 | |
| 7 | update student set name=‘宋八’ where id=1 | |
| 8 | commit; |
能不能在兩個事務中交叉更新同一條記錄呢?不能!這不就是一個事務修改了另一個未提交事務修改過的數據嗎?臟寫!
InnoDB使用鎖來保證不會有臟寫情況的發生,也就是在第一個事務更新了某條記錄后,就會給這條記錄加鎖,另一個事務再次更新時就需要等待第一個事務提交了,把鎖釋放之后才可以繼續更新。
每次對記錄進行改動,都會記錄一條undo日志,每條undo日志也都有一個roll_pointer屬性(insert操作對應的undo日志沒有該屬性,因為該記錄并沒有更早的版本),可以將這些undo日志都連起來,串成一個鏈表。
對該記錄每次更新后,都會將舊值放到一條undo日志中,就算是該記錄的一個舊版本。隨著更新次數的增多,所有的版本都會被roll_pointer屬性連接成一個鏈表,我們把這個鏈表稱之為版本鏈,版本鏈的頭節點就是當前記錄最新的值。
版本鏈會無限增長嗎?不會!如果undo log一直不刪除,則會通過當前記錄的回滾指針回溯到該行創建時的初始內容。所幸的是在Innodb中存在purge線程,它會查詢那些比現在最老的活動事務還早的undo log,并刪除它們,從而保證undo log文件不至于無限增長。
每個版本中還包含生成該版本時對應的事務id。
【3】MVCC實現原理之ReadView
MVCC的實現依賴于:隱藏字段、Undo Log、Read View。
① 什么是ReadView
在MVCC機制中,多個事務對同一個行記錄進行更新會產生多個歷史快照,這些歷史快照保存在undo log里,如果一個事務想要查詢查詢這個行記錄,需要讀取哪個版本的歷史記錄呢?這時就需要用到ReadView了,它幫我們解決了行的可見性問題。
ReadView就是事務在使用MVCC機制進行快照讀操作時產生的讀視圖。當事務啟動時,會生成數據庫系統當前的一個快照,InnoDB為每個事務構造了一個數組,用來記錄并維護系統當前活躍事務的ID("活躍"指的就是,啟動了但還沒提交)。
② 設計思路
使用READ UNCOMMITTED隔離級別的事務,由于可以讀到未提交事務修改過的記錄,所以直接讀取記錄的最新版本就好了。
使用 SERIALIZABLE 隔離級別的事務,InnoDB規定使用加鎖的方式來訪問記錄。
使用 READ COMMITTED 和 REPEATABLE READ隔離級別的事務,都必須保證讀到 已經提交了的 事務修改過的記錄。假設另一個事務已經修改了記錄但是尚未提交,是不能直接讀取最新版本的記錄的。核心問題就是需要判斷一下版本鏈中的哪個版本是當前事務可見的,這是ReadView要解決的主要問題。
這個ReadView中主要包含4個比較重要的內容,分別如下:
- creator_trx_id,創建這個ReadView的事務ID。說明:只有在對表中的記錄做改動時(執行insert、delete、update這些語句時)才會為事務分配事務id,否則在一個只讀事務中的事務id值都默認為0。
- trx_ids,表示在生成ReadView 時當前系統中活躍的讀寫事務的事務id列表。
- up_limit_id,活躍的事務中最小的事務id。
- low_limit_id,表示生成ReadView時系統中應該分配給下一個事務的id值。low_limit_id 是系統最大的事務id值,這里要注意是系統中的事務id,需要區別于正在活躍的事務Id。
注意,low_limit_id 并不是trx_ids中的最大值。事務id是遞增分配的,比如現在有id為1,2,3這三個事務,之后id為3的事務提交了。那么一個新的讀事務在生成ReadView時,trx_ids就包括1和2,up_limit_id的值就是1,low_limit_id的值就是4。
舉例 trx_ids為trx2、trx3、trx5和trx8的集合,系統的最大事務id(low_limit_id)為trx8+1(如果之前沒有其他的新增事務),活躍的最小事務ID(up_limit_id)為trx2。
③ ReadView的規則
有了這個ReadView,這樣在訪問某條記錄時,只需要按照下邊的步驟判斷記錄的某個版本是否可見。
- 如果被訪問版本的trx_id屬性值與ReadView中的creator_trx_id值相同,意味著當前事務在訪問它自己修改過的記錄,所以該版本可以被當前事務訪問。
- 如果被訪問版本的trx_id屬性值小于ReadView中的 up_limit_id 值,標明生成該版本的事務在當前事務生成ReadView前已經提交,所以該版本可以被當前事務訪問。
- 如果被訪問版本的trx_id 屬性值大于或等于ReadView中的 low_limit_id 值,表明生成該版本的事務在當前事務生成ReadView后才開啟,所以該版本不可以被當前事務訪問。
- 如果被訪問版本的trx_id屬性值在ReadView的up_limit_id 和low_limit_id之間,那就需要判斷一下trx_id屬性值是不是在trx_ids列表中。
- 如果在,說明創建ReadView時生成該版本的事務還是活躍的,該版本不可以被訪問
- 如果不在,說明創建ReadView時生成該版本的事務已經被提交,該版本可以被訪問
④ MVCC整體操作流程
了解了這些概念之后,我們來看下當查詢一條記錄的時候,系統如何通過MVCC找到它。
如果某個版本的數據對當前事務不可見的話,那就順著版本鏈找到下一個版本的數據,繼續按照上邊的步驟判斷可見性。以此類推,直到版本鏈中的最后一個版本。如果最后一個版本也不可見的話,那么就意味著該條記錄對該事務完全不可見,查詢結果就不包含該記錄。
InnoDB中,MVCC是通過Undo Log + ReadView進行數據讀取,Undo Log保存了歷史快照,而ReadView規則幫我們判斷當前版本的數據是否可見。
在隔離級別為讀已提交(Read Commit)時,一個事務中的每一次select查詢都會重新獲取一次Read View。
| begin; | |
| select * from student where id>2; | 獲取一次ReadView |
| … | |
| select * from student where id>2 | 獲取一次ReadView |
| commit; |
注意,此時同樣的查詢語句都會重新獲取一次ReadView,這時如果ReadView不同,就可能產生不可重復讀或者幻讀的情況。
當隔離級別為可重復讀的時候,就避免了不可重復讀,這時因為一個事務只在第一次select的時候會獲取一次ReadView,而后面所有的select都會復用這個ReadView,如下所示:
| begin; | |
| select * from student where id>2; | 獲取一次ReadView |
| … | |
| select * from student where id>2 | 復用ReadView |
| commit; |
【4】流程實例分析
假設現在student表中只有一條由事務id為8的事務插入的一條記錄:
MVCC只能在READ COMMITTED 和 REPEATABLE READ兩個隔離級別下工作。接下來看一下 READ COMMITTED 和 REPEATABLE READ所謂的生成ReadView的時機不同到底不同在哪里。
① READ COMMITTED隔離級別下
READ COMMITTED:每次讀取數據前都生成一個ReadView。
現在有兩個事務id分別為10/20的事務在執行(事務的ID是由系統遞增分配的,如果是增刪改行為系統會分配事務ID,如果是查詢行為,那么事務ID為0):
# Transaction 10 begin; update student set name='李四' where id=1; update student set name='王五' where id=1;#Transaction 20 begin; # 更新了一些別的表的記錄說明:事務執行過程中,只有在第一次真正修改記錄時(比如使用insert、update、delete語句),才會被分配一個單獨的事務id,這個事務id是遞增的。所以我們才在事務2中更新一些別的表的記錄,目的是讓它分配事務id。
此刻,表student中id為1的記錄得到的版本鏈表如下所示:
假設現在有一個使用 READ COMMITTED隔離級別的事務開始執行:
# 使用 READ COMMITTED 隔離級別的事務 begin;# select1: Transaction 10 20 未提交 select * from student where id=1;# 得到的列name的值為 張三這個select1的執行過程如下:
- 1.在執行select語句時會先生成一個ReadView,ReadView的 trx_ids 列表的內容就是[10,20],up_limit_id 為10,low_limit_id 為21,creator_trx_id 為0。
- 2.從版本 鏈中挑選可見的記錄,從圖中看出,最新版本的列name的內容是“王五”,該版本的trx_id 值為10,在trx_ids列表內,所以不符合可見性要求,根據roll_pointer跳到下一個版本。
- 3.下一個版本的列name的內容是“李四”,該版本的trx_id值也為10,也在trx_ids列表內,所以也不符合要求,繼續跳到下一個版本。
- 4.下一個版本的name的內容是“張三”,該版本的trx_id值為8,小于ReadView中的up_limit_id 值 10,所以這個版本是符合要求的,最后返回給用戶的版本就是這條列name為“張三”的記錄。
之后,我們把事務id為10的事務提交一下:
# Transaction 10 begin;update student set name='李四' where id=1; update student set name='王五' where id=1;commit;然后再到事務id為20的事務中更新一下表student中id為1的記錄:
# Transaction 20 begin;#更新了一些別的表的記錄 ... update student set name='錢七' where id=1; update student set name='宋八' where id=1;此刻,表student中id為1的記錄的版本鏈就長這樣:
然后再到剛才使用 READ COMMITTED 隔離級別的事務中繼續查找這個id為1的記錄,如下:
這個select2的執行過程如下:
- 1.在執行select語句時又會單獨生成一個ReadView,該ReadView的trx_ids 列表的內容就是[20], up_limit_id 為20,low_limit_id 為21,creator_trx_id 為0.
- 2.從版本鏈中挑選可見的記錄,從圖中看出,最新版本的列name的內容是“宋八”,該版本的trx_id 值為20,在trx_ids列表內,所以不符合可見性要求,根據roll_pointer跳到下一個版本。
- 3.下一個版本的列name的內容是“錢七”,該版本的trx_id 值為20,也在trx_ids列表內,所以也不符合要求,繼續跳到下一個版本。
- 4.下一個版本的列name的內容是“王五”,該版本的trx_id值為10,小于ReadView中的up_limit_id 值20,所以這個版本是符合要求的,最后返回給用戶的版本就是這條列name為“王五”的記錄。
以此類推,如果之后事務id為20的記錄也提交了,再次在使用READ COMMITTED隔離級別的事務中查詢表student 中id值為1的記錄時,得到的結果就是“宋八”了,具體流程我們就不分析了。
強調 : 使用READ COMMITTED隔離級別的事務在每次查詢開始時都會生成一個獨立的ReadView。
② REPEATABLE READ隔離級別下
使用 REPEATABLE READ 隔離級別的事務來說,只會在第一次執行查詢語句時生成一個ReadView,之后的查詢就不會重復生成了。
現在有兩個事務id分別為10/20的事務在執行:
# Transaction 10 begin; update student set name='李四' where id=1; update student set name='王五' where id=1;#Transaction 20 begin; # 更新了一些別的表的記錄此刻,表student中id為1的記錄得到的版本鏈表如下所示:
假設現在有一個使用 REPEATABLE READ隔離級別的事務開始執行:
這個select1的執行過程如下:
- 1.在執行select語句時會先生成一個ReadView,ReadView的 trx_ids 列表的內容就是[10,20],up_limit_id 為10,low_limit_id 為21,creator_trx_id 為0。
- 2.從版本 鏈中挑選可見的記錄,從圖中看出,最新版本的列name的內容是“王五”,該版本的trx_id 值為10,在trx_ids列表內,所以不符合可見性要求,根據roll_pointer跳到下一個版本。
- 3.下一個版本的列name的內容是“李四”,該版本的trx_id值也為10,也在trx_ids列表內,所以也不符合要求,繼續跳到下一個版本。
- 4.下一個版本的name的內容是“張三”,該版本的trx_id值為8,小于ReadView中的up_limit_id 值 10,所以這個版本是符合要求的,最后返回給用戶的版本就是這條列name為“張三”的記錄。
之后,我們把事務id為10的事務提交一下:
# Transaction 10 begin;update student set name='李四' where id=1; update student set name='王五' where id=1;commit;然后再到事務id為20的事務中更新一下表student中id為1的記錄:
# Transaction 20 begin;#更新了一些別的表的記錄 ... update student set name='錢七' where id=1; update student set name='宋八' where id=1;此刻,表student中id為1的記錄的版本鏈就長這樣:
然后再到剛才使用 REPEATABLE READ 隔離級別的事務中繼續查找這個id為1的記錄,如下:
# 使用 REPEATABLE READ 隔離級別的事務 begin;# select1: Transaction 10 20 未提交 select * from student where id=1;# 得到的列name的值為 張三# SELECT2: Transaction 10提交,Transaction 20 未提交 select * from student where id=1; # 得到的列name的值 仍為 張三select2的執行過程如下:
- 1.在執行select語句時會復用原先的ReadView(因為當前事務的隔離級別為 REPEATABLE READ,而之前在執行select1時已經生成過了ReadView),ReadView的 trx_ids 列表的內容就是[10,20],up_limit_id 為10,low_limit_id 為21,creator_trx_id 為0。
- 2.從版本 鏈中挑選可見的記錄,從圖中看出,最新版本的列name的內容是“宋八”,該版本的trx_id 值為20,在trx_ids列表內,所以不符合可見性要求,根據roll_pointer跳到下一個版本。
- 3.下一個版本的列name的內容是“錢七”,該版本的trx_id值為20,也在trx_ids列表內,所以也不符合要求,繼續跳到下一個版本。
- 4.下一個版本的列name的內容是“王五”,該版本的trx_id值也為10,也在trx_ids列表內,所以也不符合要求,同理下一個列name的內容是“李四”的 版本也不符合要求,繼續跳到下一個版本。
- 5.下一個版本的name的內容是“張三”,該版本的trx_id值為8,小于ReadView中的up_limit_id 值 10,所以這個版本是符合要求的,最后返回給用戶的版本就是這條列name為“張三”的記錄。
兩次select查詢得到的結果是重復的,記錄的列name值都是張三,這就是可重復讀的含義。如果我們之后再把事務id為20的記錄提交了,然后再到剛才使用 REPEATABLE READ 隔離級別的事務中繼續查找這個id為1的記錄,得到的記錄還是張三,具體過程可以自行分析。
【5】MVCC如何解決幻讀?
其實從前面我們流程分析也可以體會到,如果有新的事務進行插入,那么其事務ID要么在trx_ids列表內,要么是大于等于low_limit_id 的,其對應的行記錄版本顯示是要排除的。
假設現在表student中只有一條數據,數據內容中,主鍵id=1,隱藏的trx_id=10,它的undo log如下圖所示。
假設現在有事務A和事務B并發執行,事務A的事務id為20,事務B的事務id為30。
步驟1:事務A開始第一次查詢數據,查詢的SQL語句如下:
select * from student where id>1;在開始查詢之前,MySQL會為事務A產生一個ReadView,此時ReadView的內容如下:trx_ids=[20,30],up_limit_id=20,low_limit_id=31,creator_trx_id=20。
由于此時表student中只有一條數據,且符合whereid>=1 條件,因此會查詢出來。然后根據ReadView機制,發現該行數據的trx_id=10,小于事務A的ReadView里up_limit_id,這表示這條數據是事務A開啟之前,其他事務就已經提交了的數據,因此事務A可以讀取到。
結論:事務A的第一次查詢,能讀取到一條數據,id。
步驟2:接著事務B(trx_id=30),往表student中新插入兩條數據,并提交事務。
insert into student(id,name) values(2,'李四'); insert into student(id,name) values(3,'王五');此時表student中就有三條數據了,對應的undo如下圖所示:
步驟3:接著事務A開啟第二次查詢,根據可重復讀隔離級別的規則,此時事務A并不會再重新生成ReadView。此時表student中的3條數據都滿足where id>=1的條件,因為會先查出來。然后根據ReadView機制,判斷每條數據是不是都可以被事務A看到。
- 1.首先id =1的這條數據,前面已經說過了,可以被事務A看到
- 2.然后是id=2的數據,它的trx_id=30,此時事務A發現,這個值處于up_limit_id和low_limit_id之間,因此還需要再判斷30是否處于trx_ids數組內。由于事務A的trx_ids=[20,30],因此在數組內,這表示id=2的這條數據是與事務A在同一時刻啟動的其他事務提交的,所以這他哦數據不能讓事務A看到。
- 3.同理,id=3的這條數據,trx_id也為30,因此也不能被事務A看見。
結論:最終事務A的第二次查詢,只能查詢出id=1的這條數據。這和事務A的第一次查詢的結果是一樣的,因此沒有出現幻讀現象,所以說在MySQL的可重復讀隔離級別下,不存在幻讀問題。
在MySQL的可重復讀隔離級別下一定不會發生幻讀現象嗎?可以參考博文:MySQL事務中幻讀實踐
【6】總結
本文學習了MVCC在READ COMMITTED 、REPEATABLE READ這兩種隔離級別的事務在執行快照讀操作時訪問記錄的版本鏈的過程,這樣使不同事務的讀-寫,寫-讀操作并發執行,從而提升系統性能。
核心點在于ReadView的原理,READ COMMITTED 、REPEATABLE READ這兩個隔離級別的一個很大不同就是生成ReadView的時機不同:
- READ COMMITTED在每一次進行普通select操作前都會生成一個ReadView。
- REPEATABLE READ只在第一次進行普通select操作前生成一個ReadView,之后的查詢操作都重復使用這個ReadView。
說明:我們之前說執行delete語句或者更新主鍵的update語句并不會立即把對應的記錄完全從頁面中刪除而是執行一個所謂的delete mark操作,相當于只是對記錄打上了一個刪除標志位,這主要就是為MVCC服務的。
通過MVCC我們可以解決:
- 讀寫之間阻塞的問題。通過MVCC可以讓讀寫互相不阻塞,即讀不阻塞寫,寫不阻塞讀,這樣就可以提升事務并發處理能力。
- 降低了死鎖的概率。這是因為MVCC采用了樂觀鎖的方式,讀取數據時并不需要加鎖,對于寫操作也只鎖定必要的行。
- 解決快照讀的問題。當我們查詢數據庫在某個時間點的快照時,只能看到這個時間點之前事務提交更新的結果,而不能看到這個時間點之后事務提交的更新結果。
總結
以上是生活随笔為你收集整理的认真学习MySQL中的MVCC机制的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: php忘记登陆密码,ZBlogPHP忘记
- 下一篇: python修改文件的方法_python