起因

从胖虎午后遛弯说起

一个阳光明媚的午后,胖虎迈着苏格兰碎步,面带微笑的在小胡同里由南向北走。同时大熊也在此时,在小胡同里由北向南走,我们假设这个小胡同只能同时容纳一个人通过。

【Mysql面试加分项】——Innodb的死锁检测机制_数据库

不一会儿,两个人就来到了小胡同的中间,面面相觑,不知所措。

这时两个人就满足了死锁的条件,「胖虎站在南边想往北走」,「大熊站在北边想往南走」,通过上面的假设可知,胡同只可容纳 「一个人通过」,恰巧两个人此时谁也不想往后退,所以胖虎和大熊就耗死在这了,形成了死锁。

【Mysql面试加分项】——Innodb的死锁检测机制_数据_02

Innodb下的死锁示例

注:本文所用Mysql事物隔离级别为,可重复读(REPEATABLE-READ)**「可用SELECT @@transaction_isolation」**语句查询当前数据库事物隔离级别。

  1. 首先我们先创建一个表,里面有id和score(分数)两个字段,都是Int类型。
CREATE TABLE dead_lock_demo(
id INT,
score INT
) CHARSET=ascii ROW_FORMAT=Compact;#设置行格式为Compact

番外:ROW_FORMAT=Compact 是将当前表的行格式(记录结构)设置为 Compact。 Innodb有4种行格式,分别是**「Compact、Redundant、Dynamic和Compressed行格式」**,每种格式有不同的对象结构,这里不做详细赘述。

我们再往表里插入两条数据,id为1的88分,id为2的90分。

INSERT INTO dead_lock_demo (id,score) 
VALUES (1,88),(2,90);

此时表中的数据是这样的。

【Mysql面试加分项】——Innodb的死锁检测机制_数据_03

接下来我们开启两个事务会话,并且两个事务分别执行下面几条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」

【Mysql面试加分项】——Innodb的死锁检测机制_MySQL_04

上述操作发生在两个不同的事务中,最终在B事务执行第5条sql时innodb抛出了死锁错误提示,并回滚了B事务,并且无需人工干预。「那么innodb是如何发现死锁的呢?」

Innodb对死锁的处理

Innodb死锁的自动检测机制

上述内容可见,Innodb可以主动检测到程序发生了死锁,那么它是怎么做到的呢? 以Mysql5.7为例,采用的是**「对事务等待图(wait-for graph)进行深度优先搜索」**的方式来检测死锁的

注:Mysql 8.0以上版本对等待图进行了优化,构建了一个 「稀疏等待关系图结构」。这里因为小编在生产一直用的都是5.7版本,所以就不对8.0以上版本进行赘述了。

通过我们上述的两个事务A和B可以构建出一个简单的等待关系图。

【Mysql面试加分项】——Innodb的死锁检测机制_数据_05

图中存储了锁和事务等待关系。并且事务与事务之间的连线,代表事务在等待另一个事务释放资源:「当这个图中两个事务之间出现了环,就代表存在死锁,就会被Innodb检测出来」,很明显,上图中事务A和事务B就出现了一个环。

除了上述条件,Innodb还会判断如果一个锁上的等待事务超过200个或锁定线程等待列表上的事务拥有超过1000000个锁,就直接认定是发生了死锁,进行回滚。

当出现死锁时,Innodb会选择回滚消耗资源量最小的那个事务,因为回滚操作会回滚undo_log中的数据,如果事务A操作了非常多的更新和插入,事务B只操作了一条数据更新,那么很显然,回滚事务B对于我们来说更加划算。

接下来,我们一起来看看innodb死锁检测的源码吧,具体判断逻辑小编都写在下面代码的注释上了。(mysql源码目录 storage/innobase/lock/lock0lock.cc文件下DeadlockChecker::search()方法)。「方法是经过简化的,删掉了很多代码,要不实在放不下」

