最近线上偶发 MySQL 的死锁异常,发现原来很多理论都只背了个结论,细节都是魔鬼。
比如,MySQL 在 RR 级别用 gap lock 防止幻读,RC 级别就没有 gap lock 吗?
不妨来一起看看,MySQL 的死锁问题有哪些你不了解的细节。
死锁信息
数据库基本信息
- 版本:MySQL 5.7
- 隔离级别: READ-COMMITTED
- 表结构:
死锁日志
死锁日志分析
1)事务 1
HOLDS THE LOCK(S)
: 该事务持有两个 S 锁,其中一个锁在索引idx_displaydataid
的MX4TYZIKTKSZCAABAAAAAAY8$f$w_4
位置上WAITING FOR THIS LOCK TO BE GRANTED
: 该事务在等待索引idx_displaydataid
的MX4TYXYKTJ6VKAABAAAAADY8$m$462
位置上,等待一个 X 锁
2)事务 2
HOLDS THE LOCK(S)
: 该事务持有两个 S 锁,其中一个锁在索引idx_displaydataid
的MX4TYXYKTJ6VKAABAAAAADY8$m$462
位置上WAITING FOR THIS LOCK TO BE GRANTED
: 该事务在等待索引idx_displaydataid
的MX4TYZIKTKSZCAABAAAAAAY8$f$w_4
一个 X 锁
死锁原因看起来比较清楚,锁互斥且循环等待,造成了死锁。
死锁疑点
随着我仔细分析上面的日志,发现又不是那么简单,或者说有几个疑点困惑:
-
Question1:
gap before rec
表示一个间隙锁,我们数据库的隔离级别是RC
,怎么还有间隙锁? -
Question2:
gap before rec insert intention
好像叫插入意向锁,到底是个啥? -
Question3: INSERT 语句,到底有几把锁?为什么会获得 S 锁?
死锁答疑
为什么 RC 级别下还有间隙锁?
网上很多博客视频都会说 RC
级别下间隙锁会失效,然后搬出官方文档的原话:
Gap locking can be disabled explicitly. This occurs if you change the transaction isolation level to READ COMMITTED or enable the innodb_locks_unsafe_for_binlog system variable (which is now deprecated).
但是,官方文档后面还有一句:
In this case, gap locking is disabled for searches and index scans and is used only for foreign-key constraint checking and duplicate-key checking.
意思是 RC
级别下间隙锁会用于外键和唯一键检查。
插入意向锁到底是什么?
查阅了官方文档,我们可以了解到,插入意向锁(Insert Intention Locks
)其实是一种特殊的 gap lock
,在行插入前,要获取这个锁(所以这个锁是在行排它锁之前获取)。
假设存在值为 4 和 7 的索引记录,尝试插入值 5 和 6 的两个事务,在获取插入行上的排它锁之前,使用插入意向锁锁定间隙,即在(4,7)上加 gap lock
。
但是这两个事务不会互相冲突等待。
但是如果这个区间已经存在其他普通 gap lock
(比如其他事务用 select for update
或者 select in share mode
获取了 gap lock
),则插入意向锁会被阻塞。
注意,这也是我们常说的
gap lock
能够避免幻读的原因,可以阻止 INSERT 获取插入意向锁
如果多个事务插入相同数据导致唯一冲突,则在重复的索引记录上加读锁,这个我们后面再详细介绍。
简单来说,插入意向锁的属性为:
- 它不会阻塞其他任何锁;
- 它本身仅会被
gap lock
阻塞
INSERT 到底有几把锁
1)普通 INSERT
-
先加插入意向锁,插入意向锁之间不冲突。比如 4,7 两行之间,可以同时插入 5、6 两行。
-
插入成功后,加对应行锁。
2)INSERT 唯一索引冲突
INSERT 的时候,发现唯一索引冲突,触发 duplicate-key error 后,会先获取到一个 next-key 读锁。
session A 第二次插入时,发生唯一键冲突的时候,并不只是简单地报错返回,还在冲突的索引上加了锁。
一个 next-key lock 就是由它右边界的值定义的。这时候,session A 持有索引 c 上的 (5,10] 共享 next-key 读锁,所以 session B 插入时也被阻塞了。
总结一下:
- 通常 INSERT 语句,先加插入意向锁,插入成功后,获得行锁,排它锁
- 在 INSERT 之前,先通过插入意向锁,判断是否可以插入(仅会被
gap lock
阻塞) - 当插入唯一冲突时,在重复索引上添加 next-key 读锁
事务 1 插入成功未提交,获取了排它锁,但是事务 1 最终可能会回滚,所以其他重复插入事务不应该直接失败,这个时候他们改为申请读锁。
总结下 INSERT 几种经典死锁
模式一:唯一索引并发写入回滚
- session A 插入,获得行写锁;
- session B、C 插入时,发现唯一索引冲突,同时请求 next-key 读锁,锁排队;
- session A 回滚,释放行写锁,session B、C 同时获得 next-key 读锁
- session B、C 尝试插入,需要获取插入意向锁,互斥等待,触发死锁
模式二:唯一索引并发删除插入
- session A 拿到行写锁(delete from where 正常情况是获取 next-key 锁,只有当唯一索引命中时会变成行锁)
- sessionB/C 发现唯一索引冲突,触发 duplicate-key error 后,同时请求 next-key 读锁,锁排队;
- session A commit 后,删除成功,释放行写锁,sessionB/C 获得 next-key 读锁
- session B、C 尝试插入,需要获取插入意向锁,互斥等待,触发死锁
模式三:唯一索引并发删除后插入
- session A 的 delete from 拿到行写锁
- session B 的 delete from 希望获取行写锁,等待
- session A 的 insert 唯一索引冲突,希望获取 next-key 读锁,锁排队,并且排在 B 的后面,形成死锁
总结下加锁原则
这里还有一个加锁原则比较重要,一个 SQL 到底要加哪些锁。
查阅了网上一些资料,做了一个总结,具体案例就不展开了:
- MySQL 的锁是加在索引上的
- 查询过程中访问到的索引对象才会加锁(没有索引就可能锁全表)
- 加锁的基本单位是 next-key lock(前开后闭)
- 等值查询上 MySQL 的优化:索引上的等值查询,如果是唯一索引,next-key lock 会退化为行锁,如果不是唯一索引,需要访问到第一个不满足条件的值,此时 next-key lock 会退化为间隙锁
- 范围查询:无论是否是唯一索引,范围查询都需要访问到不满足条件的第一个值为止
死锁优化建议
- 避免大事务,尽量拆小
- 避免 经典死锁模式
- 批量操作尽量排序后,按相同顺序插入或者删除
- 尽量使用普通索引而不是唯一索引,即使使用唯一索引,也应该尽量避免重复插入
- 可以考虑使用 RC 隔离级别加 binlog_format=row 模式,而不是 RR 隔离级别