1. 程式人生 > >事務究竟有沒有被隔離

事務究竟有沒有被隔離

我們知道在 RR 級別下,對於一個事務來說,讀到的值應該是相同的,但有沒有想過為什麼會這樣,它是如何實現的?會不會有一些特殊的情況存在?本篇文章會詳細的講解 RR 級別下事務被隔離的原理。在閱讀後應該瞭解如下的內容:

  • 瞭解 MySQL 中的兩種檢視
  • 瞭解 RR 級別下,如何實現的事務隔離
  • 瞭解什麼是當前讀,以及當前讀會造成那些問題

明確檢視的概念

在 MySQL 中,檢視有兩種。第一種是 View,也就是常用來查詢的虛擬表,在呼叫時執行查詢語句從而獲取結果, 語法如 create view.

第二種則是儲存引擎層 InnoDB 用來實現 MVCC(Mutil-Version Concurrency Control | 多版本併發控制)時用到的一致性檢視 consistent read view

, 用於支援 RC 和 RR 隔離級別的實現。簡單來說,就是定義在事務執行期間,事務內能看到什麼樣的資料。

事務真正的啟動時機:

在使用 beginstart 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。

假設如下:

  1. 在 A 啟動時,系統只有一個活躍的事務 ID 為 99.
  2. A,B,C 事務的版本號為 100,101,102 且當前系統中只有這四個事務。
  3. 三個事務開始前,(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]。讀取流程如下:

  1. 獲取當前 row trx_id 為 101 的資料。發現比高水位大,落在紅色,不可見。
  2. 向上查詢,發現 row trx_id 為 102 的,比高水位大,不可見。
  3. 向上查詢,發現 row trx_id 為 90,比低水位小,落在綠色可見。

這時事務 A 無論在什麼時候查詢,看到的結果都一致,這被稱為一致性讀。

上面的判斷邏輯為程式碼邏輯,現在翻譯成便於理解的語言,對於一個事務檢視來說,除了自己的更新可見外:

  1. 版本未提交,不可見(包含了還未提交的事務,或者開始同時活躍,但先一步提交的事務);
  2. 版本已提交,在檢視後建立提交的,不可見。
  3. 版本已提交,在檢視建立前提交,可見。

現在應該清楚,可重複讀的能力就是通過一致性讀實現的。可是在文章開始部分事務 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 承認在事務啟動前已經提交完成的資料