云计算百科
云计算领域专业知识百科平台

【计算基础|MySQL16】【工程实战】行锁/间隙锁/死锁复现排查

【计算基础|MySQL16】【工程实战】行锁/间隙锁/死锁复现排查

上一篇把事务隔离和 MVCC 用实验跑通了。这一篇是它的"锁篇实战"——线上最常见的两类事故:一条 UPDATE 把整个库写阻塞了,以及报错 ERROR 1213 (40001): Deadlock found when trying to get lock。前者十有八九是"WHERE 没走索引,行锁退化成表锁",后者是两个事务加锁顺序交叉。

这篇继续在 Docker MySQL 8 上开两个会话 A/B,把记录锁、间隙锁、Next-Key Lock、无索引锁全表、经典交叉死锁、间隙锁+插入死锁全部复现一遍,并且用 performance_schema.data_locks 把 InnoDB 实际加了什么锁拍下来,最后读完整的 SHOW ENGINE INNODB STATUS 死锁报告逐行解读。

环境:Docker MySQL 8.0.46,RR 隔离级别。表 t(id INT PK, idx_key INT, val INT, status VARCHAR(20)),idx_key 普通索引,初始数据 (10,10,10),(20,20,20),(30,30,30),(40,40,40),status 默认 active。


一、先认两个看锁的"显微镜"

光靠"会不会阻塞"猜锁是猜不准的。MySQL 8 有两张现成的表,能把 InnoDB 内部加的锁直接 dump 出来:

— 当前所有锁(看 LOCK_TYPE / LOCK_MODE / LOCK_DATA)
SELECT ENGINE_TRANSACTION_ID, LOCK_TYPE, LOCK_MODE, LOCK_DATA
FROM performance_schema.data_locks;

— 当前锁等待关系(谁在等谁)
SELECT * FROM performance_schema.data_lock_waits;

LOCK_MODE 这一列是本篇的命根子,先把它的值和含义钉死:

LOCK_MODE锁类型含义
X Next-Key Lock 记录锁 + 前面间隙锁(InnoDB 默认行锁算法)
X,REC_NOT_GAP 记录锁 只锁这一条记录,不锁间隙
X,GAP 间隙锁 只锁记录前面的间隙,不锁记录本身
X,GAP,INSERT_INTENTION 插入意向锁 INSERT 前在间隙上等待

🔴 记住这四种 mode,后面每次实验我们都拍一张 data_locks 对照。


二、实战 1:记录锁(Record Lock)

结论:唯一索引等值查询命中记录,InnoDB 只给这一行加 X,REC_NOT_GAP,别的行随便动。

步骤会话 A会话 B
1 BEGIN;
2 SELECT * FROM t WHERE id=20 FOR UPDATE;
3 UPDATE t SET val=1 WHERE id=10;(没锁10,应成功)
4 UPDATE t SET val=1 WHERE id=20;(锁了20,应阻塞)
5 COMMIT;

真实输出:

[A] BEGIN
[A] SELECT * FROM t WHERE id=20 FOR UPDATE
| id | idx_key | val | status
| 20 | 20 | 20 | active

A 持锁期间拍 data_locks:

ENGINE_TRANSACTION_ID | LOCK_TYPE | LOCK_MODE | LOCK_DATA
2678 | TABLE | IX | NULL
2678 | RECORD | X,REC_NOT_GAP | 20

B 的操作:

[B] UPDATE t SET val=1 WHERE id=10
-> OK (rowcount=1) <– ✅ id=10 没被锁,直接成功
[B] UPDATE t SET val=1 WHERE id=20
-> 阻塞中(被记录锁挡住),4s 超时 <– 🔴 id=20 被 X,REC_NOT_GAP 锁住
[A] COMMIT
[B] UPDATE id=20 -> 成功 <– A 一提交,B 立刻通过

解读:

  • LOCK_TYPE=TABLE, IX:表级意向排他锁,声明"我要在某些行上加 X 锁"。
  • LOCK_TYPE=RECORD, LOCK_MODE=X,REC_NOT_GAP, LOCK_DATA=20:精准锁在 id=20 这一条主键记录上,REC_NOT_GAP 明确表示不锁间隙。所以 B 插 id=25 都不会被挡(当然这里没插)。

三、实战 2:间隙锁(Gap Lock)

结论:唯一索引等值查询未命中,InnoDB 加 X,GAP,锁住这条记录前面的开区间,别人插不进来。

