0. 引子
最近遇到了一个死锁问题,但定位死锁原因时终于遇到“面试造火箭”的理论知识(主要是mysql的事务锁),才帮助我定位到问题所在。下面介绍下分析过程
1.问题背景
1.1 问题出现
最近参与一个新的项目,有天收到了死锁异常邮件,找项目老人问了下,说这个死锁报警有了一段时间了,我翻了下报警记录,确实已经存在时日了。然后定位到报死锁的代码,发现在一个大事务中,但是在报警这行代码前,事务中并没有数据库的写操作。并且整个项目中所有的事务,并没有那种一个事务中写两张表,并且写入顺序不一样,造成的死锁。而且报警记录里面,全是这一个位置,然后问题定位进入了迷茫期。
1.2 环境描述
mysql的事务隔离级别是可重复读
为了描述问题,我创建下面这个表及表中的数据(下文中的结构及数据都是虚构的,但逻辑跟我遇到的死锁是一样的)表中后3个字段均没有索引。
+----+-------+------+--------+
| id | name | age | height |
+----+-------+------+--------+
| 1 | Bob | 15 | 0 |
| 2 | Alice | 16 | 0 |
| 3 | Candy | 17 | 0 |
| 4 | Dog | 18 | 0 |
+----+-------+------+--------+
死锁sql
update people set height=0
where id = (
select temp.id from (
select id from people
where height=0
order by id asc
limit 1
) temp
)
2.理论支撑
下面会现有些理论知识补充,并配了对应的例子,请耐心读下去,相信在熟悉这些知识后,死锁的原因便会浮现在你的面前。
2.1 事务中的读操作
上面也提到,报死锁的事务中,这行sql前并没有mysql写操作,只有几个读,那么在可重复读的情况下,读操作是否持有锁呢?
2.1.1 快照读
这个问题比较简单,不会持有锁,在RR级别是快照读,就是MVCC。可重复读的概念大家都清楚,不过还是在这里跑下demo。
事务1
begin;
select * from people where age = 15;
select sleep(10);
select * from people where age = 15;
commit;
事务2
begin;
update people set height=10 where age = 15;
commit;
可以看到两个事务对操作同一条数据,事务1先执行,然后执行事务2。事务1中我加了暂停了10s,就是保证第二次读取的时候,事务2已经执行过了,可以看到1中两次查询结果都是一样的。并且事务2也没有阻塞。
现象符合预期,是没有锁的快照读。事务中的读其实分为快照读和当前读。当前读是需要加共享锁(也就是读锁/S锁)。
2.1.2 当前读
只有事务中update之后的where条件或者select加了for update关键字才会加S锁。下面复现一下
事务1
begin;
update people set height=0 where age=18;
或者
begin;
select * from people where age =18 for update;
事务2
begin;
update people set height=10 where age = 15;
commit;
先执行事务1,可以看到我更新的条件是age=18,而且我这个事务没有提交。执行事务2条件是age=15,可以看到事务2一直阻塞会抛出lock wait timeout。这里提一点,读锁加的范围是查询扫描的行数,虽然事务我只查询age=18的,事务2只更新age=15的。但是我没有加索引,所以会全表扫描,这样会锁全部。然后对age加索引就不会造成锁等待了。(如果要复现这个场景,一定要用explain查看执行计划,确保你的查询的行数)
2.2 事务中的写操作
上面提事务中读操作的锁情况也提到了S锁,当事务中对某些行数据写操作时,会加写锁(排他锁/X锁)。
这里其实就是一个读写锁,事务读与读之间不会阻塞,但是读、写锁的获取均有竞争。
2.3 行锁的类型及其他锁
这一块的知识点与本次死锁无关,这是提一下。
innodb在RR级别下,主键和唯一索引都是行记录模式,RC级别下只有这种行锁。同时还有间隙锁gap,记录锁+间隙锁的组合叫next-key lock。 在RR级别解决幻读引入的gap lock。对范围内的插入和删除操作会有锁的竞争。因为这个与我这次死锁无关,所以不展开说明。
除此外还有意向锁,MDL锁。在innodb中,意向锁就是表锁,分为意向共享锁,意向排他锁,在加对应的行锁时,也需要取得该表的意向锁。 MDL锁是在事务中进行MDL操作时加的锁,这时进行DDL就会阻塞。
这两种锁的作用类似,都是防止事务进行时,进行DDL操作。
3.豁然开朗
3.1 问题定位
现在回到死锁问题上,上面提到通过代码层没有找到两个事务互相持有对方锁的业务代码。便像DBA要了死锁日志,发现死锁的两个事务均是上面提到的同一条sql。有了上面理论的支撑,便找到了问题所在。
update语句里面使用了子查询,这种便是当前读,会持有读锁。这是事务B也进来获取读锁成功。但是事务A要获取写锁时,发现还有别的事务持有这行记录的读锁,事务B同理也是,这样形成了死锁。
3.2 问题复现
事务1
begin;
update people set height=1
where id = (
select temp.id from (
select id,sleep(10) from people
where height=0
order by id asc
limit 1
) temp
);
commit;
事务2
begin;
update people set height=1
where id = (
select temp.id from (
select id,sleep(10) from people
where height=0
order by id asc
limit 1
) temp
);
commit;
在两个终端中分别执行这两个事务,便会发生死锁。因为业务的死锁只有两个语句都执行完读再去执行写操作时才会触发死锁,这里为了稳定复现,使用了sleep函数,帮助实现这个场景。注意这里是现查询扫面,在现实阶段在执行sleep。
PS:最后问题的解决就跟mysql没啥关系了,我们把子查询摘了出来,设计了别的方案保证id的获取是正确的,所以这里不说明了。