跳至主要內容

MySQL 死锁案例分析:一次由 Gap Lock 引发的死锁

悟空约 1911 字大约 6 分钟...

你好,我是悟空。

背景

本文通过一个实际案例来演示 MySQL 死锁是如何产生的,强烈建议你跟着动手做一遍,这样才能真正理解死锁的根因。

这篇文章带你完整看懂:

  • MySQL 死锁是怎么产生的
  • update + insert 为什么会互相等待
  • 什么是 Next-Key Lock
  • 什么是 Gap Lock
  • 为什么两个 insert 最后会形成死锁

环境说明:MySQL 版本:8.0.27,隔离级别:REPEATABLE-READ(可重复读),REPEATABLE-READ 是 MySQL InnoDB 的默认隔离级别。

下图是本文的整体概括图,可以更加快速的了解全文。

mysql-dead-lock-all
mysql-dead-lock-all

创建示例数据

建表和初始化数据的 SQL 如下:

create table passjava_test_lock2(a int, b int, c int, primary key(a), key idx_b(b));
insert into passjava_test_lock2 values(10,10,10),(15,15,15),(20,20,20);
select * from passjava_test_lock2;
+-----+-----+-----+
|  a  |  b  |  c  |
+-----+-----+-----+
|  10 |  10 |  10 |
|  15 |  15 |  15 |
|  20 |  20 |  20 |
+-----+-----+-----+

索引情况用表格表示是这样的:

  初始状态
  ┌──────┬────────┬───────────┐
  │record│ a (PK) │ b (idx_b) │
  ├──────┼────────┼───────────┤
  │ 1    │ 10     │ 10        │
  ├──────┼────────┼───────────┤
  │ 2    │ 15     │ 15        │
  ├──────┼────────┼───────────┤
  │ 3    │ 20     │ 20        │
  └──────┴────────┴───────────┘

演示如何产生死锁

下面是两个会话的 SQL 操作,会话 1 在左边,会话 2 在右边。为了方便复现,我把完整的 SQL 都贴出来了:

会话 1

-- 开启 deadlocks 日志
SET GLOBAL innodb_print_all_deadlocks = ON;
BEGIN
update passjava_test_lock2 set c= 106 where b = 10;
insert into passjava_test_lock2 values(12,12,12)

会话 2

-- 开启 deadlocks 日志
SET GLOBAL innodb_print_all_deadlocks = ON;

BEGIN
update passjava_test_lock2 set c= 206 where b = 15
insert into passjava_test_lock2 values(14,14,14)
commit

时间线如下所示,整个死锁过程大概 5 秒就结束了:

  时间线

  ┌──────┬────────────────────────────┬───────────────────────────┐
  │ STEP │           SESSION 1        │           SESSION 2       │
  ├──────┼────────────────────────────┼───────────────────────────┤
  │ 1    │ BEGIN                      │ BEGIN                     │
  ├──────┼────────────────────────────┼───────────────────────────┤
  │ 2    │ UPDATE b=10 → 成功          │                           │
  ├──────┼────────────────────────────┼───────────────────────────┤
  │ 3    │                            │ UPDATE b=15 → 成功         │
  ├──────┼────────────────────────────┼───────────────────────────┤
  │ 4    │ INSERT (12,12,12) → 等待中  │                           │
  ├──────┼────────────────────────────┼───────────────────────────┤
  │ 5    │                            │ INSERT (14,14,14) → 死锁!  │
  ├──────┼────────────────────────────┼───────────────────────────┤
  │ 6    │ 5秒后成功                   │ 回滚                       │
  └──────┴────────────────────────────┴───────────────────────────┘
image-20260528103202263
image-20260528103202263

最后真的发生了死锁,会话 2 直接报错:

Deadlock found when trying to get lock; try restarting transaction

锁情况分析

下面我们来逐步分析每个步骤持有的锁,这是理解死锁的关键。

步骤 1 和 2:两个会话分别执行 BEGIN

这个没什么好说的,BEGIN 只是开启事务,此时还没有任何锁。

步骤 3:会话 1 执行 update

执行 SQL:

update passjava_test_lock2 set c= 106 where b = 10;

执行结果:Affected rows: 1, Time: 0s

执行完之后,锁的情况是这样的:

passjava_test_lock2    TABLE    IX
passjava_test_lock2    idx_b    RECORD X                10, 10
passjava_test_lock2    PRIMARY  RECORD X,REC_NOT_GAP    10
passjava_test_lock2    idx_b    RECORD X,GAP            15, 15

用大白话解释一下:

索引锁类型lock_modelock_data锁住的范围(大白话)
TABLEIXNULL意向写锁,占个坑
idx_bRECORDX10, 10Next-key 锁,锁住 (负无穷, 10]
PRIMARYRECORDX,REC_NOT_GAP10主键记录锁,锁住 a=10 这一行
idx_bRECORDX,GAP15, 15纯 GAP 锁,锁住 (10, 15) 这个间隙

等等,为什么 update b=10 会锁住 b=15 前面的间隙?

因为在 REPEATABLE-READ 隔离级别下,InnoDB 为了防止幻读,对于非唯一索引的等值查询(b=10),不仅会锁住查到的记录及其前面的间隙,还会额外锁住下一个不同值(b=15)前面的间隙

