我們知道在 RR 級別下,對於一個事務來說,讀到的值應該是相同的,但有沒有想過為什么會這樣,它是如何實現的?會不會有一些特殊的情況存在?本篇文章會詳細的講解 RR 級別下事務隔離的原理。在閱讀后應該了解如下的內容:
- 了解 MySQL 中的兩種視圖
- 了解 RR 級別下,如何實現的事務隔離
- 了解什么是當前讀,以及當前讀會造成那些問題
明確視圖的概念
在 MySQL 中,視圖有兩種。第一種是 View,也就是常用來查詢的虛擬表,在調用時執行查詢語句從而獲取結果, 語法如 create view.
第二種則是存儲引擎層 InnoDB 用來實現 MVCC(Mutil-Version Concurrency Control | 多版本並發控制)時用到的一致性視圖 consistent read view, 用於支持 RC 和 RR 隔離級別的實現。簡單來說,就是定義在事務執行期間,事務內能看到什么樣的數據。
事務真正的啟動時機:
在使用 begin 或 start transation 時,事務並沒有真正開始運行,而是在執行一個對 InnoDB 表的操作時(即第一個快照讀操作時),事務才真正啟動。
如果想要立即開始一個事務,可以用 start transaction with consistent snapshot 命令。
不期待的結果,事務沒有被隔離
在之前 MySQL 事務 介紹中,知道在 RR 的級別的事務下,如果其他事務修改了數據,事務中看到的數據和啟動事務時的數據是一致的,並不會受其他事務的影響。可是,有沒有什么特殊的情況呢?
看下面這個例子:
創建表:
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`k` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);
按照下圖開啟事務:
這時對於事務 A 來說,查詢 k 的值為 1, 而事務 B 的 K 為 3. 不是說,在 RR 級別下的事務,不受其他事務影響嗎,為什么事務 B 結果為 3 而不是所期待的 2. 這就涉及到了 MVCC 中 “快照” 的工作原理。
MVCC 的實現 - 快照
什么是 “快照”?
在 RR 級別下,事務啟動時會基於整庫拍個“快照”,用於記錄當前狀態下的數據信息。可這樣的話,對於庫比較大的情況,事務的啟動應該非常慢。可實際上的執行過程非常快,原因就在於 InnoDB 中的實現。
“快照”的實現
在 InnoDB 中,每個事務都有唯一的事務 ID,叫做 transaction id. 在事務開始時,按照嚴格遞增的順序向 InnoDB 事務系統申請。
數據庫中,每行數據具有多個版本。在每次開啟更新的事務時,都會生成一個新的數據版本,並把 transaction id賦值給當前數據版本的事務 ID,記為 row trx_id.
如下圖所示,對於同一行數據連續更新了 4 次,對應 4 個版本如下。對於最新版本 V4 情況,K 為 22,是被事務 id 為 25 所更新的,進而 row trx_id 是 25.
在每次更新時,都會生成一條回滾日志(undo log),上圖中三個虛擬箭頭(U1,U2,U3)就是 undo log. 每次開啟事務的視圖 V1,V2,V3 物理上並不真實存在,而是通過當前事務版本和 undo log 計算出來。
了解了 row trx_id 和 transaction id,就可以進一步了解事務具體是如何進行隔離的了。
事務隔離的實現
在 RR 級別下,如果要想實現一個事務啟動時,能夠看到所有已經提交的事務結果,而在事務執行期間,其他事務的更新均不可見的效果。
只需要在事務啟動時規定,以啟動的時刻為准,如果一個數據版本在啟動前生成,就可以查看。如果在啟動后生成,則不能查看,通過 undo log 一直查詢上一個版本數據,直到找到啟動前生成的數據版本或者自己更新的數據才結束。
在具體實現上,InnoDB 為每個事務構造一個數組,用來保存事務啟動瞬間,當前正在活躍(啟動沒有提交)的所有事務 ID. 數組里 ID 最小為低水位,當前系統里面創建過的事務 ID 最大值 + 1 為高水位。這個數組和高水位,就組成了當前事務的一致性視圖(read-view),如下圖所示。
數據是否看見,就是通過比較數據的 row trx_id 和 一致性視圖的對比而得到的。
在比較時:
一、如果 row trx_id 出現在綠色部分,表示該版本是已提交的事務或者當前的事務自己生成的,該數據可見。
| 事務A | 事務B |
|---|---|
| start transaction with consistent snapshot; | |
| update t set k = k+1 where id=1; | |
| commit; | |
| start transaction with consistent snapshot; | |
| select * from t where id=1; |
事務 A 修改 k 值后提交,接着事務 B 查詢 k 值。這時對於啟動的事務 B 來說,k 值的 row trx_id 等於事務 A 的transaction id. 而事務 B 在 事務 A 之后申請,假設當前活躍事務只有 B。B 的 transaction id 肯定大於事務 A,所以當前版本 row trx_id 一定小於低水位,進而 k 值為 A 修改后的值。
二、如果在紅色部分,表示由未來的事務生成的,該數據不可見。
| 事務A | 事務B |
|---|---|
| start transaction with consistent snapshot; | |
| start transaction with consistent snapshot; | |
| update t set k = k+1 where id=1; | |
| commit; | |
| select * from t where id=1; |
事務 A 開啟后,查詢 k 值,但未提交事務。事務 B 在事務 A 開啟后修改 K 值。此時對於事務 A 來說, 修改后 k 值的 row trx_id 等於事務B transaction id. 假設當前的活躍的只有事務 A,則 row trx_id 大於高水位的值,所以事務 B 的修改對 A 不可見。
三、如果落在黃色部分,兩種情況
a. row trx_id 在數組中,表示該版本是由未提交的事務生成的,不可見。
| 事務A | 事務B |
|---|---|
| start transaction with consistent snapshot; | |
| start transaction with consistent snapshot; | |
| update t set k = k+1 where id=1; | |
| select * from t where id=1; |
事務 A,B 先后開啟,假設只有 A,B 兩個活躍的事務。此時對於事務 B 來說一致性視圖中的數組包含事務 A 和 B 的 transaction id.
當事務 B 查詢 k 值時,發現數組中包含事務 A 的 transaction id,說明是未提交的事務。所以不可見。
這時,假設讓事務 A 在事務 B 查詢前提交:
| 事務A | 事務B |
|---|---|
| start transaction with consistent snapshot; | |
| start transaction with consistent snapshot; | |
| update t set k = k+1 where id=1; | |
| commit; | |
| select * from t where id=1; |
當對於事務 B 來說,事務 A 雖然提交了,當其提交的 row transaction_d 依然是 B 啟動時的活躍事務里面的,所以更新依然不可見。
也就是說,只要在事務啟動時,同時活躍的其他事務,無論是否比當前事務提交的早或晚,都是屬於不可見的情況。
b. row trx_id 不在數組中,表示該版本是由已提交的事務生成,可見。
| 事務A (transaction id = 100) | 事務B (transaction id = 101) | 事務 C (transaction id = 102) |
|---|---|---|
| start transaction with consistent snapshot; | ||
| update t set k = k+1 where id=1; | start transaction with consistent snapshot; | |
| update t set k = k+1 where id=1; | ||
| commit; | ||
| start transaction with consistent snapshot; | ||
| select * from t where id=1; |
假設當前只活躍 A,C 兩個事務。對於事務 C 來說,一致性視圖數組為[100,102]. 當前 k 的 row trx_id 為 101,不在一致性數組中。說明是已經提交事務,所以數據可見。
InnoDB 就是利用了所有數據都有多個版本這個特性,實現了秒級創建快照的能力。
現在回到文章開始的三個事務,現在分析下為什么事務 A 的結果是 1。
假設如下:
- 在 A 啟動時,系統只有一個活躍的事務 ID 為 99.
- A,B,C 事務的版本號為 100,101,102 且當前系統中只有這四個事務。
- 三個事務開始前,(1,1)對應 row trx_id 為 90.
這樣對於事務 A,B,C 中一致性數組和 row trx_id 如下:
右側顯示的回滾段的內容,第一次更新為事務 C,其 row trx_id 等於 102. 值為(1,2)。最新 row trx_id 為事務 B 的 101,值為(1,3)。
對於事務 A 來說,視圖數組為 [99,100]。讀取流程如下:
- 獲取當前 row trx_id 為 101 的數據。發現比高水位大,落在紅色,不可見。
- 向上查找,發現 row trx_id 為 102 的,比高水位大,不可見。
- 向上查找,發現 row trx_id 為 90,比低水位小,落在綠色可見。
這時事務 A 無論在什么時候查詢,看到的結果都一致,這被稱為一致性讀。
上面的判斷邏輯為代碼邏輯,現在翻譯成便於理解的語言,對於一個事務視圖來說,除了自己的更新可見外:
- 版本未提交,不可見(包含了還未提交的事務,或者開始同時活躍,但先一步提交的事務);
- 版本已提交,在視圖后創建提交的,不可見。
- 版本已提交,在視圖創建前提交,可見。
現在應該清楚,可重復讀的能力就是通過一致性讀實現的。可是在文章開始部分事務 B 的更新語句如果按照一致性讀的情況,事務 C 在事務 B 之后提交,結果應該是(1,2)不是 (1,3)。原因就在於當前讀的影響。
當前讀的影響
對於文章開頭部分的事務 B 來說,如果在更新操作前查詢一次數據,返回結果確實是 1。但由於更新操作,並不是在歷史版本上更新,否則事務 C 的更新就會被覆蓋。因此事務 B 的更新操作是在(1,2)的基礎上操作的。
什么是當前讀?
在更新操作時,都是先讀后寫,這個讀,就是只能讀當前的值(最新已經提交的值),進而稱為“當前讀”。
除 update 語句外,給 select 語句加鎖,也是當前讀。鎖的類型可以是讀鎖(S鎖,共享鎖)和寫鎖(X鎖,排他鎖)。
比如想讓事務 A 的查詢語句獲取當前讀中的值:
# 共享鎖 - 允許其他事務讀取被鎖定的行
mysql> select k from t where id=1 lock in share mode;
# 排它鎖 - 不允許其他事務讀取被鎖定的行
mysql> select k from t where id=1 for update;
在當前讀下,快照查詢的過程
在事務 B 更新時,當前讀拿到的值為(1,2),更新后生成的新版本數據為(1,3),當前版本的 row trx_id 101.
所以在接下里的執行的查詢語句時,當前 row trx_id 為101,判斷為自己更新的,所以可見。所以查詢結果是(1,3)。
假設事務 C 改成如下事務 C' 這樣,在事務 B 更新后,再提交。
這時雖然(1,2)已經生成了,但根據兩階段鎖協議,由於事務 C’ 沒有提交,沒有釋放寫鎖。這時事務 B 就會被鎖住,等到其他事務釋放后,再繼續當前讀。
可重復讀的核心就是一致性讀,而事務更新數據的時候,只能用當前讀。如果當前的記錄的行鎖被其他事務占用的話,就需要進入鎖等待。
讀提交下的事務隔離實現
讀提交和可重復讀的邏輯類似,主要區別為:
- 在 RR 下,事務開始時就創建一致性視圖,之后事務中的查詢都共用這個一致性視圖。
- 在 RC 下,每個語句執行前會重新算出一個視圖。
重新看下文章開頭部分讀提交狀態下的事務狀態圖:
對於事務 A 來說,查詢語句的時刻會重新計算視圖,此時(1,3),(1,2)都是在該語句前生成的。
此時對於該語句來說:
- (1,3)屬於版本未提交,不可見。
- (1,2)屬於版本已提交,在視圖前創建提交,版本可見。
所以結果為 k=2.
應用場景
級別為 RR。
場景1-文章開頭例子,造成查詢結果不一致的情況
場景2- 假設場景:事務中無法更新的情況
表結構為:
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, c) values(1,1),(2,2),(3,3),(4,4);
這里想模擬出下圖的結果,把 c 和 id 相等行的 c 值清零,並出現無法修改的現象。
答案就是上面開啟的事務更新前,開啟一個新事務修改所有行中的 c 值就可以。
方式1:
| 事務A | 事務B |
|---|---|
| start transaction with consistent snapshot; | |
| update t set c = c+1; | |
| update set c=0 where id =c; | |
| select * from t; |
事務 A 的更新語句是當前讀,會先將最新的數據版本讀取出,然后更新,但由於數據是最新版本,沒有滿足更新的 where 語句的行(因為 c 值被加 1),這時更新失敗。所以原數據行的 row trx_id 沒有變,還是等於事務 B 的 ID。之后執行 select,由於數據行是事務 B 的 trx_id, 也就是屬於版本已提交,在視圖后創建提交,屬於不可見的情況,所以查出來的數據還是事務 B 更新前的數據。
方式2:
| 事務A | 事務B |
|---|---|
| start transaction with consistent snapshot; | |
| select * from t; | |
| start transaction with consistent snapshot; | |
| select * from t; | |
| update t set c = c+1; | |
| commit; | |
| update set c=0 where id =c; | |
| select * from t; |
在事務 A 啟動時,事務 B 屬於活躍的事務,雖然之后提交了,但也屬於是版本未提交,不可見的情況。
場景3 - 實際場景:實現樂觀鎖后,無法更新的情況。
下面使用樂觀鎖出現的情況就是上面場景 1 出現的實際場景。
在實現樂觀鎖后,通常會基於 version 字段進行 cas 式的更新(update ...set ... where id = xxx and version = xxx),當 version 被其他事務搶先更新時,自己所在事務更新失敗,這時由於所在 row 的 trx_id 沒有改變成自己更新事務的 id(由於更新失敗),再次 select 還是過去的舊值,造成明明值沒有變,卻沒法更新的情景。
解決方式就是在失敗后,重新開啟一個事務。判斷成功的標准一般是判斷 affected_rows 是不是等於預期值。
CAS:Compare and Swap,即比較再交換。CAS是一種無鎖算法,CAS有3個操作數,內存值V,舊的預期值A,要修改的新值B。當且僅當預期值A和內存值V相同時,將內存值V修改為B,否則什么都不做。
總結
在 MySQL 中視圖分為兩種,一種是虛擬表另一種則是一致性視圖。
在 RR 級別下開啟事務后,會拍下快照,快照里,每個事務會有自己的唯一 ID,數據庫中的每行數據存在多個版本,在執行更新語句時,為會每行數據添加一個新的版本,其中 row trx_id 就是所在更新事務的 ID.
事務隔離的實現,就是規定以事務開啟的時刻為准,之前提交的事務數據可見,之后提交的事務數據不可見。在具體實現上,通過開啟一個數組,該數組記錄了當前時刻所有活躍的事務 ID. 而開頭提到的一致性視圖就是由該數組組成。通過比較該數組和數據庫中數據多個版本的 row trx_id 來達到可見和不可見的效果。
當前讀會讀取已經提交完成的數據,這就會導致一致性視圖的查詢結果不一致,或者無法更新的奇怪現象。
RC 和 RR 的區別為,RC 承認的是語句前已經提交完成的數據。而 RR 承認在事務啟動前已經提交完成的數據。
參考
題外話:最近在系統的學習 MySQL,推薦一個比較好的學習材料就是<<丁奇老師的 MySQL 45 講>>,鏈接已經附在文章末尾。
文章中很多知識點就是從中學來,加入自己的理解並整理的。
大家在購買后,強烈推薦讀一讀評論區的內容,價值非常高,不少同學問出了自己在思考時的一些困惑。