步骤会话 A会话 B
1 BEGIN;
2 SELECT * FROM t WHERE id=25 FOR UPDATE;(查不到)
3 INSERT INTO t (id,idx_key,val) VALUES (25,25,25);(应阻塞)
4 INSERT INTO t (id,idx_key,val) VALUES (35,35,35);(不在间隙,应成功)
5 COMMIT;

真实输出:

[A] BEGIN
[A] SELECT * FROM t WHERE id=25 FOR UPDATE
| id | idx_key | val | status (空结果集,25 不存在)

A 持锁期间拍 data_locks:

ENGINE_TRANSACTION_ID | LOCK_TYPE | LOCK_MODE | LOCK_DATA
2683 | TABLE | IX | NULL
2683 | RECORD | X,GAP | 30

B 的操作:

[B] INSERT (25,25,25);
-> 阻塞中(被间隙锁挡住),4s 超时 <– 🔴 25 落在 (20,30) 间隙里
[B] INSERT (35,35,35);
-> (A COMMIT 后)成功 <– ✅ 35 不在 (20,30),不冲突

解读:

  • id=25 在主键索引上落在 20 和 30 之间。等值查不到,InnoDB 给"30 这条记录前面的间隙"加 X,GAP,即锁住开区间 (20, 30)。
  • B 想插 25,正好撞进 (20,30),被间隙锁挡住;插 35 落在 (30,40),跟这个间隙锁没关系,成功。

⚠️ 间隙锁的一个反直觉点:间隙锁之间是兼容的——A 锁了 (20,30),B 也能再锁 (20,30),谁都不拦谁。它只拦"往这个间隙里插数据"的 INSERT。这个特性直接导致实战 6 的死锁。


四、实战 3:Next-Key Lock

结论:范围当前读,InnoDB 加 Next-Key Lock = 记录锁 + 间隙锁,锁住整个左开右闭区间。

步骤会话 A会话 B
1 BEGIN;
2 SELECT * FROM t WHERE id>20 AND id<40 FOR UPDATE;
3 INSERT (25,…) / INSERT (35,…)(应阻塞)
4 UPDATE … WHERE id=30(30 在范围里,应阻塞)
5 UPDATE … WHERE id=40(40 不在 id<40,应成功)
6 COMMIT;

真实输出:

[A] BEGIN
[A] SELECT * FROM t WHERE id>20 AND id<40 FOR UPDATE
| id | idx_key | val | status
| 30 | 30 | 30 | active

A 持锁期间拍 data_locks:

ENGINE_TRANSACTION_ID | LOCK_TYPE | LOCK_MODE | LOCK_DATA
2715 | TABLE | IX | NULL
2715 | RECORD | X,GAP | 40
2715 | RECORD | X | 30

B 的操作:

[B] INSERT (25,25,25) -> 阻塞中,3s 超时 <– 🔴 25 落在 (20,30] 间隙
[B] INSERT (35,35,35) -> 阻塞中,3s 超时 <– 🔴 35 落在 (30,40] 间隙
[B] UPDATE … WHERE id=30 -> 阻塞中,3s 超时 <– 🔴 30 这条记录本身被 X 锁
[B] UPDATE … WHERE id=40 -> OK (rowcount=1) <– ✅ 40 不在 id<40 范围,没被锁

解读:

  • LOCK_MODE=X(不带 REC_NOT_GAP)就是 Next-Key Lock。对 id=30 这条记录,锁的是 (20, 30]。
  • LOCK_DATA=40, LOCK_MODE=X,GAP:在 40 前面加间隙锁 (30, 40),因为范围扫描要"扫到不满足条件的第一个值为止"——40 是第一个不满足 id<40 的记录,把它前面的间隙也锁住。
  • 合起来就是锁住了 (20,30] 和 (30,40],所以 25、35 都插不进,30 也改不了;40 是开区间边界外,能改。

五、实战 4:无索引为什么锁全表

结论:WHERE 条件没索引,执行器全表扫描,把扫过的每一行都加 X 锁,效果约等于表锁。

5.1 先复现:status 没索引

status 列一开始没建索引。A 执行:

[A] BEGIN
[A] UPDATE t SET val=1 WHERE status='active'
-> OK (rowcount=4)

拍 data_locks:

ENGINE_TRANSACTION_ID | LOCK_TYPE | LOCK_MODE | LOCK_DATA
2728 | TABLE | IX | NULL
2728 | RECORD | X | supremum pseudo-record
2728 | RECORD | X | 10
2728 | RECORD | X | 20
2728 | RECORD | X | 30
2728 | RECORD | X | 40

