關於 InnoDB 鎖的超全總結
- 2020 年 3 月 8 日
- 筆記
幾個月之前,開始深入學習 MySQL 。說起資料庫,並發控制是其中很重要的一部分。於是,就這樣開起了 MySQL 鎖的學習,隨著學習的深入,發現想要更好的理解鎖,需要了解 MySQL 事務,數據底層的存儲方式,MySQL 的執行流程,特別是索引的選擇等。
在學習期間,查找了不少資料,現根據個人的理解總結下來,方便日後複習。
InnoDB 鎖一覽
先從 MySQL 官網的鎖介紹開始,來逐一認識下這些讓我們夜不能寐的小王八蛋:

Shared and Exclusive Locks
這二位正式稱呼呢,就是共享鎖和排他鎖,其實就是我們常說的讀鎖和寫鎖。它們之間的互斥規則,想必都清楚,就不贅述了。但有一點需要注意,共享鎖和排他鎖是標準的實現行級別的鎖。舉例來說,當給 select 語句應用 lock in share mode 或者 for update,或者更新某條記錄時,加的都是行級別的鎖。
與行級別的共享鎖和排他鎖類似的,還有表級別的共享鎖和排他鎖。如 LOCK TABLES ... WRITE/READ 等命令,實現的就是表級鎖。
Intention Locks
在 InnoDB 中是支援多粒度的鎖共存的,比如表鎖和行鎖。而 Intention Locks - 意向鎖,就是表級鎖。和行級鎖一樣,意向鎖分為 intention shared lock (IS) 和 intention exclusive lock (IX) . 但有趣的是,IS 和 IX 之間並不互斥,也就是說可以同時給不同的事務加上 IS 和 IX. 兼容性如下:

