事務究竟有沒有被隔離
我們知道在 RR 級別下,對於一個事務來說,讀到的值應該是相同的,但有沒有想過為什麼會這樣,它是如何實現的?會不會有一些特殊的情況存在?本篇文章會詳細的講解 RR 級別下事務被隔離的原理。在閱讀後應該瞭解如下的內容:
- 瞭解 MySQL 中的兩種檢視
- 瞭解 RR 級別下,如何實現的事務隔離
- 瞭解什麼是當前讀,以及當前讀會造成那些問題
明確檢視的概念
在 MySQL 中,檢視有兩種。第一種是 View,也就是常用來查詢的虛擬表,在呼叫時執行查詢語句從而獲取結果, 語法如 create view
.
第二種則是儲存引擎層 InnoDB 用來實現 MVCC(Mutil-Version Concurrency Control | 多版本併發控制)時用到的一致性檢視 consistent read view
事務真正的啟動時機:
在使用 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,說明是未提交的事務。所以不可見。
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 承認在事務啟動前已經提交完成的資料