1
死锁问题背景 1
1.1 一个不可思议的死锁 1 1.1.1
初步分析 3
1.2 如何阅读死锁日志 3 2
死锁原因深入剖析 4
2.1 Delete 操作的加锁逻辑 4 2.2 死锁预防策略 5 2.3 剖析死锁的成因 6 3
总结 7
做 MySQL 代码的深入分析也有些年头了,再加上自己 10 年左右的数据库内核研发经验,自认为对于 MySQL/InnoDB 的加锁实现了如指掌,正因如此,前段时间,还专门写了一篇洋洋洒洒的文章,专门分析 MySQL 的加锁实现细节:《 MySQL 加锁处理分析 》。
但是,昨天 "润洁" 同学在《 MySQL 加锁处理分析 》这篇博文下咨询的一个 MySQL 的死锁场景,还是彻底把我给难住了。此死锁,完全违背了本人原有的锁知识体系,让我百思不得其解。本着机器不会骗人,既然报出死锁,那么就一定存在死锁的原则,我又重新深入分析了 InnoDB 对应的源码实现,进行多次实验,配合恰到好处的灵光一现,还真让我分析出了这个死锁产生的原因。这篇博文的余下部分的内容安排,首先是给出 "润洁" 同学描述的死锁场景,然后再给出我的剖析。对个人来说,这是一篇十分有必要的总结,对此博文的读者来说,希望以后碰到类似的死锁问题时,能够明确死锁的原因所在。
"润洁" 同学,给出的死锁场景如下:
表结构:CREATE TABLE dltask (
id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'auto id',
a varchar(30) NOT NULL COMMENT 'uniq.a',
b varchar(30) NOT NULL COMMENT 'uniq.b',
c varchar(30) NOT NULL COMMENT 'uniq.c',
x varchar(30) NOT NULL COMMENT 'data',
PRIMARY KEY (id),
UNIQUE KEY uniq_a_b_c (a, b, c)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='deadlock test';
a,b,c 三列,组合成一个唯一索引,主键索引为 id 列。
事务隔离级别:delete from dltask where a=? and b=? and c=?;
SQL 的执行计划:并发事务,每个事务只有一条 SQL 语句:给定唯一的二级索引键值,删除一条记录。每个事务,最多只会删除一条记录,为什么会产生死锁?这绝对是不可能的。但是,事实上,却真的是发生了死锁。产生死锁的两个事务,删除的是同一条记录,这应该是死锁发生的一个潜在原因,但是,即使是删除同一条记录,从原理上来说,也不应该产生死锁。因此,经过初步分析,这个死锁是不可能产生的。这个结论,远远不够!
在详细给出此死锁产生的原因之前,让我们先来看看,如何阅读 MySQL 给出的死锁日志。
以上打印出来的死锁日志,由 InnoDB 引擎中的 lock0lock.c::lock_deadlock_recursive() 函数产生。死锁中的事务信息,通过调用函数 lock_deadlock_trx_print() 处理;而每个事务持有、等待的锁信息,由 lock_deadlock_lock_print() 函数产生。
例如,以上的死锁,有两个事务。事务 1,当前正在操作一张表 (mysql tables in use 1),持有两把锁 (2 lock structs,一个表级意向锁,一个行锁 (1 row lock)),这个事务,当前正在处理的语句是一条 delete 语句。同时,这唯一的一个行锁,处于等待状态 (WAITING FOR THIS LOCK TO BE GRANTED)。
事务 1 等待中的行锁,加锁的对象是唯一索引 uniq_a_b_c 上页面号为 12713 页面上的一行 (注:具体是哪一行,无法看到。但是能够看到的是,这个行锁,一共有 96 个 bits 可以用来锁 96 个行记录,n bits 96:lock_rec_print() 方法)。同时,等待的行锁模式为 next key 锁(lock_mode X)。(注:关于 InnoDB 的锁模式,可参考我早期的一篇 PPT:《 InnoDB 事务 / 锁 / 多版本 实现分析 》。简单来说,next key 锁有两层含义,一是对当前记录加 X 锁,防止记录被并发修改,同时锁住记录之前的 GAP,防止有新的记录插入到此记录之前。)
同理,可以分析事务 2。事务 2 上有两个行锁,两个行锁对应的也都是唯一索引 uniq_a_b_c 上页面号为 12713 页面上的某一条记录。一把行锁处于持有状态,锁模式为 X lock with no gap(注:记录锁,只锁记录,但是不锁记录前的 GAP,no gap lock)。一把行锁处于等待状态,锁模式为 next key 锁 (注:与事务 1 等待的锁模式一致。同时,需要注意的一点是,事务 2 的两个锁模式,并不是一致的,不完全相容。持有的锁模式为 X lock with no gap,等待的锁模式为 next key lock X。因此,并不能因为持有了 X lock with no gap,就可以说 next key lock X 就一定能够加上。)。
分析这个死锁日志,就能发现一个死锁。事务 1 的 next key lock X 正在等待事务 2 持有的 X lock with no gap(行锁 X 冲突),同时,事务 2 的 next key lock X,却又在等待事务 1 正在等待中的 next key 锁 (注:这里,事务 2 等待事务 1 的原因,在于公平竞争,杜绝事务 1 发生饥饿现象。),形成循环等待,死锁产生。
死锁产生后,根据两个事务的权重,事务 1 的权重更小,被选为死锁的牺牲者,回滚。
根据对于死锁日志的分析,确认死锁确实存在。而且,产生死锁的两个事务,确实都是在运行同样的基于唯一索引的等值删除操作。既然死锁确实存在,那么接下来,就是抓出这个死锁产生原因。
在《 MySQL 加锁处理分析 》一文中,我详细分析了各种 SQL 语句对应的加锁逻辑。例如:Delete 语句,内部就包含一个当前读 (加锁读),然后通过当前读返回的记录,调用 Delete 操作进行删除。在此文的 组合六:id 唯一索引 + RR 中,可以看到,RR 隔离级别下,针对于满足条件的查询记录,会对记录加上排它锁 (X 锁),但是并不会锁住记录之前的 GAP(no gap lock)。对应到此文上面的死锁例子,事务 2 所持有的锁,是一把记录上的排它锁,但是没有锁住记录前的 GAP(lock_mode X locks rec but not gap),与我之前的加锁分析一致。
其实,在《MySQL 加锁处理分析》一文中的 组合七:id 非唯一索引 + RR 部分的最后,我还提出了一个问题:如果组合五、组合六下,针对 SQL:select * from t1 where id = 10 for update; 第一次查询,没有找到满足查询条件的记录,那么 GAP 锁是否还能够省略?针对此问题,参与的朋友在做过试验之后,给出的正确答案是:此时 GAP 锁不能省略,会在第一个不满足查询条件的记录上加 GAP 锁,防止新的满足条件的记录插入。
其实,以上两个加锁策略,都是正确的。以上两个策略,分别对应的是:1)唯一索引上满足查询条件的记录存在并且有效;2)唯一索引上满足查询条件的记录不存在。但是,除了这两个之外,其实还有第三种:3)唯一索引上满足查询条件的记录存在但是无效。众所周知,InnoDB 上删除一条记录,并不是真正意义上的物理删除,而是将记录标识为删除状态。(注:这些标识为删除状态的记录,后续会由后台的 Purge 操作进行回收,物理删除。但是,删除状态的记录会在索引中存放一段时间。) 在 RR 隔离级别下,唯一索引上满足查询条件,但是却是删除记录,如何加锁?InnoDB 在此处的处理策略与前两种策略均不相同,或者说是前两种策略的组合:对于满足条件的删除记录,InnoDB 会在记录上加 next key lock X(对记录本身加 X 锁,同时锁住记录前的 GAP,防止新的满足条件的记录插入。) Unique 查询,三种情况,对应三种加锁策略,总结如下:
此处,我们看到了 next key 锁,是否很眼熟?对了,前面死锁中事务 1,事务 2 处于等待状态的锁,均为 next key 锁。明白了这三个加锁策略,其实构造一定的并发场景,死锁的原因已经呼之欲出。但是,还有一个前提策略需要介绍,那就是 InnoDB 内部采用的死锁预防策略。
InnoDB 引擎内部 (或者说是所有的数据库内部),有多种锁类型:事务锁 (行锁、表锁),Mutex(保护内部的共享变量操作)、RWLock(又称之为 Latch,保护内部的页面读取与修改)。
InnoDB 每个页面为 16K,读取一个页面时,需要对页面加 S 锁,更新一个页面时,需要对页面加上 X 锁。任何情况下,操作一个页面,都会对页面加锁,页面锁加上之后,页面内存储的索引记录才不会被并发修改。
因此,为了修改一条记录,InnoDB 内部如何处理:
做了这么多铺垫,有了 Delete 操作的 3 种加锁逻辑、InnoDB 的死锁预防策略等准备知识之后,再回过头来分析本文最初提到的死锁问题,就会手到拈来,事半而功倍。
首先,假设 dltask 中只有一条记录:(1,'a','b','c','data')。三个并发事务,同时执行以下的这条 SQL:
delete from dltask where a='a' and b='b' and c='c';
并且产生了以下的并发执行逻辑,就会产生死锁:
上面分析的这个并发流程,完整展现了死锁日志中的死锁产生的原因。其实,根据事务 1 步骤 6,与事务 0 步骤 3/4 之间的顺序不同,死锁日志中还有可能产生另外一种情况,那就是事务 1 等待的锁模式为记录上的 X 锁 + No Gap 锁 (lock_mode X locks rec but not gap waiting)。这第二种情况,也是 "润洁" 同学给出的死锁用例中,使用 MySQL 5.6.15 版本测试出来的死锁产生的原因。
行文至此,MySQL 基于唯一索引的单条记录的删除操作并发,也会产生死锁的原因,已经分析完毕。其实,分析此死锁的难点,在于理解 MySQL/InnoDB 的行锁模式,针对不同情况下的加锁模式的区别,以及 InnoDB 处理页面锁与事务锁的死锁预防策略。明白了这些,死锁的分析就会显得清晰明了。
最后,总结下此类死锁,产生的几个前提:
来源: http://hedengcheng.com/?p=844#_Toc378337494