前言
上篇文章我們知道了產生幻讀的原因 ,以及 innoDB 中解決幻讀的方案 ,這篇文章中我將介紹關於 in share mode 和 for update 這兩種上鎖在可重復讀事務下的一些規則 .
文章部分描述和圖片來自<<MySQL 45講>> ,屬於學習總結 ,半原創
規則
課程中講到了幾點規則, 該規則的前提是 :
MySQL后面的版本可能會改變加鎖策略,所以這個規則只限於截止到現在的最新版本,即5.x系列<=5.7.24,8.0系列 <=8.0.13。
在分析得出最終加鎖的結果前 ,我們一般可以從兩個角度去分析
-
誰加鎖 ,是主鍵索引還是非聚集索引 , 如果是非聚集索引加的鎖那么我更新一條主鍵相關的記錄是否就是沒問題的
-
加鎖范圍 ,到底是從哪里鎖到哪里 ,是開閉區間
抓住這兩個相信你分析加鎖的邏輯的時候會有所幫助.
加鎖規則里面,包含了兩個“原則”、兩個“優化”和一個“bug”。 -
原則1:加鎖的基本單位是next-key lock。希望你還記得,next-key lock是前開后閉區間。
-
原則2:查找過程中訪問到的對象才會加鎖。
-
優化1:索引上的等值查詢,給唯一索引加鎖的時候,next-key lock退化為行鎖。
-
優化2:索引上的等值查詢,向右遍歷時且最后一個值不滿足等值條件的時候,next-key lock退化為間隙鎖。
-
一個bug:唯一索引上的范圍查詢會訪問到不滿足條件的第一個值為止。
我們用一張表用於做例子使用 ,建表語句如下:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
上述總結為2個原則 ,2個優化 ,1個bug .我們對上面的規則做一下說明.
-
首先我們分析加鎖的過程中是先根據原則再根據是否符合優化點得出加鎖的邏輯的. 下面我們會有例子表述
-
優化1中給
唯一索引加鎖,我們自然而然地想到是where id = x for update這種情況 (id為主鍵索引) ,當然不限於這種情況, 也有可能是where 非聚集索引 = x for update,也會鎖住唯一索引 . -
注意兩個優化中指的是
等值判斷而不是范圍查詢
案例說明
以下案例均來自課程 ,非原創
案例一: 等值查詢間隙鎖

由於表t中沒有id=7的記錄,所以用我們上面提到的加鎖規則判斷一下的話:
-
根據原則1,加鎖單位是next-key lock,session A加鎖范圍就是(5,10];
-
同時根據優化2,這是一個
等值查詢(id=7),而id=10不滿足查詢條件,next-key lock退化成間隙鎖,因此最終加鎖的范圍是(5,10)。
上鎖對象 : 主鍵索引 , 范圍 : (5,10)
所以,session B要往這個間隙里面插入id=8的記錄會被鎖住,但是session C修改id=10這行是可以的。
案例二:非唯一索引等值鎖
第二個例子是關於覆蓋索引上的鎖:

這里session A要給索引c上c=5的這一行加上讀鎖.
-
根據原則1,加鎖單位是next-key lock,因此會給(0,5]加上next-key lock。
-
要注意c是普通索引,因此僅訪問c=5這一條記錄是不能馬上停下來的,需要向右遍歷,查到c=10才放棄。根據原則2,訪問到的都要加鎖,因此要給(5,10]加next-key lock。
-
但是同時這個符合優化2:等值判斷,向右遍歷,最后一個值不滿足c=5這個等值條件,因此退化成間隙鎖(5,10)。
-
根據原則2 ,只有訪問到的對象才會加鎖,這個查詢使用覆蓋索引,並不需要訪問主鍵索引,所以主鍵索引上沒有加任何鎖,這就是為什么session B的update語句可以執行完成。
上鎖對象 : 非聚集索引 , 范圍 : (0,10)
但session C要插入一個(7,7,7)的記錄,就會被session A的間隙鎖(5,10)鎖住。
需要注意,在這個例子中,lock in share mode只鎖覆蓋索引,但是如果是for update就不一樣了。 執行 for update時,系統會認為你接下來要更新數據,因此會順便給主鍵索引上滿足條件的行加上行鎖。
這個例子說明,鎖是加在索引上的;同時,它給我們的指導是,如果你要用lock in share mode來給行加讀鎖避免數據被更新的話,就必須得繞過覆蓋索引的優化,在查詢字段中加入索引中不存在的字段。比如,將session A的查詢語句改成select d from t where c=5 lock in share mode。你可以自己驗證一下效果。想想這是為啥 ,因為非聚集的那顆B+樹保存的是主鍵索引呀 ,找到后就直接返回了 ,不用回表.
也就是說我們在可重復讀事務隔離級別給使用到了覆蓋索引的記錄上S鎖的時候有可能是沒鎖到的!!解決方案有兩種 :
- 不使用覆蓋索引
- 使用
for update強行上X鎖 ,直接把主鍵索引鎖住
案例三:主鍵索引范圍鎖
第三個例子是關於范圍查詢的。
舉例之前,你可以先思考一下這個問題:對於我們這個表t,下面這兩條查詢語句,加鎖范圍相同嗎?
mysql> select * from t where id=10 for update;
mysql> select * from t where id>=10 and id<11 for update;
范圍查詢 ,優化中是等值查詢 ,所以兩個優化都無效了
你可能會想,id定義為int類型,這兩個語句就是等價的吧?其實,它們並不完全等價。
在邏輯上,這兩條查語句肯定是等價的,但是它們的加鎖規則不太一樣。現在,我們就讓session A執行第二個查詢語句,來看看加鎖效果。