這時就產生疑問了,那它倆存在的意義是什麼?作用就是,和共享鎖和排他鎖互斥。注意下,這裡指的是表級別的共享鎖和排他鎖,和行級別沒有關係!
官網中給了這樣一段解釋:
The main purpose of intention locks is to show that someone is locking a row, or going to lock a row in the table. 意向鎖的目的就是表明有事務正在或者將要鎖住某個表中的行。想像這樣一個場景,我們使用 select * from t where id=0 for update; 將 id=0 這行加上了寫鎖。假設同時,一個新的事務想要發起 LOCK TABLES ... WRITE 鎖表的操作,這時如果沒有意向鎖的話,就需要去一行行檢測是否所在表中的某行是否存在寫鎖,從而引發衝突,效率太低。相反有意向鎖的話,在發起 lock in share mode 或者 for update 前就會自動加上意向鎖,這樣檢測起來就方便多了。
在實際中,手動鎖表的情況並不常見,所以意向鎖並不常用。特別是之後 MySQL 引入了 MDL 鎖,解決了 DML 和 DDL 衝突的問題,意向鎖就更不被提起來了。
Record Locks
record lock ,就是常說的行鎖。InnoDB 中,表都以索引的形式存在,每一個索引對應一顆 B+ 樹,這裡的行鎖鎖的就是 B+ 中的索引記錄。之前提到的共享鎖和排他鎖,就是將鎖加在這裡。
Gap Locks
Gap Locks, 間隙鎖鎖住的是索引記錄間的空隙,是為了解決幻讀問題被引入的。有一點需要注意,間隙鎖和間隙鎖本身之間並不衝突,僅僅和插入這個操作發生衝突。
Next-Key lock
next-key lock 是行鎖(Record)和間隙鎖的並集。在 RR 級別下,InnoDB 使用 next-key 鎖進行樹搜索和索引掃描。記住這句話,加鎖的基本單位是 next-key lock.
加鎖規則
該加鎖原則由林曉斌老師刷程式碼後總結,符合的版本如下:
- MySQL 版本:5.x - 5.7.24, 8.0 - 8.0.13. 我是 5.7.27 也未發現問題。
規則包括:兩個「原則」、兩個「優化」和一個「bug」。
- 原則1:加鎖的基本單位是 next-key lock。next-key lock 是前開後閉區間。
- 原則2:查找過程中訪問到的對象才會加鎖。
- 優化1:索引上的等值查詢,給唯一索引加鎖的時候,next-key lock 退化為行鎖。
- 優化2:索引上的等值查詢,向右遍歷時且最後一個值不滿足等值條件的時候,next-key lock 退化為間隙鎖。
- 一個 bug:唯一索引上的範圍查詢會訪問到不滿足條件的第一個值為止。
解釋下容易理解錯誤的地方:
對優化 2 的說明:
從等值查詢的值開始,向右遍歷到第一個不滿足等值條件記錄結束,然後將不滿足條件記錄的 next-key 退化為間隙鎖。
等值查詢和遍歷有什麼關係?
在分析加鎖行為時,一定要從索引的數據結構開始。通過樹搜索的方式定位索引記錄時,用的是"等值查詢",而遍歷對應的是在記錄上向前或向後掃描的過程。
應用場景
表結構如下:
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); 場景1:主鍵索引等值間歇鎖
| Session A | Session B | Session C |
|---|---|---|
| begin; | ||
| update t set d=d+1 where id=7; | ||
| insert into t values(8,8,8); | ||
| 被阻塞 | update t set d=d+1 where id=10; | |
| 查詢正常 |
其中 id 列為主鍵索引,並且 id=7 的行並不存在。
對於 Session A 來說:
- 根據原則1,加鎖的單位是 next-key, 因為 id=7 在 5 - 10 間,next-key 默認是左開右閉。所以範圍是 (5,10].
- 根據優化2,但因為 id=7 是等值查詢,到 id=10 結束。next-key 退化成間隙鎖 (5,10).
對於 Session B 來說:
- 插入操作與間隙鎖衝突,所以失敗。
對於 Session C 來說:
- 根據原則1,next-key 加鎖 (5,10].
- 根據優化1:給唯一索引加鎖時,退化成行鎖。範圍變為:id=10 的行鎖
- Session C 和 Session A (5,10) 並不起衝突,所以成功。
這裡可以看出,行鎖和間隙鎖都是有 next-key 鎖滿足一定後條件後轉換的,加鎖的默認單位是 next-key.
場景2:非唯一索引等值鎖
| Session A | Session B | Session C |
|---|---|---|
| begin; | ||
| select id from t where c=5 lock in share mode; | ||
| update t set d=d+1 where id=5; | ||
| 查詢正常 | insert into t values(7,7,7); | |
| 被阻塞 |
關注幾點:c為非唯一索引,查詢的欄位僅有 id,lock in share mode 給滿足條件的行加上讀鎖。
Session A:
- c=5,等值查詢且值存在。先加 next-key 鎖,範圍為 (0,5].
- 由於 c 是普通索引,因此僅訪問 c=5 這一條記錄不會停止,會繼續向右遍歷,到 10 結束。根據原則2,這時會給 id=10 加 next-key (5,10].
- 但 id=10 同時滿足優化2,退化成間隙鎖 (5,10).
- 根據原則2,該查詢使用覆蓋索引,可以直接得到 id 的值,主鍵索引未被訪問到,不加鎖。
Session B:
- 根據原則1 和優化1,給 id=10 的主鍵索引加行鎖,並不衝突,修改成功。
Session C:
- 由於 Session A 已經對索引 c 中 (5,10) 的間隙加鎖,與插入 c=7 衝突, 所以被阻塞。
可以看出,加鎖其實是在索引上,並且只加在訪問到的記錄上,如果想要在 lock in share mode 下避免數據被更新,需要引入覆蓋索引不能包含的欄位。
假設將 Session A 的語句改成 select id from t where c=5 for update;, for update 表示可能當前事務要更新數據,所以也會給滿足的條件的主鍵索引加鎖。這時 Session B 就會被阻塞了。
場景3:非唯一索引等值鎖-鎖主鍵
| Session A | Session B |
|---|---|
| begin; | |
| select id from t where c=5 lock in share mode; | |
| insert into t values(9,10,7); | |
| 被阻塞 | |
和場景 2 很相似,該例主要是為了更好的說明間隙的概念。
Session A 的加鎖範圍不變,給索引 C 加了 (0,5] 和 (5,10) 的行鎖。需要知道的是,非唯一索引形成的 key,需要包含主鍵 id,用於保證唯一性,畫個圖如下。

