《MySQL實戰45講》讀後感 08|事務到底是隔離的還是不隔離的

這篇文章全部是轉載的,因爲我覺得非常好,講了一個之前一直不理解的問題。
文末有丁奇老師的官方購買途徑,如果你也覺得不錯,希望大家也都支持下丁奇老師。

我在第 3 篇文章和你講事務隔離級別的時候提到過,如果是可重複讀隔離級別,事務 T 啓動的時候會創建一個視圖 read-view,之後事務 T 執行期間,即使有其他事務修改了數據,事務 T 看到的仍然跟在啓動時看到的一樣。也就是說,一個在可重複讀隔離級別下執行的事務,好像與世無爭,不受外界影響。

但是,我在上一篇文章中,和你分享行鎖的時候又提到,一個事務要更新一行,如果剛好有另外一個事務擁有這一行的行鎖,它又不能這麼超然了,會被鎖住,進入等待狀態。問題是,既然進入了等待狀態,那麼等到這個事務自己獲取到行鎖要更新數據的時候,它讀到的值又是什麼呢?

我給你舉一個例子吧。下面是一個只有兩行的表的初始化語句。

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);

在這裏插入圖片描述
圖 1 事務 A、B、C 的執行流程
這裏,我們需要注意的是事務的啓動時機。

begin/start transaction 命令並不是一個事務的起點,在執行到它們之後的第一個操作 InnoDB 表的語句,事務才真正啓動。如果你想要馬上啓動一個事務,可以使用 start transaction with consistent snapshot 這個命令。

還需要注意的是,在整個專欄裏面,我們的例子中如果沒有特別說明,都是默認 autocommit=1。

在這個例子中,事務 C 沒有顯式地使用 begin/commit,表示這個 update 語句本身就是一個事務,語句完成的時候會自動提交。事務 B 在更新了行之後查詢 ; 事務 A 在一個只讀事務中查詢,並且時間順序上是在事務 B 的查詢之後。

這時,如果我告訴你事務 B 查到的 k 的值是 3,而事務 A 查到的 k 的值是 1,你是不是感覺有點暈呢?

所以,今天這篇文章,我其實就是想和你說明白這個問題,希望藉由把這個疑惑解開的過程,能夠幫助你對 InnoDB 的事務和鎖有更進一步的理解。

在 MySQL 裏,有兩個“視圖”的概念:

  • 一個是 view。它是一個用查詢語句定義的虛擬表,在調用的時候執行查詢語句並生成結果。創建視圖的語法是 create view … ,而它的查詢方法與表一樣。
  • 另一個是 InnoDB 在實現 MVCC 時用到的一致性讀視圖,即 consistent read view,用於支持 RC(Read Committed,讀提交)和 RR(Repeatable Read,可重複讀)隔離級別的實現。
    它沒有物理結構,作用是事務執行期間用來定義“我能看到什麼數據”。

在第 3 篇文章《事務隔離:爲什麼你改了我還看不見?》中,我跟你解釋過一遍 MVCC 的實現邏輯。今天爲了說明查詢和更新的區別,我換一個方式來說明,把 read view 拆開。你可以結合這兩篇文章的說明來更深一步地理解 MVCC。

“快照”在 MVCC 裏是怎麼工作的?

在可重複讀隔離級別下,事務在啓動的時候就“拍了個快照”。注意,這個快照是基於整庫的。

這時,你會說這看上去不太現實啊。如果一個庫有 100G,那麼我啓動一個事務,MySQL 就要拷貝 100G 的數據出來,這個過程得多慢啊。可是,我平時的事務執行起來很快啊。

實際上,我們並不需要拷貝出這 100G 的數據。我們先來看看這個快照是怎麼實現的。

InnoDB 裏面每個事務有一個唯一的事務 ID,叫作 transaction id。它是在事務開始的時候向 InnoDB 的事務系統申請的,是按申請順序嚴格遞增的。

而每行數據也都是有多個版本的。每次事務更新數據的時候,都會生成一個新的數據版本,並且把 transaction id 賦值給這個數據版本的事務 ID,記爲 row trx_id。同時,舊的數據版本要保留,並且在新的數據版本中,能夠有信息可以直接拿到它。

也就是說,數據表中的一行記錄,其實可能有多個版本 (row),每個版本有自己的 row trx_id。