🔴 看 LOCK_DATA:10、20、30、40 四条主键记录全被 X 锁了,连 supremum pseudo-record(索引最末尾的"哨兵")都锁了。这就是全表锁。

B 的操作:

[B] UPDATE t SET val=2 WHERE id=10 -> 阻塞中,3s 超时 <– 🔴 id=10 也被锁
[A] COMMIT
[B] UPDATE id=10 -> 成功 <– A 提交后才通

5.2 加索引后对比

CREATE INDEX idx_status ON t(status);

再跑同样的 UPDATE t SET val=1 WHERE status='active',拍 data_locks:

ENGINE_TRANSACTION_ID | LOCK_TYPE | LOCK_MODE | LOCK_DATA
2740 | TABLE | IX | NULL
2740 | RECORD | X | supremum pseudo-record
2740 | RECORD | X | 'active', 10
2740 | RECORD | X | 'active', 20
2740 | RECORD | X | 'active', 30
2740 | RECORD | X | 'active', 40
2740 | RECORD | X,REC_NOT_GAP | 10
2740 | RECORD | X,REC_NOT_GAP | 20
2740 | RECORD | X,REC_NOT_GAP | 30
2740 | RECORD | X,REC_NOT_GAP | 40

前后对比一句话:

场景锁的是B 插一条 status='inactive'
status 无索引 全表 4 条主键记录 + supremum 被阻塞
status 有索引 只锁 secondary index 上 status=‘active’ 的 4 条 + 回表的 4 条 仍受 Next-Key 间隙影响,但范围精确多了

⚠️ 注意:即使加了索引,InnoDB 在 RR 下仍会对 secondary index 加 Next-Key Lock(带间隙),所以插一个全新的 status 值有时也会被挡住。但关键是——有索引时锁的范围是"匹配行+相关间隙",没索引时是"全表所有行"。大表上后者就是生产事故。

🔴 面试题答案:UPDATE/DELETE 的 WHERE 条件没走索引,InnoDB 会全表扫描并对扫描到的每一行加 X 锁;虽然 Server 层会过滤不匹配的行,但在 RR 下这些锁要到事务提交才释放,结果就是锁住全表所有行。所以"大表 UPDATE 一定要看执行计划走没走索引"是铁律。


六、实战 5:经典交叉死锁

结论:两个事务以相反顺序锁同一批行,InnoDB 自动检测到死锁,回滚 undo 量小的那个。

用表 t2(id PK, val),数据 (1,1),(2,2):

步骤会话 A会话 B
1 BEGIN; UPDATE t2 SET val=1 WHERE id=1;
2 BEGIN; UPDATE t2 SET val=2 WHERE id=2;
3 UPDATE t2 SET val=1 WHERE id=2;(等 B 的 id=2)
4 UPDATE t2 SET val=2 WHERE id=1;(等 A 的 id=1)→ 死锁

真实输出:

[A] BEGIN
[A] UPDATE t2 SET val=1 WHERE id=1 -> OK
[B] BEGIN
[B] UPDATE t2 SET val=2 WHERE id=2 -> OK
[A] UPDATE t2 SET val=1 WHERE id=2; (开始等 B 的 id=2 锁)
[B] UPDATE t2 SET val=2 WHERE id=1
!! ERROR: (1213, 'Deadlock found when trying to get lock; try restarting transaction')
[A] UPDATE id=2 -> 成功 <– A 等到了,继续走

🔴 关键:B 立刻收到 1213 死锁错误并被回滚,A 不受影响。这就是 InnoDB 的死锁检测——它在等待图里发现了环,主动选一个代价小的事务回滚来打破环。

6.1 读懂死锁报告

SHOW ENGINE INNODB STATUS\\G 里 LATEST DETECTED DEADLOCK 段(真实输出,已精简 hex 部分):

LATEST DETECTED DEADLOCK
————————
*** (1) TRANSACTION:
TRANSACTION 2817, ACTIVE 1 sec starting index read
UPDATE t2 SET val=1 WHERE id=2 <– 事务1正在执行的SQL

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS … index PRIMARY of table `demo16`.`t2`
trx id 2817 lock_mode X locks rec but not gap
Record lock … 0: len 4; hex 80000001; asc ;; <– 持有 id=1 的锁

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS … trx id 2817 lock_mode X locks rec but not gap waiting
Record lock … 0: len 4; hex 80000002; asc ;; <– 正在等 id=2 的锁

