“我一直覺得在休息的同時,也是人和人拉開差距的時候。我之前喜歡說一句話,生時何必久睡,死後自然長眠。現在我覺得早睡早起身體好。”
本篇文章是學習極客專欄幻讀後所寫的,文章中一些總結來源於作者。
開篇
如果沒有特別說明,本篇的內容都是在可重複讀級別事務下進行,因為為了解決幻讀,InnoDB 引入了間隙鎖?,而間隙鎖是在可重複讀的隔離級別下才會生效的,所以如果你把隔離級別設定成讀提交的情況下,就不存在間隙鎖了,當然你需要面對的是其他出現的問題了,也不在本篇文章的討論之中,本篇文章主要是以實踐為主。
開始之前執行一下命令檢視自己 InnoDB 事務隔離級別。
show variables like 'transaction_isolation'
幻讀的定義
為了便於分析,我們開始在資料庫中建立如下表,並初始化插入一些資料。把id設定為主鍵索引,欄位c設定為普通索引,並插入六條資料。
接著我們來看下面的一條語句,會咋麼進行加鎖。
begin;
select * from t where d=5 for update;
commit;
比較好理解是,這條語句會查詢到 d=5 這一行,也就是記錄(5,5,5),對應的主鍵 id =5,這裡的 for update 會在查詢完成之後,給當前主鍵 id 等於5的這條記錄加一個寫鎖,由於兩階段鎖的協議,這個寫鎖會在事務 commit 之後才釋放。
現在的問題在於,如果只是在 id =5 這一行上加鎖,而其他行不加鎖的情況下會發生什麼?下面模擬多個事務同時進行的情況。
從上面可以看到事務A執行了三次查詢,並且都是 for update,表示當前讀,按照我們說的,如果只是給 id=5 這一行加上寫鎖,這時候事務 B 的 T2 時刻在編輯 id=0 這一條記錄的時候並沒有被鎖住,所以事務 B 的的更新語句可以執行,那麼 id=0 的當前的記錄變成(0,0,5),事務 B 並沒有像 A 一樣顯式的開啟事務,代表的這一條更新就是事務,更新完畢事務提交,事務 A 的 T3 時刻當前讀,條件語句還是 where,所以可以讀取事務 B 提交的修改。T4 時刻 C 事務插入一條記錄,所以 T5 時刻 A 事務查詢的結果是3條。接著 T6 時刻事務 A 提交事務,釋放鎖。
這裡需要說明的是,在 T5 時刻讀到 id=1 這一行資料才稱之為幻讀,幻讀指的是在一次事務中前後兩次查詢同一範圍資料的時候,後一次查詢看到了前一次查詢沒有看到的行,事務 A 的 T3 時刻查詢 where d=5 的時候,此時並沒有 id=1 這一行資料,等到事務 A 的 T5 時刻查詢的時候由於事務 C 的 T4 時刻插入了一條 id=1 的資料,導致了 id=1 這一行的幻讀,所以你可以知道,幻讀僅僅指新插入的行,所以對於 id=0 的修改不能算是幻讀。**
那麼按照我們想的有什麼問題嗎?當然有問題。首先是事務 A 在 T1 時刻就宣告要把所有 d=5 的行鎖住,但是現在這個語義已經被破壞了。讓我們來稍微修改下列子。
由於事務 A 的 T1 時刻只是鎖住 id=5 這一條資料加了行數,但是並沒有給 id=0 的資料加上行鎖,導致此時即便它的 d=5 也可以進行更新操作,至於事務 C 在事務 A 加鎖的時候記錄都還沒生成,導致它也能進行修改操作。這兩條語句都破壞了事務A 在 T1 時刻的加鎖宣告。
另一點在於資料一致性的問題。如果我們再在事務 A 那稍微改動一下。
update 加鎖的語義和 select ..... for update
的語義是一致的,所以這時候加上 update
也很合理。如果按照這個流程走過來,會發生什麼,注意事務 A的事務要到 T6 時刻才提交。
當然有問題了,等到 T6 時候事務 A 提交事務的時候,會將所有 d=5 記錄的 d 值修改成 1000,這就造成了本來正常情況下事務 B 的記錄是(0,5,5),事務 C 的記錄是 (1,5,5)。這時候三行 d=5 的資料變成了 (0,5,100),(1,5,100)和(5,5,100)。這時候就發生了資料不一致。
所以綜上所述,如果在
select * from t where d=5 for update
的時候只是給 id=5 這一行加上行鎖是不行的。如果我們把 select 掃描到的行全部加上行鎖呢?那麼事務 B 確實沒問題,因為在它要更新的時候,事務 A 的寫鎖導致它被鎖住,需要等到事務 A 提交事務才得以進行,所以沒問題。但是事務 C 就不一樣了,因為在事務 A 鎖住掃描行的時候,id=1 這條記錄還沒誕生呢,所以也就不存在被鎖住了,這也就是幻讀的問題還是沒解決
如何解決幻讀
幻讀產生的原因就是行鎖只能鎖住行, 但是對於新插入的記錄,更新的是行之間的間隙,InnoDB
為了在可重複讀的情況下解決幻讀問題,引入了間隙鎖(Gap Lock)。
這樣當你再次執行 select * from t where d =5 for update
的時候,就不僅僅只是給6條記錄加上行鎖,還同時加上7個間隙鎖。這樣能確保的是在一個事務鎖的期間,沒有新的記錄能 insert 進來,就解決了幻讀的問題。
間隙鎖之前不互鎖。比如說。
上面的語句事務 B 並不會被鎖住,因為表中沒有 c=7 這條記錄,所以事務 A 加的是間隙鎖(5,10),而事務 B 也是加的(5,10) 的間隙鎖,他們保護的是共同的目標,就是保護這個間隙不被插入新值,所以他們並不衝突。
間隙鎖和行鎖合稱 next-key lock
,每個 next-key lock
是前開後閉區間。也就是說,我們的表 t 初始化以後,如果用 select * from t for update
要把整個表所有記錄鎖起來,就形成了 7 個 next-key lock
,分別是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。
這裡的 supremum
的值是多少呢。實際上 InnoDb 給每一個索引加了一個不存在的最大值 supremum
,這樣就符合前開後閉的準則了。
間隙鎖在解決幻讀的同時,也會帶來新的問題,比如下面的。
我們來實驗一下,看發生了什麼。事務 B 在插入的時候會被鎖住。
當我在事務 A 中執行同樣的操作時,事務 A 此時也被鎖住了,這時候已經造成了死鎖,兩個事務互相等待對方釋放鎖的資源,形成了死鎖,當然 InnoDB
馬上監測到死鎖,讓事務 A 的 insert
語句直接報錯返回並且釋放鎖,所以此時可以看到事務 B 獲取到鎖馬上執行插入成功。
這兩個事務的執行語句加鎖過程又是則麼樣的呢?
事務 A 執行 select * from t where id =9 ,因為 id=9 並不存在,所以語句加上間隙鎖(5,10)。
事務 B 執行 select * from t where id =9, 因為 此時 id=9 不存在,所以事務 B 也加上間隙鎖(5,10)。
當事務 B 執行 insert (9,9,9) 的時候 ,被事務 A 的間隙鎖鎖住了。
當事務 A 執行 insert (9,9,9) 的時候,被事務 B 的間隙鎖鎖住了。
因此產生了死鎖。
在引入間隙鎖之後,可能會導致同樣的語句鎖住更大的範圍。影響了併發性。為了解決幻讀,我們竟然引入了這麼多東西,還能咋麼搞?開篇就已經說過了,間隙鎖是在可重複讀的隔離級別下才會出現的,如果改為讀已提交的情況下,就沒有這些問題了,當然出現的問題就是讀已提交情況下能出現的問題了?。
上面介紹的只是間隙鎖和 next-key lock
的概念,還沒有實際開始加鎖的規則,下面開始一些實踐。MySQL 在後續版本中可能會修改加鎖的規則,所以以下的內容版本應該在 5.x 系列 <=5.7.24,8.0 系列 <=8.0.13。
首先上面提到過間隙鎖和行鎖合稱 next-key lock
,每個 next-key lock
是前開後閉區間,這也是加鎖的基本單位。接下來我們來看第一個例子。等值查詢的間隙鎖。
思考下結果是什麼?我們來簡單分析一下,事務 A 在 T1 時刻進行加鎖的操作,由於當前記錄中並不存在 id=7 這條記錄,所以加鎖的單位就是 next-key lock
,所以加鎖的範圍就是(5,10],同時這是一個等值的查詢 id=7, 而 id=10顯然不滿足條件,所以鎖退化成間隙鎖即(5,10)。所以最終的結果應該是事務 B 被掛起,事務 C 執行成功,讓我們來驗證一下。
同時開啟三個視窗,事務 B 插入被鎖住了,而事務 C 可以執行。
剛才是在唯一索引上進行的操作,接下來是非唯一索引等值鎖。
我們先來複現一下結果。千萬別驚呆?
什麼情況❓你可以看到 事務 B 竟然沒有被鎖住,反而是事務 C 被鎖住了。首先加鎖會給(0,5]加上 next-key lock
,接著因為 c 只是普通索引,所以在查詢 c=5 的時候並不會立即停下。而是會繼續向右查詢,此時查詢到下一條 c=10 (不等於5),根據規則,訪問到的都必須加鎖,所以給(5,10]加上 next-key lock
。但是同時是等值判斷最後一個 c=10 明顯不等於5,所以又退化為間隙鎖(5,10)。之前說的只有訪問到的物件會加上鎖。你再看事務 A ,這個查詢明顯會使用到覆蓋索引,並不需要去訪問主鍵索引,換句話說,這時候主鍵索引沒有被訪問,那就不會給他加鎖。所以事務 B 並沒有被鎖住,事務 C 被事務 A 間隙鎖(5,10)鎖住了。
上面的例子不同點在於 lock in share mode
,這個語句只會鎖住覆蓋索引,如果你想讓他鎖住事務 B 很簡單 改為 for update
,系統會預設你接下來要修改資料,會順便給主鍵索引上滿足條件的記錄加上行鎖。我們來看一下。
現在就可以看到兩個事務都被事務 A 鎖住了。
再來看一個主鍵索引範圍鎖。
我們來分析一下,開始的時候要找到第一行 id=10,本來應該是 next-key lock(5,10]
,因為是主鍵上的等值查詢,所以退化成行鎖,所以只鎖住 id=10 這一行。接下來範圍查詢的時候,找到 id=15 這一行即停下,所以此時需要加上間隙鎖(10,15],所以綜合下來事務 A 鎖的範圍是主鍵索引上,行鎖 id=10 以及間隙鎖(10,15],這時候你再看下面這張圖,你就知道為什麼了。
上面的幾個例子展示了一些間隙鎖的加鎖規則,當前實際的場景還有很多很多,比如如果帶上 limit 的時候又是咋麼加鎖的呢,這些都是可以自己動手做實踐的,實踐才能檢驗真理。
其實在專欄裡,作者已經總結了間隙鎖的加鎖原則,我這裡直接照搬過來,間隙鎖規則包含了兩個“原則”、兩個“優化”和一個“bug”。
原則 1:加鎖的基本單位是 next-key lock。希望你還記得,next-key lock 是前開後閉區間。
原則 2:查詢過程中訪問到的物件才會加鎖。
優化 1:索引上的等值查詢,給唯一索引加鎖的時候,next-key lock 退化為行鎖。
優化 2:索引上的等值查詢,向右遍歷時且最後一個值不滿足等值條件的時候,next-key lock 退化為間隙鎖。一個 bug:唯一索引上的範圍查詢會訪問到不滿足條件的第一個值為止。