@
一、MVCC的由來
我們知道:標准的數據庫事務是要具備ACID特性的。但是對於【I】隔離性而言,可以根據不同的場景,權衡使用不同的隔離級別。在MySQL中實現了SQL標准中的四大隔離級別,同時對應了可能會出現的不同問題:
隔離級別 | 臟讀 | 不可重復讀 | 幻讀 |
---|---|---|---|
READ UNCOMMITTED(讀未提交) | 可能 | 可能 | 可能 |
READ COMMITTED(讀已提交) | 不可能 | 可能 | 可能 |
REPEATABLE READ(可重復讀) | 不可能 | 不可能 | *可能 |
SERIALIZABLE(可串行化) | 不可能 | 不可能 | 不可能 |
其中,需要特別注意的一點是:也是與SQL標准規定不一樣的一個地方,MySQL中RR可重復讀的隔離級別下,是基本上不會出現幻讀問題(這個地方會出現一些特殊的場景,后面會介紹)。
訪問MySQL數據一般就是增刪改查操作,對應的是insert、delete、update和select語句。
對於insert、delete、update這三種類型的語句而言,需要先定位到最新的數據;對於select語句,也有select ... lock in share mode(MySQL8.0中增加了SELECT ... FOR SHARE)/select... for update的操作,同樣需要讀取最新的數據,同時必須堵塞其他並發事務修改當前記錄,所以需要進行加鎖。 這些操作在MySQL中統一被稱為——鎖定讀(Locking Reads),又稱為當前讀。
而對於普通的select語句,MySQL則根據不同的隔離級別,進行不同的處理:
- READ UNCOMMITTED(讀未提交):讀取最新的記錄
- READ COMMITTED(讀已提交):讀取最新提交事務的記錄
- REPEATABLE READ(可重復讀):讀取當前事務在開始前已提交的記錄或者被自己事務修改的記錄
- SERIALIZABLE(可串行化):讀取最新的記錄,同時也加上讀鎖,事務提交前不允許被其他事務修改,也就是select ... lock in share mode
可以看到,對於READ COMMITTED(讀已提交)和 REPEATABLE READ(可重復讀)這兩種隔離級別來說,普通的select是不需要加鎖的,並且受到事務發生時機的影響。可以利用事務保存的歷史版本數據,控制版本訪問權限,這種方案就稱之為——MVCC(Multi-Version Concurrency Control),而MVCC下的select查詢,被稱為——非鎖定讀(Consistent Nonlocking Reads),又稱為快照讀。
生成的快照就被稱之為ReadView(一致性視圖)
利用MVCC提供的快照讀能力,可以實現在不加鎖的情況下,避免讀寫沖突時的堵塞問題,極大提升並發查詢性能,同時也保證了事務的隔離性。這是在並發性能和數據一致性之間做的一次trade-off。
二、MVCC的實際應用
前面提到,MVCC是在RC和RR隔離級別下使用歷史版本數據實現事務隔離性的,下面我們來看個具體的案例分析。
首先先創建表 t1:
CREATE TABLE `t1` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
#插入一條數據
INSERT INTO `t1` (`id`, `c`, `d`) VALUES ('5', '5', '5');
RR級別場景
事務隔離級別為RR:
SELECT @@tx_isolation
REPEATABLE-READ
1.當事務1執行語句:start transaction with consistent snapshot;會創建一個ReadView,最后進行查詢id=5的時候,仍然會讀取到快照時的數據,也就是(5,5,5)這條數據
2.事務2同樣使用start transaction with consistent snapshot開啟了一個ReadView,但是由於事務中使用了update語句更新了這條數據,所以最后事務2查詢id=5的數據時,結果是(5,2,5)
RC級別場景
事務隔離級別為RC:
set session transaction isolation level read committed;
SELECT @@tx_isolation
READ-COMMITTED
同樣是對於這個案例:
- 對於事務2,查詢id=5的結果不變,還是(5,2,5)
- 對於事務1,由於隔離級別是 READ COMMITTED,可以查詢到其他事務已提交的記錄,也就是說ReadView是在查詢語句開始時才生成的,不是事務開始前,所以查詢id=5的結果也是(5,1,5)
小結一下:
- RR級別下,MVCC的一致性視圖(快照)是只在事務開始(第一次查詢語句)時生成;
- RC級別下,MVCC的一致性視圖(快照)是在每次查詢語句前時生成;
三、MVCC的實現
3.1 舊版本數據從哪里來——Undo Log
An undo log is a collection of undo log records associated with a single read-write transaction. An undo log record contains information about how to undo the latest change by a transaction to a clustered index record. If another transaction needs to see the original data as part of a consistent read operation, the unmodified data is retrieved from undo log records.
undo log是與單個讀寫事務相關聯的撤消日志記錄的集合。undo log記錄可以明確如何撤消事務對聚集索引記錄的最新更改。如果另一個事務需要通過一致性讀取操作查看原始數據,可以從undo log記錄中查詢未修改前的數據。
也就是說,undo log是為了記錄如何回滾事務而生成的。數據每進行一次增刪改,就會對應一條或多條undo log。 InnoDB中使用不同的undo log類型對insert、delete和update進行區分。
3.1.1 插入操作對應的undo log
TRX_UNDO_INSERT_REC:是插入操作對應的undo log類型,因為插入操作對應的撤銷邏輯是刪除,所以只需要把這條記錄的主鍵id記錄下來。
3.1.2 刪除操作對應的undo log
由於MVCC的存在,被刪除的記錄並不會被真正的刪除, 而是進行delete mark操作——只是將行記錄上的刪除標記位delete_flag改為1,后面在再通過purge操作把記錄加入垃圾鏈表中,待后續進行空間復用。生成刪除語句對應的undo log類型為TRX_UNDO_DELETE_MARK_REC,相比於TRX_UNDO_INSERT_REC,TRX_UNDO_DELETE_MARK_REC不僅保存了主鍵id,也保存了相關的索引列信息,用來對刪除過程中一些中間狀態的清理。
3.1.3 更新操作對應的undo log
更新操作對應的undo log除了會記錄主鍵和索引列信息之外,還會把被更新前各個字段的信息記錄下來,還有指向舊記錄的DB_ROLL_PTR和DB_TRX_ID。
- 不更新主鍵,且被更新的列存儲空間不發生變化:直接在原有行記錄上面更新。
- 不更新主鍵,且被更新的列存儲空間發生了變化:先刪除舊記錄(不是delete mark,而是直接刪除),再插入新記錄。
- 更新主鍵:舊記錄進行delete mark,再插入新記錄。
- 過程中更新了二級索引:對舊的二級索引進行delete mark,插入新的二級索引記錄。
通過以上3種不同操作對應的undo log,可以得到事務開始前的的歷史記錄。那么這些記錄是如何關聯起來的呢?
3.2 多版本數據如何關聯——行記錄隱藏字段和版本鏈
對於InnoDB引擎的表,聚簇索引記錄中包含了兩個必要的隱藏字段:
- DB_TRX_ID:一個事務每次對某條聚簇索引記錄進行改動(插入、更新或刪除)時,會把該事務id賦值給該字段
- DB_ROLL_PTR:每次對某條聚簇索引記錄進行改動時,會把舊版本寫入undo日志中,DB_ROLL_PTR就是指向這條記錄上一個版本的指針。
還是對於表 t1:
CREATE TABLE `t1` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
1.插入一條數據
INSERT INTO `t1` (`id`, `c`, `d`) VALUES ('5', '5', '5');
此時版本只有一個:
2.執行了3次變更sql后:
update t1 set c=4 where id=5;
update t1 set c=3 where id=5;
update t1 set c=3 where id=5;
對應的undo版本鏈就變成:
結合前面的undo log來看,每次進行記錄變更的時候,都會把舊值放到一條undo log里面,同時利用DB_ROLL_PTR把多個版本的undo log組成一個版本鏈,並保持每個版本對應的事務id。
那么如何判斷版本鏈中哪個才是當前事務可見的正確版本呢?
3.3 不同版本數據如何正確訪問——Read View訪問規則
3.3.1 Read View
Read View就是事務進行快照讀操作的時候生成的,是一個邏輯層面的概念。
Read View主要包含以下幾部分內容:
- m_ids: 表示生成Read View時,系統中正在活躍的事務id列表。
- up_limit_id:表示生成Read View時,系統中正在活躍的最小事務id,也就是m_ids中的最小值。
- low_limit_id:表示生成Read View時,系統還未分配的下一個事務 ID ,也就是目前已出現過的事務 ID 的最大值 + 1。
- creator_trx_id:生成Read View的事務id
判斷邏輯如下:
- 若版本數據的DB_TRX_ID屬性值和creator_trx_id相同,代表是本事務修改的記錄,可以訪問。
- 若版本數據的DB_TRX_ID屬性值小於up_limit_id,代表是在生成Read View前就已經提交的事務,可以訪問。
- 若版本數據的DB_TRX_ID屬性值大於low_limit_id,代表是在生成Read View之后才開啟的事務,不可以訪問。
- 若版本數據的DB_TRX_ID屬性值在up_limit_id和low_limit_id之間,需要再判斷DB_TRX_ID是否在m_ids里面,如果在,則代表是在生成Read View時,該版本數據所在的事務還未提交,不可以訪問;如果不在,則代表是在生成Read View時,該版本數據所在的事務已經提交,可以訪問。
3.3.2 覆蓋索引下的MVCC
由於DB_TRX_ID和DB_ROLL_PTR都是在聚簇索引記錄中的,當事務中使用覆蓋索引進行查詢時,如何能確認正確的數據版本呢?
二級索引頁上有一個PAGE_MAX_TRX_ID的屬性,記錄該頁面變更時的最大事務id,如果PAGE_MAX_TRX_ID的值是在Read View創建前提交的,那么該頁的全部索引都可見;如果不是,則需要根據二級索引的主鍵進行回表,進行再次判斷。
小結一下:
對於一個 Read View 來說,除了自己的更新總是可見以外,有三種情況:
- 事務未提交的記錄版本,不可見;
- 事務已提交的記錄版本,但是是在Read View創建后提交的,不可見;
- 版本已提交,而且是在Read View創建前提交的,可見。
再結合前文提到的:
- RR級別下,MVCC的一致性視圖(快照)是只在事務開始(第一次查詢語句)時生成;
- RC級別下,MVCC的一致性視圖(快照)是在每次查詢語句前時生成;
就能找到正確的版本數據,實現RR和RC事務隔離級別的快照讀機制。
四、擴展一——MVCC的多版本是否影響性能
因為MVCC是通過回溯undo log的方式,將符合訪問條件的歷史版本查詢出來,而undo log文件是保存在磁盤中,是否會影響查詢性能?
由於undo log是為了回滾未成功提交的事務和MVCC存在的,如果事務成功提交,以及當系統里最早的Read View不再訪問它們的時候,就會使用purge操作進行清除,所以undo log的生存周期一般來說不會太長,但是使用時,我們仍需要避免使用長事務。
五、擴展二——RR級別能否完全避免幻讀問題
回到最開始提到的:MySQL中RR可重復讀的隔離級別下,是基本上不會出現幻讀問題。但是是否可以完全解決幻讀問題呢?
在RR可重復讀級別下, InnoDB使用了MVCC避免【快照讀】場景下的幻讀問題,而在【當前讀】場景下, 是使用gap lock間隙鎖的方式避免幻讀問題(關於間隙鎖可以參考文章:《 MySQL&InnoDB鎖機制全面解析》)。但是如果同時使用了快照讀和當前讀會發生什么情況呢?
下面還是使用表 t1 模擬一個案例,在RR級別下開啟兩個事務會話:
CREATE TABLE `t1` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO `codemavs`.`t1` (`id`, `c`, `d`) VALUES ('0', '0', '0');
INSERT INTO `codemavs`.`t1` (`id`, `c`, `d`) VALUES ('1', '1', '1');
INSERT INTO `codemavs`.`t1` (`id`, `c`, `d`) VALUES ('5', '5', '5');
1、事務1【1.1】開啟了快照讀后,查詢結果是(1,1,1)和(5,5,5);
2、事務2把了id=0的記錄中c的字段更新為5;
3、事務1【1.2】中使用了select...for update的當前讀,此時返回的結果不再是(1,1,1)和(5,5,5),而是 (0,5,0),(1,1,1)和(5,5,5),中間多出了(0,5,0)這條記錄。
這是一種特殊的場景:在同一個事務中,先使用了快照讀, 再使用當前讀,兩次的結果可能出現不一致。
可知RR級別下先使用非鎖定讀,再使用鎖定讀時,可能會出現幻讀問題,但這在MySQL中是允許出現的。
總結
MVCC是RC和RR隔離級別下利用【快照讀】提升了讀寫並發的整體性能,主要通過Undo Log、版本鏈和Read View訪問規則來實現。