現在我們就用前面提到的加鎖規則,來分析一下session A 會加什么鎖呢?
-
開始執行的時候,要找到第一個id=10的行,因此本該是next-key lock(5,10]。 根據優化1, 主鍵id上的等值條件,退化成行鎖,只加了id=10這一行的行鎖。
-
范圍查找就往后繼續找,找到id=15這一行停下來,因此需要加next-key lock(10,15]。
所以,session A這時候鎖的范圍就是主鍵索引上,行鎖id=10和next-key lock(10,15]。這樣,session B和session C的結果你就能理解了。
這里你需要注意一點,首次session A定位查找id=10的行的時候,是當做等值查詢來判斷的,而向右掃描到id=15的時候,用的是范圍查詢判斷。
案例四:非唯一索引范圍鎖
接下來,我們再看兩個范圍查詢加鎖的例子,你可以對照着案例三來看。
需要注意的是,與案例三不同的是,案例四中查詢語句的where部分用的是字段c。

圖4 非唯一索引范圍鎖
例子中是范圍查詢, 所以肯定用不到優化
這次session A用字段c來判斷,加鎖規則跟案例三唯一的不同是:在第一次用c=10定位記錄的時候,索引c上加了(5,10]這個next-key lock后,由於索引c是非唯一索引,沒有優化規則,也就是說不會蛻變為行鎖,因此最終sesion A加的鎖是,索引c上的(5,10] 和(10,15] 這兩個next-key lock。
所以從結果上來看,sesson B要插入(8,8,8)的這個insert語句時就被堵住了。
這里需要掃描到c=15才停止掃描,是合理的,因為InnoDB要掃到c=15,才知道不需要繼續往后找了。
案例五:唯一索引范圍鎖bug
前面的四個案例,我們已經用到了加鎖規則中的兩個原則和兩個優化,接下來再看一個關於加鎖規則中bug的案例。

圖5 唯一索引范圍鎖的bug
session A是一個范圍查詢,按照原則1的話,應該是索引id上只加(10,15]這個next-key lock,並且因為id是唯一鍵,所以循環判斷到id=15這一行就應該停止了。
但是實現上,InnoDB會往前掃描到第一個不滿足條件的行為止,也就是id=20。而且由於這是個范圍掃描,因此索引id上的(15,20]這個next-key lock也會被鎖上。
所以你看到了,session B要更新id=20這一行,是會被鎖住的。同樣地,session C要插入id=16的一行,也會被鎖住。
為什么說是個bug呢?
照理說,這里鎖住id=20這一行的行為,其實是沒有必要的。因為掃描到id=15,就可以確定不用往后再找了。但實現上還是這么做了.
案例六:非唯一索引上存在"等值"的例子
接下來的例子,是為了更好地說明“間隙”這個概念。這里,我給表t插入一條新記錄。
mysql> insert into t values(30,10,30);
新插入的這一行c=10,也就是說現在表里有兩個c=10的行。那么,這時候索引c上的間隙是什么狀態了呢?你要知道,由於非唯一索引上包含主鍵的值,所以是不可能存在“相同”的兩行的。