在這裏插入圖片描述
如圖 2 所示,就是一個記錄被多個事務連續更新後的狀態。

圖 2 行狀態變更圖
圖中虛線框裏是同一行數據的 4 個版本,當前最新版本是 V4,k 的值是 22,它是被 transaction id 爲 25 的事務更新的,因此它的 row trx_id 也是 25。

你可能會問,前面的文章不是說,語句更新會生成 undo log(回滾日誌)嗎?那麼,undo log 在哪呢?

實際上,圖 2 中的三個虛線箭頭,就是 undo log;而 V1、V2、V3 並不是物理上真實存在的,而是每次需要的時候根據當前版本和 undo log 計算出來的。比如,需要 V2 的時候,就是通過 V4 依次執行 U3、U2 算出來。

明白了多版本和 row trx_id 的概念後,我們再來想一下,InnoDB 是怎麼定義那個“100G”的快照的。

按照可重複讀的定義,一個事務啓動的時候,能夠看到所有已經提交的事務結果。但是之後,這個事務執行期間,其他事務的更新對它不可見。

因此,一個事務只需要在啓動的時候聲明說,“以我啓動的時刻爲準,如果一個數據版本是在我啓動之前生成的,就認;如果是我啓動以後才生成的,我就不認,我必須要找到它的上一個版本”。

當然,如果“上一個版本”也不可見,那就得繼續往前找。還有,如果是這個事務自己更新的數據,它自己還是要認的。

在實現上, InnoDB 爲每個事務構造了一個數組,用來保存這個事務啓動瞬間,當前正在“活躍”的所有事務 ID。“活躍”指的就是,啓動了但還沒提交。

數組裏面事務 ID 的最小值記爲低水位,當前系統裏面已經創建過的事務 ID 的最大值加 1 記爲高水位。

這個視圖數組和高水位,就組成了當前事務的一致性視圖(read-view)。

而數據版本的可見性規則,就是基於數據的 row trx_id 和這個一致性視圖的對比結果得到的。

這個視圖數組把所有的 row trx_id 分成了幾種不同的情況。
在這裏插入圖片描述
圖 3 數據版本可見性規則
這樣,對於當前事務的啓動瞬間來說,一個數據版本的 row trx_id,有以下幾種可能:

  • 如果落在綠色部分,表示這個版本是已提交的事務或者是當前事務自己生成的,這個數據是可見的;

  • 如果落在紅色部分,表示這個版本是由將來啓動的事務生成的,是肯定不可見的;

  • 如果落在黃色部分,那就包括兩種情況
    a. 若 row trx_id 在數組中,表示這個版本是由還沒提交的事務生成的,不可見;
    b. 若 row trx_id 不在數組中,表示這個版本是已經提交了的事務生成的,可見。

比如,對於圖 2 中的數據來說,如果有一個事務,它的低水位是 18,那麼當它訪問這一行數據時,就會從 V4 通過 U3 計算出 V3,所以在它看來,這一行的值是 11。

你看,有了這個聲明後,系統裏面隨後發生的更新,是不是就跟這個事務看到的內容無關了呢?因爲之後的更新,生成的版本一定屬於上面的 2 或者 3(a) 的情況,而對它來說,這些新的數據版本是不存在的,所以這個事務的快照,就是“靜態”的了。

所以你現在知道了,InnoDB 利用了“所有數據都有多個版本”的這個特性,實現了“秒級創建快照”的能力。

接下來,我們繼續看一下圖 1 中的三個事務,分析下事務 A 的語句返回的結果,爲什麼是 k=1。

這裏,我們不妨做如下假設:

  • 事務 A 開始前,系統裏面只有一個活躍事務 ID 是 99;

  • 事務 A、B、C 的版本號分別是 100、101、102,且當前系統裏只有這四個事務;

  • 三個事務開始前,(1,1)這一行數據的 row trx_id 是 90。

這樣,事務 A 的視圖數組就是 [99,100], 事務 B 的視圖數組是 [99,100,101], 事務 C 的視圖數組是 [99,100,101,102]。

爲了簡化分析,我先把其他干擾語句去掉,只畫出跟事務 A 查詢邏輯有關的操作:
在這裏插入圖片描述
圖 4 事務 A 查詢數據邏輯圖
從圖中可以看到,第一個有效更新是事務 C,把數據從 (1,1) 改成了 (1,2)。這時候,這個數據的最新版本的 row trx_id 是 102,而 90 這個版本已經成爲了歷史版本。