const trx_t*
DeadlockChecker::search()
{
/* Look at the locks ahead of wait_lock in the lock queue. */
ulint heap_no;
//获取事务中的第一个锁,也就是我们事务A中 id=1的这条数据的锁
const lock_t* lock = get_first_lock(&heap_no);
for (;;) {
if (lock == NULL) {
break;
}
//如果想要获取的锁,在同一个事务内,不会造成死锁,直接走到最后返回return(0);
else if (lock == m_wait_lock) {
/* We can mark this subtree as searched */
ut_ad(lock->trx->lock.deadlock_mark <= m_mark_start);
lock->trx->lock.deadlock_mark = ++s_lock_mark_counter;
ut_ad(s_lock_mark_counter > 0);
/* Backtrack */
lock = NULL;
}
//如果没有发生锁冲突,获取下一个锁
else if (!lock_has_to_wait(m_wait_lock, lock)) {
/* No conflict, next lock */
lock = get_next_lock(lock, heap_no);
}
//检测到死锁发生(事务形成一个循环),调用select_victim()选择要回滚的事务。
else if (lock->trx == m_start) {
/* Found a cycle. */
notify(lock);
return(select_victim());
}
//1.判断等待锁的事务数超过200。 2.如果锁定线程等待列表上的事务拥有的超过1000000个锁。 判断以上2个条件是否成立,一个成立就直接认为是死锁,进行选择性回滚。
else if (is_too_deep()) {
/* Search too deep to continue. */
m_too_deep = true;
return(m_start);
//如果锁对应的事务处于等待状态,
} else if (lock->trx->lock.que_state == TRX_QUE_LOCK_WAIT) {
//这块逻辑稍微复杂点,可以先不看
}
else {
lock = get_next_lock(lock, heap_no);
}
}
ut_a(lock == NULL && m_n_elems == 0);
/* No deadlock found. */
return(0);
}

利用超时时间

如果我们要是停用了上面的死锁检测,那么我们还有一个方法可以解决死锁问题,就是设置一个获取锁的超时时间,「Innodb可以通过参数 Innodb_lock_wait_timeout来设置超时时间。当一个事务获取锁超时,Innodb会将超时的事务进行回滚」。 利用超时时间回滚虽然简单方便,但是有以下两点坏处:

  1. 如果当前事务**「已经进行」**了非常多的更新操作,这个时候做回滚,会对undo_log日志进行大量的操作,但是innodb直接操作的是内存缓存区,如果不是超级多的数据更改其实问题也不大。
  2. innodb_lock_wait_timeout默认值是50s,所以如果真的发生死锁,需要等待50秒才能解锁,这时如果50s内有大量的请求打到数据库上,会导致数据库异常甚至不可用,这是我们不能容忍的。

番外:Innodb在对数据进行增、删、改操作时,先操作的是缓存区(buffer pool),然后由系统内核控制具体什么时候刷盘(异步持久化),我们的undo_log日志也对应了一块缓存区,在写undo_log日志时,也是先写到缓存区,再进行刷盘。「这里留一个思考:既然Innodb是先写到缓存区,再由系统内核控制异步刷盘,那万一数据还没有刷盘就断电了怎么办?会导致数据丢失吗?Innodb是怎么解决这个问题的? 答案关注公众号 云下风澜,会在后续文章中更新哦!」

扩展

Innodb的锁结构

当一个事务在对某一条记录进行加锁时,会生成一个锁结构。以下是innodb中锁结构体的定义。这里我删掉了很多字段属性,留下了一些最基础的。

struct lock_t {
//当前这个锁属于哪个事务
trx_t* trx; /*!< transaction owning the
lock */

//通过一个宏,将这个事务拥有的锁用一个链表串起来
UT_LIST_NODE_T(lock_t)
trx_locks; /*!< list of the locks of the
transaction */

//一个表锁和行锁的联合体结构,我们一般用的都是行锁
union {
lock_table_t tab_lock;/*!< table lock */
lock_rec_t rec_lock;/*!< record lock */
} un_member; /*!< lock details */

//返回当前事务是否在等待
bool is_waiting() const
{
return(type_mode & LOCK_WAIT);
}
};

【Mysql面试加分项】——Innodb的死锁检测机制_回滚_06

我们以事务A和事务B同时对ID=1这条记录进行加锁为例:

  1. 事务A对Id=1这条记录进行加锁,会生成一个锁结构,与记录进行关联。并且之前没有别的事务对Id=1这条记录加锁,所以is_waiting方法返回false。事务A获取锁成功。
  2. 这时事务B也想对Id=1这条记录记性加锁,那事务B就会查当前记录有没有已生成的锁结构,发现有锁结构之后,事务B自己也会再生成一个锁结构与这条记录关联,不过锁结构的is_waiting()会返回true,标识当前事务在等待锁。
  3. 在事务A提交后,就会把自己的锁结构释放掉,然后查询还有没有别的事务在等待锁,发现事务B在等待,就将事务B的锁结构的LOCK_WAIT设置为true,也就是会导致is_waiting()会返回true。事务B获取锁成功,继续执行。

最后

对于所有的程序员也好,架构师也罢,都要对数据库的原理有一定了解。这样才能写好或设计出一个好的系统。