圖6 非唯一索引等值的例子
可以看到,雖然有兩個c=10,但是它們的主鍵值id是不同的(分別是10和30),因此這兩個c=10的記錄之間,也是有間隙的。
圖中我畫出了索引c上的主鍵id。為了跟間隙鎖的開區間形式進行區別,我用(c=10,id=30)這樣的形式,來表示索引上的一行。
現在,我們來看一下案例六。
這次我們用delete語句來驗證。注意,delete語句加鎖的邏輯,其實跟select ... for update 是類似的,也就是我在文章開始總結的兩個“原則”、兩個“優化”和一個“bug”。

圖7 delete 示例
這時,session A在遍歷的時候,先訪問第一個c=10的記錄。同樣地,根據原則1,這里加的是(c=5,id=5)到(c=10,id=10)這個next-key lock。
然后,session A向右查找,直到碰到(c=15,id=15)這一行,循環才結束。根據優化2,這是一個等值查詢,向右查找到了不滿足條件的行,所以會退化成(c=10,id=10) 到 (c=15,id=15)的間隙鎖。
也就是說,這個delete語句在索引c上的加鎖范圍,就是下圖中藍色區域覆蓋的部分。

圖8 delete加鎖效果示例
這個藍色區域左右兩邊都是虛線,表示開區間,即(c=5,id=5)和(c=15,id=15)這兩行上都沒有鎖。
沒啥問題 ,只是讓大家更加深刻地知道了`間隙`這個東西
案例七:limit 語句加鎖
例子6也有一個對照案例,場景如下所示:

圖9 limit 語句加鎖
這個例子里,session A的delete語句加了 limit 2。你知道表t里c=10的記錄其實只有兩條,因此加不加limit 2,刪除的效果都是一樣的,但是加鎖的效果卻不同。可以看到,session B的insert語句執行通過了,跟案例六的結果不同。
這是因為,案例七里的delete語句明確加了limit 2的限制,因此在遍歷到(c=10, id=30)這一行之后,滿足條件的語句已經有兩條,循環就結束了。
因此,索引c上的加鎖范圍就變成了從(c=5,id=5)到(c=10,id=30)這個前開后閉區間,如下圖所示:

圖10 帶limit 2的加鎖效果
可以看到,(c=10,id=30)之后的這個間隙並沒有在加鎖范圍里,因此insert語句插入c=12是可以執行成功的。
這個例子對我們實踐的指導意義就是,在刪除數據的時候盡量加limit。這樣不僅可以控制刪除數據的條數,讓操作更安全,還可以減小加鎖的范圍。
當然我得知道后面有兩條數據 ,這個可以先 `select` 然后再執行 delete , 或是delete后面跟一個子語句獲得記錄條數
案例八:一個死鎖的例子
前面的例子中,我們在分析的時候,是按照next-key lock的邏輯來分析的,因為這樣分析比較方便。最后我們再看一個案例,目的是說明:next-key lock實際上是間隙鎖和行鎖加起來的結果。
你一定會疑惑,這個概念不是一開始就說了嗎?不要着急,我們先來看下面這個例子:

圖11 案例八的操作序列
現在,我們按時間順序來分析一下為什么是這樣的結果。
-
session A 啟動事務后執行查詢語句加lock in share mode,在索引c上加了next-key lock(5,10] 和間隙鎖(10,15);
-
session B 的update語句也要在索引c上加next-key lock(5,10] ,進入鎖等待;
-
然后session A要再插入(8,8,8)這一行,被session B的間隙鎖鎖住。由於出現了死鎖,InnoDB讓session B回滾。
你可能會問,session B的next-key lock不是還沒申請成功嗎?
其實是這樣的,session B的“加next-key lock(5,10] ”操作,實際上分成了兩步,先是加(5,10)的間隙鎖,加鎖成功;然后加c=10的行鎖,這時候才被鎖住的。
也就是說,我們在分析加鎖規則的時候可以用next-key lock來分析。但是要知道,具體執行的時候,是要分成間隙鎖和行鎖兩段來執行的。
也就是實際上 session B 在等 session A 對於 `id = 10` 這個行鎖的釋放 ,而對於 session A 而后執行的 `insert` 操作又依賴於 session B 間隙鎖的釋放 ,所以兩者互相依賴,形成死鎖
總結
今天學習了可重復讀隔離級別下加鎖的規則
參考資料
- <<MySQL45講>>
