起因
从胖虎午后遛弯说起
一个阳光明媚的午后,胖虎迈着苏格兰碎步,面带微笑的在小胡同里由南向北走。同时大熊也在此时,在小胡同里由北向南走,我们假设这个小胡同只能同时容纳一个人通过。
不一会儿,两个人就来到了小胡同的中间,面面相觑,不知所措。
这时两个人就满足了死锁的条件,「胖虎站在南边想往北走」,「大熊站在北边想往南走」,通过上面的假设可知,胡同只可容纳 「一个人通过」,恰巧两个人此时谁也不想往后退,所以胖虎和大熊就耗死在这了,形成了死锁。
Innodb下的死锁示例
❝
注:本文所用Mysql事物隔离级别为,可重复读(REPEATABLE-READ)**「可用SELECT @@transaction_isolation」**语句查询当前数据库事物隔离级别。
❞
- 首先我们先创建一个表,里面有id和score(分数)两个字段,都是Int类型。
❝
番外:ROW_FORMAT=Compact 是将当前表的行格式(记录结构)设置为 Compact。 Innodb有4种行格式,分别是**「Compact、Redundant、Dynamic和Compressed行格式」**,每种格式有不同的对象结构,这里不做详细赘述。
❞
我们再往表里插入两条数据,id为1的88分,id为2的90分。
此时表中的数据是这样的。
接下来我们开启两个事务会话,并且两个事务分别执行下面几条SQL。
序号 | 事物A | 事物B |
1 | BEGIN | |
2 | UPDATE dead_lock_demo SET score = 99 WHERE id = 1; 「将id为1的分数修改为90分,语句将获取id=1那条数据的排它锁」 | BEGIN |
3 | UPDATE dead_lock_demo SET score = 100 WHERE id = 2;「将id为2的分数修改为100分,语句将获取id=2那条数据的排它锁」 | |
4 | UPDATE dead_lock_demo SET score = 80 WHERE id = 2; 「将id为2的分数修改为80分,id为2的数据已被事务B获取排它锁,所以这条语句将会阻塞等待锁释放」 | |
5 | UPDATE dead_lock_demo SET score = 70 WHERE id = 1; 「将id为1的分数修改为70分,此时id为1的数据排它锁正被事务A持有,并且事务A在等待事务B释放id为2那条数据的排它锁,所以此时就会形成死锁,Innodb将输出错误提示:1213-Deadlock found when trying to get lock;try restarting transaction」 |
上述操作发生在两个不同的事务中,最终在B事务执行第5条sql时innodb抛出了死锁错误提示,并回滚了B事务,并且无需人工干预。「那么innodb是如何发现死锁的呢?」
Innodb对死锁的处理
Innodb死锁的自动检测机制
上述内容可见,Innodb可以主动检测到程序发生了死锁,那么它是怎么做到的呢? 以Mysql5.7为例,采用的是**「对事务等待图(wait-for graph)进行深度优先搜索」**的方式来检测死锁的
❝
注:Mysql 8.0以上版本对等待图进行了优化,构建了一个 「稀疏等待关系图结构」。这里因为小编在生产一直用的都是5.7版本,所以就不对8.0以上版本进行赘述了。
❞
通过我们上述的两个事务A和B可以构建出一个简单的等待关系图。
图中存储了锁和事务等待关系。并且事务与事务之间的连线,代表事务在等待另一个事务释放资源:「当这个图中两个事务之间出现了环,就代表存在死锁,就会被Innodb检测出来」,很明显,上图中事务A和事务B就出现了一个环。
❝
除了上述条件,Innodb还会判断如果一个锁上的等待事务超过200个或锁定线程等待列表上的事务拥有超过1000000个锁,就直接认定是发生了死锁,进行回滚。
❞
当出现死锁时,Innodb会选择回滚消耗资源量最小的那个事务,因为回滚操作会回滚undo_log中的数据,如果事务A操作了非常多的更新和插入,事务B只操作了一条数据更新,那么很显然,回滚事务B对于我们来说更加划算。
接下来,我们一起来看看innodb死锁检测的源码吧,具体判断逻辑小编都写在下面代码的注释上了。(mysql源码目录 storage/innobase/lock/lock0lock.cc文件下DeadlockChecker::search()方法)。「方法是经过简化的,删掉了很多代码,要不实在放不下」
利用超时时间
如果我们要是停用了上面的死锁检测,那么我们还有一个方法可以解决死锁问题,就是设置一个获取锁的超时时间,「Innodb可以通过参数 Innodb_lock_wait_timeout来设置超时时间。当一个事务获取锁超时,Innodb会将超时的事务进行回滚」。 利用超时时间回滚虽然简单方便,但是有以下两点坏处:
- 如果当前事务**「已经进行」**了非常多的更新操作,这个时候做回滚,会对undo_log日志进行大量的操作,但是innodb直接操作的是内存缓存区,如果不是超级多的数据更改其实问题也不大。
- innodb_lock_wait_timeout默认值是50s,所以如果真的发生死锁,需要等待50秒才能解锁,这时如果50s内有大量的请求打到数据库上,会导致数据库异常甚至不可用,这是我们不能容忍的。
❝
番外:Innodb在对数据进行增、删、改操作时,先操作的是缓存区(buffer pool),然后由系统内核控制具体什么时候刷盘(异步持久化),我们的undo_log日志也对应了一块缓存区,在写undo_log日志时,也是先写到缓存区,再进行刷盘。「这里留一个思考:既然Innodb是先写到缓存区,再由系统内核控制异步刷盘,那万一数据还没有刷盘就断电了怎么办?会导致数据丢失吗?Innodb是怎么解决这个问题的? 答案关注公众号 云下风澜,会在后续文章中更新哦!」
❞
扩展
Innodb的锁结构
当一个事务在对某一条记录进行加锁时,会生成一个锁结构。以下是innodb中锁结构体的定义。这里我删掉了很多字段属性,留下了一些最基础的。
我们以事务A和事务B同时对ID=1这条记录进行加锁为例:
- 事务A对Id=1这条记录进行加锁,会生成一个锁结构,与记录进行关联。并且之前没有别的事务对Id=1这条记录加锁,所以is_waiting方法返回false。事务A获取锁成功。
- 这时事务B也想对Id=1这条记录记性加锁,那事务B就会查当前记录有没有已生成的锁结构,发现有锁结构之后,事务B自己也会再生成一个锁结构与这条记录关联,不过锁结构的is_waiting()会返回true,标识当前事务在等待锁。
- 在事务A提交后,就会把自己的锁结构释放掉,然后查询还有没有别的事务在等待锁,发现事务B在等待,就将事务B的锁结构的LOCK_WAIT设置为true,也就是会导致is_waiting()会返回true。事务B获取锁成功,继续执行。
最后
对于所有的程序员也好,架构师也罢,都要对数据库的原理有一定了解。这样才能写好或设计出一个好的系统。