*** (2) TRANSACTION:
TRANSACTION 2818, ACTIVE 1 sec starting index read
UPDATE t2 SET val=2 WHERE id=1 <– 事务2正在执行的SQL

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS … trx id 2818 lock_mode X locks rec but not gap
Record lock … 0: len 4; hex 80000002; asc ;; <– 持有 id=2 的锁

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS … trx id 2818 lock_mode X locks rec but not gap waiting
Record lock … 0: len 4; hex 80000001; asc ;; <– 正在等 id=1 的锁

*** WE ROLL BACK TRANSACTION (2) <– 🔴 回滚事务2(代价小的)

逐行解读:

  • 80000001 / 80000002 是大端编码后的 id 值(MySQL 用大端序存整型,方便比较),即 id=1 和 id=2。
  • 事务 1 持有 id=1、等 id=2;事务 2 持有 id=2、等 id=1——一个完美的等待环。
  • WE ROLL BACK TRANSACTION (2):InnoDB 挑 undo 量小的事务 2 回滚,让事务 1 继续。

七、实战 6:间隙锁 + 插入意向锁死锁

这是 RR 下最隐蔽的死锁,也是"两个事务都没改同一行,却死锁了"的元凶。

步骤会话 A会话 B
1 BEGIN; SELECT * FROM t WHERE id=25 FOR UPDATE;(锁间隙(20,30))
2 BEGIN; SELECT * FROM t WHERE id=25 FOR UPDATE;(也锁间隙,成功)
3 INSERT (25,…);(等 B 的间隙锁)
4 INSERT (26,…);(等 A 的间隙锁)→ 死锁

真实输出:

[A] BEGIN
[A] SELECT * FROM t WHERE id=25 FOR UPDATE -> 空(间隙锁,成功)
[B] BEGIN
[B] SELECT * FROM t WHERE id=25 FOR UPDATE -> 空(间隙锁不互斥,成功)
[A] INSERT (25,25,25); (开始等 B 的间隙锁)
[B] INSERT (26,26,26)
!! ERROR: (1213, 'Deadlock found …')
[A] INSERT (25,25,25) -> 成功

死锁报告关键段:

*** (1) TRANSACTION:
TRANSACTION 2826 … UPDATE
INSERT INTO t (id,idx_key,val) VALUES (25,25,25)
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS … trx id 2826 lock_mode X locks gap before rec <– A 持有 (20,30) 间隙锁
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS … lock_mode X locks gap before rec insert intention waiting <– A 想插入,等B

*** (2) TRANSACTION:
TRANSACTION 2827 … UPDATE
INSERT INTO t (id,idx_key,val) VALUES (26,26,26)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS … trx id 2827 lock_mode X locks gap before rec <– B 也持有 (20,30) 间隙锁
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS … lock_mode X locks gap before rec insert intention waiting <– B 想插入,等A

🔴 为什么会死锁? 间隙锁之间不互斥,所以 A 和 B 能同时锁住同一个间隙 (20,30)。但 INSERT 要往间隙里塞数据,必须拿到"插入意向锁",而插入意向锁和别人持有的间隙锁互斥。于是:A 拿着间隙锁等 B 的间隙锁放行插入,B 拿着间隙锁等 A 的间隙锁放行插入——环形成,死锁。

这也是为什么很多生产事故里"明明两个事务操作的是不同主键,却报死锁"——它们撞在同一个间隙上了。


八、排查 SOP(贴墙上)

线上发现"Update 卡住"或"偶发 1213 死锁",按这个顺序查:

— 1. 现在有哪些事务在跑?谁在等锁?
SELECT trx_id, trx_state, trx_started, trx_query
FROM information_schema.innodb_trx\\G

— 2. 现在到底加了哪些锁?
SELECT ENGINE_TRANSACTION_ID, LOCK_TYPE, LOCK_MODE, LOCK_DATA
FROM performance_schema.data_locks\\G

— 3. 谁在等谁的锁?
SELECT * FROM performance_schema.data_lock_waits\\G

— 4. 哪个会话在等锁?State=Locked 的就是
SHOW PROCESSLIST;

— 5. 实在救不回来,杀会话(谨慎)
KILL <processlist_id>;

— 6. 看最近一次死锁长什么样
SHOW ENGINE INNODB STATUS\\G — 找 LATEST DETECTED DEADLOCK 段