第二個有效更新是事務 B,把數據從 (1,2) 改成了 (1,3)。這時候,這個數據的最新版本(即 row trx_id)是 101,而 102 又成爲了歷史版本。

你可能注意到了,在事務 A 查詢的時候,其實事務 B 還沒有提交,但是它生成的 (1,3) 這個版本已經變成當前版本了。但這個版本對事務 A 必須是不可見的,否則就變成髒讀了。

好,現在事務 A 要來讀數據了,它的視圖數組是 [99,100]。當然了,讀數據都是從當前版本讀起的。所以,事務 A 查詢語句的讀數據流程是這樣的:

  • 找到 (1,3) 的時候,判斷出 row trx_id=101,比高水位大,處於紅色區域,不可見;
  • 接着,找到上一個歷史版本,一看 row trx_id=102,比高水位大,處於紅色區域,不可見;
  • 再往前找,終於找到了(1,1),它的 row trx_id=90,比低水位小,處於綠色區域,可見。

這樣執行下來,雖然期間這一行數據被修改過,但是事務 A 不論在什麼時候查詢,看到這行數據的結果都是一致的,所以我們稱之爲一致性讀。

這個判斷規則是從代碼邏輯直接轉譯過來的,但是正如你所見,用於人肉分析可見性很麻煩。

所以,我來給你翻譯一下。一個數據版本,對於一個事務視圖來說,除了自己的更新總是可見以外,有三種情況:

  • 版本未提交,不可見;

  • 版本已提交,但是是在視圖創建後提交的,不可見;

  • 版本已提交,而且是在視圖創建前提交的,可見。

現在,我們用這個規則來判斷圖 4 中的查詢結果,事務 A 的查詢語句的視圖數組是在事務 A 啓動的時候生成的,這時候:

  • (1,3) 還沒提交,屬於情況 1,不可見;
  • (1,2) 雖然提交了,但是是在視圖數組創建之後提交的,屬於情況 2,不可見;
  • (1,1) 是在視圖數組創建之前提交的,可見。

你看,去掉數字對比後,只用時間先後順序來判斷,分析起來是不是輕鬆多了。所以,後面我們就都用這個規則來分析。

更新邏輯

細心的同學可能有疑問了:事務 B 的 update 語句,如果按照一致性讀,好像結果不對哦?

你看圖 5 中,事務 B 的視圖數組是先生成的,之後事務 C 才提交,不是應該看不見 (1,2) 嗎,怎麼能算出 (1,3) 來?
在這裏插入圖片描述
圖 5 事務 B 更新邏輯圖

是的,如果事務 B 在更新之前查詢一次數據,這個查詢返回的 k 的值確實是 1。

但是,當它要去更新數據的時候,就不能再在歷史版本上更新了,否則事務 C 的更新就丟失了。因此,事務 B 此時的 set k=k+1 是在(1,2)的基礎上進行的操作。

所以,這裏就用到了這樣一條規則:更新數據都是先讀後寫的,而這個讀,只能讀當前的值,稱爲“當前讀”(current read)。

因此,在更新的時候,當前讀拿到的數據是 (1,2),更新後生成了新版本的數據 (1,3),這個新版本的 row trx_id 是 101。

所以,在執行事務 B 查詢語句的時候,一看自己的版本號是 101,最新數據的版本號也是 101,是自己的更新,可以直接使用,所以查詢得到的 k 的值是 3。

這裏我們提到了一個概念,叫作當前讀。其實,除了 update 語句外,select 語句如果加鎖,也是當前讀。

所以,如果把事務 A 的查詢語句 select * from t where id=1 修改一下,加上 lock in share mode 或 for update,也都可以讀到版本號是 101 的數據,返回的 k 的值是 3。下面這兩個 select 語句,就是分別加了讀鎖(S 鎖,共享鎖)和寫鎖(X 鎖,排他鎖)。

mysql> select k from t where id=1 lock in share mode;
mysql> select k from t where id=1 for update;

再往前一步,假設事務 C 不是馬上提交的,而是變成了下面的事務 C’,會怎麼樣呢?

