mysql 锁机制 详解二 (死锁)

1    死锁问题背景    1mysql

1.1    一个难以想象的死锁    1sql

1.1.1    初步分析    3数据库

1.2    如何阅读死锁日志    3并发

2    死锁缘由深刻剖析    4函数

2.1    Delete操做的加锁逻辑    4测试

2.2    死锁预防策略    5spa

2.3    剖析死锁的成因    6调试

3    总结    7日志

 

 

  1. 死锁问题背景

 

作MySQL代码的深刻分析也有些年头了,再加上本身10年左右的数据库内核研发经验,自认为对于MySQL/InnoDB的加锁实现了如指掌,正因如此,前段时间,还专门写了一篇洋洋洒洒的文章,专门分析MySQL的加锁实现细节:《MySQL加锁处理分析》。对象

 

可是,昨天”润洁”同窗在《MySQL加锁处理分析》这篇博文下咨询的一个MySQL的死锁场景,仍是完全把我给难住了。此死锁,彻底违背了本人原有的锁知识体系,让我百思不得其解。本着机器不会骗人,既然报出死锁,那么就必定存在死锁的原则,我又从新深刻分析了InnoDB对应的源码实现,进行屡次实验,配合恰到好处的灵光一现,还真让我分析出了这个死锁产生的缘由。这篇博文的余下部分的内容安排,首先是给出”润洁”同窗描述的死锁场景,而后再给出个人剖析。对我的来讲,这是一篇十分有必要的总结,对此博文的读者来讲,但愿之后碰到相似的死锁问题时,可以明确死锁的缘由所在。

 

 

 

  1. 一个难以想象的死锁

 

“润洁”同窗,给出的死锁场景以下:

 

表结构:

 

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列。

 

事务隔离级别:

 

RR (Repeatable Read)

 

每一个事务只有一条SQL:

 

delete from dltask where a=? and b=? and c=?;

 

SQL的执行计划:

 

执行计划

 

死锁日志:

 

死锁日志

 

  1. 初步分析

 

并发事务,每一个事务只有一条SQL语句:给定惟一的二级索引键值,删除一条记录。每一个事务,最多只会删除一条记录,为何会产生死锁?这绝对是不可能的。可是,事实上,却真的是发生了死锁。产生死锁的两个事务,删除的是同一条记录,这应该是死锁发生的一个潜在缘由,可是,即便是删除同一条记录,从原理上来讲,也不该该产生死锁。所以,通过初步分析,这个死锁是不可能产生的。这个结论,远远不够!

 

  1. 如何阅读死锁日志

 

在详细给出此死锁产生的缘由以前,让咱们先来看看,如何阅读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的权重更小,被选为死锁的牺牲者,回滚。

 

根据对于死锁日志的分析,确认死锁确实存在。并且,产生死锁的两个事务,确实都是在运行一样的基于惟一索引的等值删除操做。既然死锁确实存在,那么接下来,就是抓出这个死锁产生缘由。

 

  1. 死锁缘由深刻剖析

 

  1. Delete操做的加锁逻辑

 

在《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 部分的最后,我还提出了一个问题:若是组合5、组合六下,针对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查询,三种状况,对应三种加锁策略,总结以下:

 

  • 找到知足条件的记录,而且记录有效,则对记录加X锁,No Gap锁(lock_mode X locks rec but not gap);

     

  • 找到知足条件的记录,可是记录无效(标识为删除的记录),则对记录加next key锁(同时锁住记录自己,以及记录以前的Gap:lock_mode X);

  • 未找到知足条件的记录,则对第一个不知足条件的记录加Gap锁,保证没有知足条件的记录插入(locks gap before rec);

 

此处,咱们看到了next key锁,是否很眼熟?对了,前面死锁中事务1,事务2处于等待状态的锁,均为next key锁。明白了这三个加锁策略,其实构造必定的并发场景,死锁的缘由已经呼之欲出。可是,还有一个前提策略须要介绍,那就是InnoDB内部采用的死锁预防策略。

 

  1. 死锁预防策略

 

InnoDB引擎内部(或者说是全部的数据库内部),有多种锁类型:事务锁(行锁、表锁),Mutex(保护内部的共享变量操做)、RWLock(又称之为Latch,保护内部的页面读取与修改)。

 

