記錄鎖、間隙鎖與 Next-Key Lock
有小伙伴在微信上表示面試時被問到了 Next-Key Lock 是啥,結果一臉懵逼,那么今天我們來捋一捋 MySQL 中的記錄鎖、間隙鎖以及 Next-Key Lock。
1. Record LockRecord
Lock 也就是我們所說的記錄鎖,記錄鎖是對索引記錄的鎖,注意,它是針對索引記錄,即它只鎖定記錄這一行數據。
例如如下一條 SQL:
select * from user where id=1 for update;
注意,id 是索引,id 如果不是索引,上面這條 SQL 所加的排他鎖就不是一個 Record Lock。
我們來看如下一個例子:
首先我們將系統變量 innodb_status_output_locks 設置為 ON,如下:
接下來我們執行如下 SQL,鎖定一行數據,此時會自動為表加上 IX 鎖:
接下來我們在一個新的會話中執行如下指令來查看 InnoDB 存儲引擎的情況:
show engine innodb status\G
輸出的信息很多,我們重點關注 TRANSACTIONS,如下:
可以看到:
- TABLE LOCK table test08.user trx id 3564804 lock mode IX:這句就是說事務 id 為 3564804 的事務,為 user 表添加了意向排他鎖(IX)。
- RECORD LOCKS space id 851 page no 3 n bits 80 index PRIMARY of table test08.user trx id 3564804 lock_mode X locks rec but not gap:這個就是一個鎖結構的記錄,這里的索引是 PRIMARY,加的鎖也是正兒八經的記錄鎖(not gap)。
看到了 LOCKS REC BUT NOT GAP,就說明這是一個記錄鎖。
那么這個 Record Lock 和我們之前所講的 S 鎖以及 X 鎖有什么區別呢?S 鎖是共享鎖,X 鎖是排他鎖,當我們加 S 鎖或者 X 鎖的時候,如果用到了索引,鎖加在了某一條具體的記錄上,那么這個鎖也是一個記錄鎖(其實,記錄鎖,S 鎖,X 鎖,概念有一些重復的地方,但是描述的重點不一樣)。
或者也可以理解為記錄鎖又細分為 S 鎖和 X 鎖,它們之間的兼容性如下圖:
兼容性 | S 型記錄鎖 | X 型記錄鎖 |
S 型記錄鎖 | 兼容 | 不兼容 |
X 型記錄鎖 | 不兼容 | 不兼容 |
2. Gap Lock
Gap Lock 也叫做間隙鎖,它的存在可以解決幻讀問題,另外需要注意,Gap Lock 也只在 REPEATABLE READ 隔離級別下有效。先來看看什么是幻讀,我們來看如下一個表格:
有兩個會話,A 和 B,先在會話 A 中開啟事務,然后查詢 age 為 99 的用戶總數,注意使用當前讀,因為在默認的隔離級別下,默認的快照讀并不能讀到其他事務提交的數據,至于快照讀和當前讀的區別,大家參考:S 鎖與 X 鎖,當前讀與快照讀!。當會話 A 中第一次查詢過后,會話 B 中向數據庫添加了一行記錄,等到會話 A 中第二次查詢的時候,就查到了和第一次查詢不一樣的結果,這就是幻讀(注意幻讀專指數據插入引起的不一致)。
在 MySQL 默認的隔離級別 REPEATABLE READ 下,上圖所描述的情況無法復現。無法復現的原因在于,在 MySQL 的 REPEATABLE READ 隔離級別中,它已經幫我們解決了幻讀問題,解決的方案就是 Gap Lock。
大家想想,之所以出現幻讀的問題,是因為記錄之間存在縫隙,用戶可以往這些縫隙中插入數據,這就導致了幻讀問題,如下圖:
如圖所示,id 之間有縫隙,有縫隙就有漏洞。前面我們所說的記錄鎖只能鎖住一條具體的記錄,但是對于記錄之間的空隙卻無能無力,這就導致了幻讀(其他事務可往縫隙中插入數據)。
現在 Gap Lock 間隙鎖,就是要把這些記錄之間的間隙也給鎖住,間隙鎖住了,就不用擔心幻讀問題了,這也是 Gap Lock 存在的意義。
給一條記錄加 Gap Lock,是鎖住了這條記錄前面的空隙,例如給 id 為 1 的記錄加 Gap Lock,鎖住的范圍是 (-∞,1),給 id 為 3 的記錄加 Gap Lock,鎖住的范圍是 (1,3),那么 id 為 10 后面的空隙怎么鎖定呢?MySQL 提供了一個 Supremum 表示當前頁面中的最大記錄,所以最后針對 Supremum 鎖住的范圍就是 (10,+∞),這樣,所有的間隙都被覆蓋到了,由于鎖定的是間隙,所以都是開區間。
那么我們怎么樣能看到 Gap Lock 呢?我給大家舉一個簡單的例子,假設我有如下一張表:
CREATE TABLE `user` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`age` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `age` (`age`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
一個簡單的表,id 是主鍵,age 是普通索引,表中有如下幾條記錄:
接下來我們執行如下 SQL,鎖定一行數據,此時也會產生間隙鎖:
接下來我們在一個新的會話中執行如下指令來查看 InnoDB 存儲引擎的情況:
show engine innodb status\G
輸出的信息很多,我們重點關注 TRANSACTIONS,如下:
紅色框選中的,就是一個間隙鎖的加鎖記錄,可以看到,在某一個記錄之前加了間隙鎖。
這就是間隙鎖。非常重要的一點需要大家牢記:Gap Lock 只在 REPEATABLE READ 隔離級別下有效。
3. Next-Key Lock
以下內容都是基于 MySQL 默認的隔離級別 REPEATABLE READ。
如果我們既想鎖定一行,又想鎖定行之間的記錄,那么就是 Next-Key Lock 了,換言之,Next-Key Lock 是 Record Lock 和 Gap Lock 的結合體。
正常來說,我們加行鎖的基本單位就是 Next-Key Lock,即既有記錄鎖又有間隙鎖,但是有時候 Next-Key Lock 會退化,我們通過幾個簡單的例子來分析一下。
首先我們來看看 Next-Key Lock 的加鎖規則:
- 鎖的范圍是左開右閉。
- 如果是唯一非空索引的等值查詢,Next-Key Lock 會退化成 Record Lock。
- 普通索引上的等值查詢,向后遍歷時,最后一個不滿足等值條件的時候,Next-Key Lock 會退化成 Gap Lock。
我們通過幾個簡單的例子來分析下。
3.1 唯一非空索引
假設我有一個學生表,學生表中有學生的姓名和成績,如下:
CREATE TABLE `student` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`score` double NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `score` (`score`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
id 是主鍵,score 是成績,其中 score 是唯一非空索引。
現在表中有如下數據:
假設我們執行如下 SQL:
在這個例子中,由于 score 是唯一非空索引,所以 Next-Key Lock 會退化成 Record Lock,換句話說,這行 SQL 只給 score 為 90 的記錄加鎖,不存在 Gap Lock,即我們新開一個會話,插入一條 score 為 88 的記錄也是 OK 的。
不過這里有一個特例,如果鎖定的是一個不存在的記錄,那么也會產生間隙鎖,例如下面這個:
由于并不存在 score 為 91 的記錄,所以這里會產生一個范圍為 (90,95) 的間隙鎖,我們執行如下 SQL 可以驗證:
可以看到,90.1、94.9 都會被阻塞(我按了 Ctrl C,所以大家看到查詢終止)。
90、95 則不符合唯一非空索引的條件。
95.1 則可以插入成功。
沒問題。
3.2 非空索引
現在我們重新開始,將 score 索引改為普通索引,如下:
CREATE TABLE `student` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`score` double NOT NULL,
PRIMARY KEY (`id`),
KEY `score` (`score`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
數據還是跟前面一樣,此時我們來執行如下 SQL:
我們來分析下。
此時要鎖定的是 id 為 90 的記錄,那么首先加間隙鎖,上一個 score 為 89,所以這次加的間隙鎖范圍是 (89,90),同時要鎖定 id 為 90 的記錄,所以進一步優化為 (89,90]。
同時,這里還有一條規則,就是滿足條件的上一條記錄,也需要被鎖住,所以最終的鎖范圍就是 [89,90]。
由于 score 不是唯一性索引,所以還需要繼續向后查找,找到的下一條記錄是 95,由于此時 Next-Key Lock 會退化成 Gap Lock,所以鎖定的范圍是 (90,95)。綜上,最終鎖定的范圍是 [89,95)。
接下來我們可以新開一個會話,我們分別嘗試添加如下數據看看是否能夠添加成功:
可以看到,score 為 88 是可以的,但是為 89.1 就不行。
score 為 95 也是可以的,但是為 94.9 就不行。
再試一下 89 是否可以:
說明我們上面分析的加鎖范圍是正確的。
再來看如下一條 SQL:
跟前面的案例相比,這次多了 limit 1,limit 1 表示只要一條記錄,所以這次查找到 90 之后就不會再往后查找了,那么最終的鎖就是間隙鎖+一個記錄鎖,最終的范圍就是 [89,90]。
此時新開一個會話,分別插入 score 為 88.9、89、90、91 的 記錄,驗證我們上面所分析的加鎖范圍:
88.9 和 89 的插入結果跟我們預想的一致。
可以看到,這里 90 也能插入,能插入的原因是因為缺乏 90 往后的間隙鎖。
4. 小結
MySQL 中的鎖有點繁雜,小伙伴們可以趁著某個周末,花點時間捋一捋,以后面試再遇到這些問題的時候就不頭大了。