由圖中可知,由於非唯一索引存在主鍵id,並且按照 B+ 樹的排序規則,不光 c 的值在加鎖範圍內不能被更改和插入,對應 id 的範圍也不能被更改。怎麼理解這句話呢,上個例子不是說,主鍵索引不會被加鎖,怎麼這裡的主鍵 id 又被鎖了呢?
首先主鍵索引是另外一顆B+樹確實沒有被鎖,但這裡由於 C 是非唯一索引,形成的B+樹需要將主鍵索引的 Id 包含進來,並在按照先 c 欄位,後 id 欄位進行排序。這樣,在給 c 欄位加行鎖時,對應的 id 也同時加了行鎖。
上面例子中,Session2 更新成功是因為修改的是 d 地段,並沒有更新 id 的值,所以成功了。而這裡想要插入的 (id=6, c=10), 雖然 c=10 沒有鎖,但 id=6 卻在鎖的範圍內,所以這裡就被阻塞了。同樣插入 (id=100,c=6) 也會被阻塞。
也就說,對於非唯一索引,考慮加鎖範圍時要考慮到主鍵 Id 的情況。
場景4:主鍵索引範圍鎖
| Session A | Session B | Session C |
|---|---|---|
| begin; | ||
| select * from t where id>=10 and id <11 for update; | ||
| insert into t values(8,8,8); | ||
| 正常 | ||
| insert into t values(13,13,13); | ||
| 被阻塞 | ||
| update t set d=d+1 where id=15; | ||
| 被阻塞 |
Session A:
- 先找到 id=10 行,屬於等值查詢。根據原則1,優化1,範圍是 id=10 這行。
- 向後遍歷,屬於範圍查詢,找到 id=15 這行,根據原則2,範圍是 (10,15]
Session B:
- 插入 (8,8,8) 可以,(13,13,13) 就不行了。
Session C:
- id=15 同樣在鎖的範圍內,所以也被阻塞。
場景5:非唯一索引範圍鎖
| Session A | Session B | Session C |
|---|---|---|
| begin; | ||
| select * from t where c>=10 and c <11 for update; | ||
| insert into t values(8,8,8); | ||
| 被阻塞 | ||
| insert into t values(13,13,13); | ||
| 被阻塞 | ||
| update t set d=d+1 where c=15; | ||
| 被阻塞 |
Session A:
- 由於 c 是非唯一索引,索引對於 c=10 等值查詢來說,根據原則1,加鎖範圍為 (5,10].
- 向右遍歷,範圍查詢,加鎖範圍為 (10,15].
Session B:
- (8,8,8) 和 (13,13,13) 都衝突,所以被阻塞。
Session C:
- c=5 也被鎖住,也會被阻塞。
場景6:唯一索引範圍鎖 bug
| Session A | Session B | Session C |
|---|---|---|
| begin; | ||
| select * from t where id>10 and id <=15 for update; | ||
| update t set d=d+1 where id=20; | ||
| 被阻塞 | ||
| insert into t values(16,16,16); | ||
| 被阻塞 |
Session A:
- 由於開始找大於 10 的過程中是第一次是範圍查詢,所以沒有優化原則。加 (10,15].
- 有一個特殊的地方,理論上說由於 id 是唯一主鍵,找到 id=15 就應該停下來了,但實際沒有。根據 bug 原則,會繼續掃描第一個不滿足的值為止,接著找到 id=20,因為是範圍查找,沒有優化原則,繼續加鎖 (15,20].
這個 bug 在 8.0.18 後已經修復了
對於 Session B 和 Session C 均和加鎖的範圍衝突。
場景7:非唯一索引 delete
| Session A | Session B | Session C |
|---|---|---|
| begin; | ||
| delete from t where c=10; | ||
| insert into t values(12,12,12) | ||
| 被阻塞 | ||
| update t set d=d+1 where c=15; | ||
| 成功 |
delete 後加 where 語句和 select * for update 語句的加鎖邏輯類似。
Session A:
- 根據原則1,加 (5,10] 的 next-key.
- 向右遍歷,根據優化2,加 (10,15) 的間歇鎖。
場景8:非唯一索引 limit
| Session A | Session B |
|---|---|
| begin; | |
| delete from t where c=10 limit 1; | |
| insert into t values(12,12,12) | |
| 正常 |
雖然 c=10 只有一條記錄,但和場景7 的加鎖範圍不同。
Session A:
- 根據原則1,加 (5,10] 的 next-key.
- 因為加了 limit 1,所以找到一條就可以了,不需要繼續遍歷,也就是說不在加鎖。
所以對於 session B 來說,就不在阻塞。
場景9:next-key 引發死鎖
| Session A | Session B |
|---|---|
| begin; | |
| select id from t where c=10 lock in share mode; | |
| update t set d=d+1 where c=10; | |
| 被阻塞 | |
| insert into t values(8,8,8) | |
| ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction |
- Session A 第一句 加 (5,10] next-key. 和 (10,15) 的 間隙鎖。
- Session B,和 Session A 想要的加鎖範圍相同,先加 (5,10] next-key 發現被阻塞,後面 (10,15) 沒有被加上,暫時等待。
- Session A,加入 (8,8,8) 和 Session B 的加鎖範圍 (5,10] 衝突,被阻塞。形成 A,B 相互等待的情況。引發死鎖檢測,釋放 Session B.
假如把 insert into t values(8,8,8) 改成 insert into t values(11,11,11) 是可以的,因為 Session B 的間歇鎖 (10,15) 沒有被加上。
分析下死鎖:
mysql> show engine innodb statusG; ------------------------ LATEST DETECTED DEADLOCK ------------------------ 2020-03-08 17:04:10 0x7f9be0057700 *** (1) TRANSACTION: TRANSACTION 836108, ACTIVE 16 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 1653, OS thread handle 140307320846080, query id 1564409 localhost cisco updating update t set d=d+1 where c=10 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 251 page no 4 n bits 80 index c of table `my_test`.`t` trx id 836108 lock_mode X waiting Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0 0: len 4; hex 8000000a; asc ;; 1: len 4; hex 8000000a; asc ;; *** (2) TRANSACTION: TRANSACTION 836109, ACTIVE 22 sec inserting mysql tables in use 1, locked 1 5 lock struct(s), heap size 1136, 3 row lock(s), undo log entries 1 MySQL thread id 1655, OS thread handle 140307455112960, query id 1564410 localhost cisco update insert into t values(8,8,8) *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 251 page no 4 n bits 80 index c of table `my_test`.`t` trx id 836109 lock mode S Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0 0: len 4; hex 8000000a; asc ;; 1: len 4; hex 8000000a; asc ;; *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 251 page no 4 n bits 80 index c of table `my_test`.`t` trx id 836109 lock_mode X locks gap before rec insert intention waiting Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0 0: len 4; hex 8000000a; asc ;; 1: len 4; hex 8000000a; asc ;; *** WE ROLL BACK TRANSACTION (1) (1) TRANSACTION表明發生死鎖的第一個事務資訊。(2) TRANSACTION表明發生死鎖的第二個事務資訊。WE ROLL BACK TRANSACTION (1)表明死鎖的處理方案。
針對 (1) TRANSACTION:
(1) WAITING FOR THIS LOCK TO BE GRANTED表示update t set d=d+1 where c=10要申請寫鎖,並處於鎖等待的情況。- 申請的對象是
n_fields 2,hex 8000000a;和hex 8000000a;, 也就是 id=10 和 c=10 的記錄。
針對 (1) TRANSACTION:
HOLDS THE LOCK(S):表示當前事務2持有的鎖是 :hex 8000000a;和hex 8000000a;.WAITING FOR THIS LOCK TO BE GRANTED:表示對於insert into t values(8,8,8)進行所等待。lock_mode X locks gap before rec insert intention waiting: 表明在插入意向鎖時,等待一個間隙鎖(gap before rec)。
所以最後選擇,回滾事務 (1)。
場景10:非唯一索引 order by
| Session A | Session B |
|---|---|
| begin; | |
| select * from t where c>=15 and c<=20 order by c desc lock in share mode; | |
| insert into t values(6,6,6); | |
| 被阻塞 |
在分析具體的加鎖過程時,先要分析語句的執行順序。如 Session A 中使用了 ordery by c desc 按照降序排列的語句,這就意味著需要在索引樹 C 上,找到第一個 20 的值,然後向左遍歷。並且由於 C 是非唯一索引 20 的值應該是記錄中最右邊的值。
Session A 的加鎖過程:
- 在找到第一個 c=20 的值後,加 next-key (15,20].
- 但不會停下,因為無法確定當前 c=20 是最右面的值,繼續遍歷到 c=25,發現不滿足,根據優化2,加 (20,25) 的間隙鎖。
- 然後從最左面的 c=20 向左遍歷,找到 c=15,加鎖 next-key (10,15].
- 和之前是一樣,無法確定 c=15 是最左面的值,繼續遍歷到 c=10,根據優化2,加(5,10)的間隙鎖 。
- 最後由於是
select *對應主鍵索引 id=10,15,20 加行鎖。
場景 11:INSERT INTO .... SELECT ...
| Session A | Session B |
|---|---|
| begin; | begin; |
| insert into t values(1,1,1); | |
| insert into t (id,c,d) select 1,1,1 from t where id=1; | |
| 被阻塞 |
為了保證數據的一致性,對於 INSERT INTO .... SELECT ... 中 select 部分會加 next-key 的讀鎖。
對於 Session A,在插入數據後,有了 id=1 的行鎖。而 Session B 中的 select 雖然是一致性讀,但會加上 id=1 的讀鎖。與 Session A 衝突,所以被阻塞。
場景12:不等號條件里的等值查詢
begin; select * from t where id>9 and id<12 order by id desc for update; 這裡由於是
order by語句,優化器會先找到第一個比 12 小的值。在索引樹搜索過程後,其實要找到 id=12 的值,但沒有找到,向右遍歷找到 id=15,所以加鎖 (10,15].但由於第一次查找是等值查找(在索引樹上搜索),根據優化2,變為間隙鎖 (10,15).
然後向左遍歷,變為範圍查詢,找到 id=5 這行,加 (0,5] 的 next-key.
場景 13:等值查詢 in
begin; select id from t where c in(5,20,10) lock in share mode; mysql> explain select id from t where c in (5,20,10) lock in share modeG; *************************** 1. row *************************** id: 1 select_type: SIMPLE table: t partitions: NULL type: range possible_keys: c key: c key_len: 5 ref: NULL rows: 3 filtered: 100.00 Extra: Using where; Using index 1 row in set, 1 warning (0.00 sec) ERROR: No query specified rows=3 並且使用索引 c,說明三個值都是通過 B+ 樹搜索定位的。
- 先查找 c=5,鎖住 (0,5]. 由於 c 不是唯一索引,向右遍歷到 c=10,開始是等值查找,加 (5,10).
- 查找 c=15,鎖住 (10,15], 再加 (15,20).
- 最後查找 c=20,鎖住 (15,20]. 再加 (20,25).
可見,在 MySQL 中,鎖是一個個逐步加的。
假設還有一個這樣的語句:
select id from t where c in(5,20,10) order by c desc for update; 由於是 order by c desc,雖然這裡的加鎖範圍沒有變,但是加鎖的順序發生了改變,會按照 c=20,c=10,c=5. 的順序加鎖。雖然說間隙鎖本身並不衝突,但記錄鎖卻會。這樣如果是兩個語句並發的情況,就可能發生死鎖,第一個語句擁有了 c5 的行鎖,請求c=10 的行鎖。當第二個語句,擁有了 c=10 的行鎖,請求 c=5 的行鎖。
場景14:GAP 動態鎖
| Session A | Session B |
|---|---|
| begin; | |
| select * from t where id>10 and id<=15 for update; | |
| delete from t where id=10; | |
| 成功 | |
| insert into t values(10,10,10); | |
| 被阻塞。 |
這裡 insert 被阻塞,就是因為間隙鎖是個動態的概念,Session B 在刪除 id=10 的記錄後,Session A 持有的間隙變大。
對於 Session A 原來持有,(10,15] 和 (15,20] 的 next-key 鎖。 Session B 刪除 id=10 的記錄後,(10,15] 變成了 (5,15] 的間隙。所以之後就插入不回去了。
場景15:update Gap 動態鎖
| Session A | Session B |
|---|---|
| begin; | |
| select * from t where id> 5 lock in share mode; | |
| update t set c=1 where c = 5; | |
| 成功 | |
| update t set c=5 where c = 1; | |
| 被阻塞。 |
Session A 加鎖:(5,10], (10,15], (15,20], (20,25], (25,supermum].
c>5 第一個找到的是 c=10,並且是範圍查找,沒有優化原則。
Session B 的 update 可以拆成兩步:
- 插入 (c=1,id=5).
- 刪除 (c=5,id=5).
或者理解成,加(0.5] next-key 和 (5,10) 的間隙鎖,但間隙鎖不衝突。
修改後 Session A 的鎖變為;
(c=1, 10], (10,15], (15,20], (20,25], (25,supermum].
接下來:update t set c=1 where c = 1
- 插入 (c=5,id=5).
- 刪除 (c=1,id=5).
第一步插入意向鎖和間隙鎖衝突。