InnoDB每一个页面为16K,读取一个页面时,须要对页面加S锁,更新一个页面时,须要对页面加上X锁。任何状况下,操做一个页面,都会对页面加锁,页面锁加上以后,页面内存储的索引记录才不会被并发修改。

 

所以,为了修改一条记录,InnoDB内部如何处理:

 

  1. 根据给定的查询条件,找到对应的记录所在页面;

     

  2. 对页面加上X锁(RWLock),而后在页面内寻找知足条件的记录;

     

  3. 在持有页面锁的状况下,对知足条件的记录加事务锁(行锁:根据记录是否知足查询条件,记录是否已经被删除,分别对应于上面提到的3种加锁策略之一);

     

  4. 死锁预防策略:相对于事务锁,页面锁是一个短时间持有的锁,而事务锁(行锁、表锁)是长期持有的锁。所以,为了防止页面锁与事务锁之间产生死锁。InnoDB作了死锁预防的策略:持有事务锁(行锁、表锁),能够等待获取页面锁;但反之,持有页面锁,不能等待持有事务锁。

     

  5. 根据死锁预防策略,在持有页面锁,加行锁的时候,若是行锁须要等待。则释放页面锁,而后等待行锁。此时,行锁获取没有任何锁保护,所以加上行锁以后,记录可能已经被并发修改。所以,此时要从新加回页面锁,从新判断记录的状态,从新在页面锁的保护下,对记录加锁。若是此时记录未被并发修改,那么第二次加锁可以很快完成,由于已经持有了相同模式的锁。可是,若是记录已经被并发修改,那么,就有可能致使本文前面提到的死锁问题。

  1. 以上的InnoDB死锁预防处理逻辑,对应的函数,是row0sel.c::row_search_for_mysql()。感兴趣的朋友,能够跟踪调试下这个函数的处理流程,很复杂,可是集中了InnoDB的精髓。

 

  1. 剖析死锁的成因

 

作了这么多铺垫,有了Delete操做的3种加锁逻辑、InnoDB的死锁预防策略等准备知识以后,再回过头来分析本文最初提到的死锁问题,就会手到拈来,事半而功倍。

 

首先,假设dltask中只有一条记录:(1, ‘a’, ‘b’, ‘c’, ‘data’)。三个并发事务,同时执行如下的这条SQL:

 

delete from dltask where a=’a’ and b=’b’ and c=’c';

 

而且产生了如下的并发执行逻辑,就会产生死锁:

 

deadlock

 

上面分析的这个并发流程,完整展示了死锁日志中的死锁产生的缘由。其实,根据事务1步骤6,与事务0步骤3/4之间的顺序不一样,死锁日志中还有可能产生另一种状况,那就是事务1等待的锁模式为记录上的X锁 + No Gap锁(lock_mode X locks rec but not gap waiting)。这第二种状况,也是”润洁”同窗给出的死锁用例中,使用MySQL 5.6.15版本测试出来的死锁产生的缘由。

 

  1. 总结

 

行文至此,MySQL基于惟一索引的单条记录的删除操做并发,也会产生死锁的缘由,已经分析完毕。其实,分析此死锁的难点,在于理解MySQL/InnoDB的行锁模式,针对不一样状况下的加锁模式的区别,以及InnoDB处理页面锁与事务锁的死锁预防策略。明白了这些,死锁的分析就会显得清晰明了。

 

最后,总结下此类死锁,产生的几个前提:

 

  • Delete操做,针对的是惟一索引上的等值查询的删除;(范围下的删除,也会产生死锁,可是死锁的场景,跟本文分析的场景,有所不一样)

     

  • 至少有3个(或以上)的并发删除操做;

  • 并发删除操做,有可能删除到同一条记录,而且保证删除的记录必定存在;

  • 事务的隔离级别设置为Repeatable Read,同时未设置innodb_locks_unsafe_for_binlog参数(此参数默认为FALSE);(Read Committed隔离级别,因为不会加Gap锁,不会有next key,所以也不会产生死锁)

  • 使用的是InnoDB存储引擎;(废话!MyISAM引擎根本就没有行锁)

相关文章
相关标签/搜索