在這裏插入圖片描述
圖 6 事務 A、B、C’的執行流程

事務 C’的不同是,更新後並沒有馬上提交,在它提交前,事務 B 的更新語句先發起了。前面說過了,雖然事務 C’還沒提交,但是 (1,2) 這個版本也已經生成了,並且是當前的最新版本。那麼,事務 B 的更新語句會怎麼處理呢?

這時候,我們在上一篇文章中提到的“兩階段鎖協議”就要上場了。事務 C’沒提交,也就是說 (1,2) 這個版本上的寫鎖還沒釋放。而事務 B 是當前讀,必須要讀最新版本,而且必須加鎖,因此就被鎖住了,必須等到事務 C’釋放這個鎖,才能繼續它的當前讀。
在這裏插入圖片描述
圖 7 事務 B 更新邏輯圖(配合事務 C’)
到這裏,我們把一致性讀、當前讀和行鎖就串起來了。

現在,我們再回到文章開頭的問題:事務的可重複讀的能力是怎麼實現的?

可重複讀的核心就是一致性讀(consistent read);而事務更新數據的時候,只能用當前讀。如果當前的記錄的行鎖被其他事務佔用的話,就需要進入鎖等待。

而讀提交的邏輯和可重複讀的邏輯類似,它們最主要的區別是:

  • 在可重複讀隔離級別下,只需要在事務開始的時候創建一致性視圖,之後事務裏的其他查詢都共用這個一致性視圖;
  • 在讀提交隔離級別下,每一個語句執行前都會重新算出一個新的視圖。
    那麼,我們再看一下,在讀提交隔離級別下,事務 A 和事務 B 的查詢語句查到的 k,分別應該是多少呢?

這裏需要說明一下,“start transaction with consistent snapshot; ”的意思是從這個語句開始,創建一個持續整個事務的一致性快照。所以,在讀提交隔離級別下,這個用法就沒意義了,等效於普通的 start transaction。

下面是讀提交時的狀態圖,可以看到這兩個查詢語句的創建視圖數組的時機發生了變化,就是圖中的 read view 框。(注意:這裏,我們用的還是事務 C 的邏輯直接提交,而不是事務 C’)
在這裏插入圖片描述
圖 8 讀提交隔離級別下的事務狀態圖
這時,事務 A 的查詢語句的視圖數組是在執行這個語句的時候創建的,時序上 (1,2)、(1,3) 的生成時間都在創建這個視圖數組的時刻之前。但是,在這個時刻:

  • (1,3) 還沒提交,屬於情況 1,不可見;
  • (1,2) 提交了,屬於情況 3,可見。
    所以,這時候事務 A 查詢語句返回的是 k=2。

顯然地,事務 B 查詢結果 k=3。

小結

InnoDB 的行數據有多個版本,每個數據版本有自己的 row trx_id,每個事務或者語句有自己的一致性視圖。普通查詢語句是一致性讀,一致性讀會根據 row trx_id 和一致性視圖確定數據版本的可見性。

  • 對於可重複讀,查詢只承認在事務啓動前就已經提交完成的數據;
  • 對於讀提交,查詢只承認在語句啓動前就已經提交完成的數據;
    而當前讀,總是讀取已經提交完成的最新版本。

你也可以想一下,爲什麼表結構不支持“可重複讀”?這是因爲表結構沒有對應的行數據,也沒有 row trx_id,因此只能遵循當前讀的邏輯。

當然,MySQL 8.0 已經可以把表結構放在 InnoDB 字典裏了,也許以後會支持表結構的可重複讀。

又到思考題時間了。我用下面的表結構和初始化語句作爲試驗環境,事務隔離級別是可重複讀。現在,我要把所有“字段 c 和 id 值相等的行”的 c 值清零,但是卻發現了一個“詭異”的、改不掉的情況。請你構造出這種情況,並說明其原理。

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);

在這裏插入圖片描述
復現出來以後,請你再思考一下,在實際的業務開發中有沒有可能碰到這種情況?你的應用代碼會不會掉進這個“坑”裏,你又是怎麼解決的呢?

你可以把你的思考和觀點寫在留言區裏,我會在下一篇文章的末尾和你討論這個問題。感謝你的收聽,也歡迎你把這篇文章分享給更多的朋友一起閱讀。

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章