这么说吧,用非唯一索引 b=10 做 update,InnoDB 害怕别人在你附近插入新数据导致幻读,所以不仅锁住了 b<=10 的区域,还提前把 (10, 15) 这段空隙也锁上了。这个"空隙锁"在日志里就显示成 lock_data = 15,15,锁模式是 X,GAP

步骤 4:会话 2 执行 update

执行 SQL:

update passjava_test_lock2 set c= 206 where b = 15;

执行结果:Affected rows: 1, Time: 0s

image-20260528113900297
image-20260528113900297

锁的情况:

索引锁类型lock_modelock_data锁住的范围(大白话)
TABLEIXNULL意向写锁,占个坑
idx_bRECORDX15, 15Next-key 锁,锁住 (10, 15]
PRIMARYRECORDX,REC_NOT_GAP15主键记录锁,锁住 a=15 这一行
idx_bRECORDX,GAP20, 20纯 GAP 锁,锁住 (15, 20) 这个间隙

注意看,事务 2 锁住了 (15, 20) 这个间隙。

步骤 5:会话 1 执行 insert

执行 SQL:

insert into passjava_test_lock2 values(12,12,12);

执行结果:等待中

此时事务 1 正在等待的锁:

索引lock_modelock_data说明
idx_bX,GAP,INSERT_INTENTION (waiting)15, 15想在 (10,15) 间隙里插入,但被阻塞

事务 1 想插入 b=12,必须在 (10,15) 间隙上加插入意向锁。但这个间隙已经被事务 2 的 Next-key 锁(包含同一个间隙的 GAP 锁)占着。InnoDB 规定:插入意向锁必须等所有已有的 GAP 锁释放才能获得。所以事务 1 只能等。

所以事务 1 的状态是:LOCK WAIT,等的是 X,GAP,INSERT_INTENTION 这个请求被批准。

步骤 6:会话 2 执行 insert

执行 SQL:

insert into passjava_test_lock2 values(14,14,14);

执行结果:报错 dead lock

Deadlock found when trying to get lock; try restarting transaction
image-20260528094647032
image-20260528094647032
deadlock
deadlock

步骤 7:会话 1 执行成功

事务 1 在等待 5 秒后,事务 2 回滚了,事务 1 终于拿到了锁,执行成功:

Affected rows: 1, Time: 5s

Dead Lock 日志分析

死锁发生之后,我们可以查看死锁日志来验证我们的分析:

$ sudo tail -100 /usr/local/mysql/data/mysqld.local.err

TRANSACTION 2766242, ACTIVE 15 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1128, 4 row lock(s), undo log entries 2
MySQL thread id 12, OS thread handle 6162132992, query id 1194 localhost 127.0.0.1 root root update
insert into passjava_test_lock2 values(12,12,12)
RECORD LOCKS space id 501 page no 5 n bits 72 index idx_b of table `passjava_admin`.`passjava_test_lock2` trx id 2766242 lock_mode X locks gap before rec
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 8000000f; asc     ;;
 1: len 4; hex 8000000f; asc     ;;

RECORD LOCKS space id 501 page no 5 n bits 72 index idx_b of table `passjava_admin`.`passjava_test_lock2` trx id 2766242 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 8000000f; asc     ;;
 1: len 4; hex 8000000f; asc     ;;

TRANSACTION 2766243, ACTIVE 11 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1128, 4 row lock(s), undo log entries 2
MySQL thread id 13, OS thread handle 6163247104, query id 1198 localhost 127.0.0.1 root root update
insert into passjava_test_lock2 values(14,14,14)
RECORD LOCKS space id 501 page no 5 n bits 72 index idx_b of table `passjava_admin`.`passjava_test_lock2` trx id 2766243 lock_mode X
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 8000000f; asc     ;;
 1: len 4; hex 8000000f; asc     ;;

RECORD LOCKS space id 501 page no 5 n bits 72 index idx_b of table `passjava_admin`.`passjava_test_lock2` trx id 2766243 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 8000000f; asc     ;;
 1: len 4; hex 8000000f; asc     ;;

日志分析:

  • 会话 1 持有 (10, 15)GAP 锁(来自 X,GAP on 15,15)
  • 会话 2 持有 (10, 15]Next-key 锁(包含 GAP (10,15) + 记录锁 on 15)
  • 两者都想要在 (10,15) 间隙上获得插入意向锁来插入自己的数据(12 和 14)
  • GAP 锁之间虽然兼容,但插入意向锁必须等待所有 GAP 锁释放,所以互相等 → 死锁

总结

回过头看这个死锁案例,核心原因就一句话:两个事务在非唯一索引上更新不同行时,互相把对方的 GAP 锁区域给锁了,然后双方都想插入数据,形成循环等待

如果你想避免这种死锁,可以考虑:

  1. 使用唯一索引:如果 b 字段换成唯一索引,就不会触发 Next-key 锁,只会锁住那一条记录。
  2. 调整隔离级别:改成 READ COMMITTED 隔离级别,GAP 锁的行为会不一样。
  3. 保持操作顺序一致:如果多个事务都要对多条记录加锁,尽量按相同顺序来。

好了,死锁案例就讲到这里。有问题欢迎留言讨论。

评论
  • 按正序
  • 按倒序
  • 按热度