— 7. 把所有死锁都记到 error log(生产建议开)
SET GLOBAL innodb_print_all_deadlocks=ON;

排查顺序口诀:PROCESSLIST 找谁卡 → innodb_trx 看长事务 → data_locks 看加了什么锁 → data_lock_waits 顺着等待链找到源头 → 源头 SQL 看 explain 有没有走索引。


九、锁等待 vs 死锁(别再搞混)

对比项锁等待死锁
本质 一个事务等另一个事务释放锁 两个事务互相等,形成环
MySQL 反应 一直等,直到 innodb_lock_wait_timeout(默认 50s)超时报 1205 立刻检测到环,主动回滚一个事务报 1213
错误码 1205 Lock wait timeout exceeded 1213 Deadlock found when trying to get lock
代码要做什么 重试,或优化索引减少锁范围 捕获 1213,重试整个事务

🔴 工程上的硬规矩:代码里捕获到 1213 或 1205 必须重试事务,不能让用户看到"系统忙"。重试几次还失败,再去查慢 SQL 和索引。


总结:你真正需要记住的 7 件事

  • 看锁用 performance_schema.data_locks,LOCK_MODE 四种值:X(Next-Key)、X,REC_NOT_GAP(记录锁)、X,GAP(间隙锁)、X,GAP,INSERT_INTENTION(插入意向锁等待)。
  • 唯一索引等值命中 → REC_NOT_GAP 只锁那行;等值未命中 → GAP 锁间隙;范围查询 → Next-Key Lock 锁住左开右闭区间。
  • WHERE 没索引 = 全表扫描加全表行锁,data_locks 里能看到每一条主键记录都被 X 锁;大表上这是事故,UPDATE 前必须看 explain。
  • 间隙锁之间不互斥,但和插入意向锁互斥——这是"两个事务没改同一行却死锁"的根因。
  • 死锁是 InnoDB 主动检测、主动回滚 undo 小的事务,错误码 1213;锁等待是等超时才报错,错误码 1205。
  • 死锁报告看 LATEST DETECTED DEADLOCK:谁 holds 什么、waiting 什么、WE ROLL BACK TRANSACTION (n) 回滚了谁。
  • 代码必须对 1213/1205 做事务重试;排查按 PROCESSLIST → innodb_trx → data_locks → data_lock_waits 顺序。
  • 验证清单

    • 能复现记录锁:WHERE id=20 FOR UPDATE 后 data_locks 出现 X,REC_NOT_GAP | 20,改 id=10 成功、改 id=20 阻塞
    • 能复现间隙锁:WHERE id=25 FOR UPDATE 后出现 X,GAP | 30,插 25 阻塞、插 35 成功
    • 能复现 Next-Key:范围查询后 data_locks 出现 X | 30 和 X,GAP | 40,插 25/35 都阻塞
    • 能复现无索引锁全表:data_locks 里 10/20/30/40 全被 X 锁;加索引后范围明显缩小
    • 能复现交叉死锁,从 SHOW ENGINE INNODB STATUS 里指出谁 holds id=1 等 id=2、谁 holds id=2 等 id=1
    • 能解释间隙锁+插入为什么会死锁(间隙锁不互斥、插入意向锁互斥)
    • 能区分 1205 锁等待超时和 1213 死锁,并说出代码该怎么处理

    参考资源

    • MySQL 8.0 官方文档:InnoDB 锁模式 https://dev.mysql.com/doc/refman/8.0/en/innodb-lock-modes.html
    • MySQL 8.0:InnoDB 死锁检测 https://dev.mysql.com/doc/refman/8.0/en/innodb-deadlock-detection.html
    • MySQL 8.0:performance_schema.data_locks https://dev.mysql.com/doc/refman/8.0/en/performance-schema-data-locks-table.html
    • MySQL 8.0:performance_schema.data_lock_waits https://dev.mysql.com/doc/refman/8.0/en/performance-schema-data-lock-waits-table.html
    • MySQL 8.0:innodb_print_all_deadlocks https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_print_all_deadlocks

    下一篇《三大日志:undo/redo/binlog》:锁讲完了,离开"并发"进入"可靠性"——undo log 怎么支撑回滚和 MVCC 版本链、redo log 怎么靠 WAL 保证 crash 不丢数据、binlog 三种格式有啥区别、以及那条经典的"两阶段提交"为什么必须存在。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【计算基础|MySQL16】【工程实战】行锁/间隙锁/死锁复现排查
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!