可能的死锁场景
文章总共分为五个部分:
- InnoDB的锁机制浅析(一)—基本概念/兼容矩阵
- InnoDB的锁机制浅析(二)—探索InnoDB中的锁(Record锁/Gap锁/Next-key锁/插入意向锁)
- InnoDB的锁机制浅析(三)—幻读
- InnoDB的锁机制浅析(四)—不同SQL的加锁状况
- InnoDB的锁机制浅析(五)—死锁场景(Insert死锁)
大而全版(五合一):InnoDB的锁机制浅析(All in One)
前言
这一章节只列举两种死锁场景,其他的死锁问题大多也万变不离其宗。
示例的基础是一个只有两列的数据库表。
mysql> CREATE TABLE test (
id int(11) NOT NULL,
code int(11) NOT NULL,
PRIMARY KEY(id),
KEY (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
mysql> INSERT INTO test(id,code) values(1,1),(10,10);
锁的兼容矩阵如下:
--- | 排它锁(X) | 意向排它锁(IX) | 共享锁(S) | 意向共享锁(IS) |
---|---|---|---|---|
排它锁(X) | N | N | N | N |
意向排它锁(IX) | N | OK | N | OK |
共享锁(S) | N | N | OK | OK |
意向共享锁(IS) | N | OK | OK | OK |
1 Duplicate key error引发的死锁
并发条件下,唯一键索引冲突可能会导致死锁,这种死锁一般分为两种,一种是rollback
引发,另一种是commit
引发。
1.1 rollback
引发的Duplicate key死锁
我命名为insert-insert-insert-rollback死锁
事务一 | 事务二 | 事务三 |
---|---|---|
mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> insert into test values (2,2); Query OK, 1 row affected (0.01 sec) |
||
mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> insert into test values (2,2); 执行之后被阻塞,等待事务一 |
||
mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> insert into test values (2,2); 执行之后被阻塞,等待事务一 |
||
mysql>rollback; Query OK, 0 rows affected (0.00 sec) |
||
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction | ||
Query OK, 1 row affected (16.13 sec) |
当事务一执行回滚时,事务二和事务三发生了死锁。InnoDB的死锁检测一旦检测到死锁发生,会自动失败其中一个事务,因此看到的结果是一个失败另一个成功。
为什么会死锁?
死锁产生的原因是事务一插入记录时,对(2,2)记录加X锁,此时事务二和事务三插入数据时检测到了重复键错误,此时事务二和事务三要在这条索引记录上设置S锁,由于X锁的存在,S锁的获取被阻塞。
事务一回滚,由于S锁和S锁是可以兼容的,因此事务二和事务三都获得了这条记录的S锁,此时其中一个事务希望插入,则该事务期望在这条记录上加上X锁,然而另一个事务持有S锁,S锁和X锁互相是不兼容的,两个事务就开始互相等待对方的锁释放,造成了死锁。
事务二和事务三为什么会加S锁,而不是直接等待X锁
事务一的insert语句加的是隐式锁(隐式的Record锁、X锁),但是其他事务插入同一行记录时,出现了唯一键冲突,事务一的隐式锁升级为显示锁。
事务二和事务三在插入之前判断到了唯一键冲突,是因为插入前的重复索引检查,这次检查必须进行一次当前读,于是非唯一索引就会被加上S模式的next-key锁,唯一索引就被加上了S模式的Record锁。
因为插入和更新之前都要进行重复索引检查而执行当前读操作,所以RR隔离级别下,同一个事务内不连续的查询,可能也会出现幻读的效果(但个人并不认为RR级别下也会出现幻读,幻读的定义应该是连续的读取)。而连续的查询由于都是读取快照,中间没有当前读的操作,所以不会出现幻读。
1.2 commit
引发的Duplicate key死锁
delete-insert-insert-commit死锁
事务一 | 事务二 | 事务三 |
---|---|---|
mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> delete from test where id=2; Query OK, 1 row affected (0.01 sec) |
||
mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> insert into test values (2,2); 执行之后被阻塞,等待事务一 |
||
mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> insert into test values (2,2); 执行之后被阻塞,等待事务一 |
||
mysql>commit; Query OK, 0 rows affected (0.00 sec) |
||
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction | ||
Query OK, 1 row affected (2.37 sec) |
这种情况下产生的死锁和insert-insert-insert-rollback
死锁产生的原理一致。
6.2 数据插入的过程
经过以上分析,一条数据在插入时经过以下几个过程:
假设数据表test.test
中存在(1,1)、(5,5)和(10,10)三条记录。
- 事务开启,尝试获取插入意向锁。例如,事务一执行了
select * from test where id>8 for update
,事务二要插入(9,9),此时先要获取插入意向锁,由于事务一已经在对应的记录和间隙上加了X锁,因此事务二被阻塞,并且阻塞的原因是获取插入意向锁时被事务一的X锁阻塞。 - 获取意向锁之后,插入之前进行重复索引检查。重复索引检查为当前读,需要添加S锁。
- 如果是已经存在唯一索引,且索引未加锁。直接抛出
Duplicate key
的错误。如果存在唯一索引,且索引加锁,等待锁释放。 - 重复检查通过之后,加入X锁,插入记录
3 GAP与Insert Intention冲突引发死锁
update-insert死锁
仍然是表test,当前表中的记录如下:
mysql> select * from test;
+----+------+
| id | code |
+----+------+
| 1 | 1 |
| 5 | 5 |
| 10 | 10 |
+----+------+
3 rows in set (0.01 sec)
事务一 | 事务二 |
---|---|
begin; | begin; |
select * from test where id=5 for update; | select * from test where id=10 for update; |
insert into test values(7,7); | |
insert into test values(7,7); | |
Query OK, 1 row affected (5.03 sec) | |
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction |
使用show engine innodb status
查看死锁状态。先后出现lock_mode X locks gap before rec insert intention waiting
和lock_mode X locks gap before rec
字眼,是gap锁和插入意向锁的冲突导致的死锁。
回顾select...for update的加锁范围
首先回顾一下两个事务中的select ... for update
做了哪些加锁操作。
code=5
时,首先会获取code=5
的索引记录锁(Record锁),根据之前gap锁的介绍,会在前一个索引和当前索引之间的间隙加锁,于是区间(1,5)之间被加上了X模式的gap锁。除此之外RR模式下,还会加next-key锁,于是区间(5,10]被加了next-key锁;
- 因此,
code=5
的加锁范围是,区间(1,5)的gap锁,{5}索引Record锁,(5,10]的next-key锁。即区间(1,10)上都被加上了X模式的锁。 - 同理,
code=10
的加锁范围是,区间(5,10)的gap锁,{10}索引Record锁,(10,+∞)的next-key锁。
由gap锁的特性,兼容矩阵中冲突的锁也可以被不同的事务同时加在一个间隙上。上述两个select ... for update
语句出现了间隙锁的交集,code=5
的next-key锁和code=10
的gap锁有重叠的区域——(5,10)。
死锁的成因
当事务一执行插入语句时,会先加X模式的插入意向锁
,即兼容矩阵中的IX锁。
但是由于插入意向锁要锁定的位置存在X模式的gap锁
。兼容矩阵中IX和X锁是不兼容的,因此事务一的IX锁会等待事务二的gap锁释放。
事务二也执行插入语句,与事务一同样,事务二的插入意向锁IX锁会等待事务一的gap锁释放。
两个事务互相等待对方先释放锁,因此出现死锁。
2 总结
除了以上给出的几种死锁模式,还有很多其他死锁的场景。
无论是哪种场景,万变不离其宗,都是由于某个区间上或者某一个记录上可以同时持有锁,例如不同事务在同一个间隙gap上的锁不冲突;不同事务中,S锁可以阻塞X锁的获取,但是不会阻塞另一个事务获取该S锁。这样才会出现两个事务同时持有锁,并互相等待,最终导致死锁。
其中需要注意的点是,增、删、改的操作都会进行一次当前读操作,以此获取最新版本的数据,并检测是否有重复的索引。
这个过程除了会导致RR隔离级别下出现死锁之外还会导致其他两个问题:
- 第一个是可重复读可能会因为这次的当前读操作而中断,(同样,幻读可能也会因此产生);
- 第二个是其他事务的更新可能会丢失(解决方式:悲观锁、乐观锁)。