幾個月之前,開始深入學習 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+ 中的索引記錄。之前提到的共享鎖和排他鎖,就是將鎖加在這里。
RECORD LOCKS space id 251 page no 3 n bits 80 index PRIMARY of table `my_test`.`t` trx id 1339983 lock_mode X locks rec but not gap
Record lock, heap no 5 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
0: len 4; hex 8000000f; asc ;;
1: len 6; hex 0000001471fa; asc q ;;
2: len 7; hex 5e00000a812aff; asc ^ * ;;
3: len 4; hex 8000000f; asc ;;
4: len 4; hex 8000000f; asc ;;
Gap Locks
Gap Locks, 間隙鎖鎖住的是索引記錄間的空隙,是為了解決幻讀問題被引入的。有一點需要注意,間隙鎖和間隙鎖本身之間並不沖突,僅僅和插入這個操作發生沖突。
TABLE LOCK table `my_test`.`t` trx id 1339983 lock mode IX
RECORD LOCKS space id 251 page no 4 n bits 80 index c of table `my_test`.`t` trx id 1339983 lock_mode X locks gap before rec
Record lock, heap no 7 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 80000019; asc ;;
1: len 4; hex 80000019; asc ;;
Next-Key lock
next-key lock 是行鎖(Record)和間隙鎖的並集。在 RR 級別下,InnoDB 使用 next-key 鎖進行樹搜索和索引掃描。記住這句話,加鎖的基本單位是 next-key lock.
RECORD LOCKS space id 251 page no 4 n bits 80 index c of table `my_test`.`t` trx id 1339983 lock_mode X
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 ;;
打開 InnoDB 鎖日志
打開 InnoDB 的鎖日志,在遇到一些奇葩的現象時,會幫助我們確定鎖范圍。
SET GLOBAL innodb_status_output=ON;
SET GLOBAL innodb_status_output_locks=ON;****
加鎖規則
該加鎖原則由林曉斌老師刷代碼后總結,符合的版本如下:
- 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 status\G;
------------------------
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 的記錄。
針對 (2) 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 mode\G;
*************************** 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).
第一步插入意向鎖和間隙鎖沖突。