蹲厕所的熊

benjaminwhx

由一次线上问题带来的MySQL死锁问题分析

2018-02-28 作者: 吴海旭


  1. 1、死锁成因
    1. 1.1、wait-for-graph算法
    2. 1.2、InnoDB隔离级别和锁
    3. 1.3、死锁成因
      1. 1.3.1、不同表相同行冲突
      2. 1.3.2、相同表记录行锁冲突
      3. 1.3.3、不同索引锁冲突
      4. 1.3.4、gap锁冲突
  2. 2、线上死锁异常分析
  3. 3、死锁异常究竟是哪个事务抛出?
  4. 4、如何定位死锁并避免
    1. 定位死锁成因
  5. 5、参考

某天线上突然报出如下异常:Deadlock found when trying to get lock

Oh, My God! 是死锁问题。尽管报错不多,对性能目前看来也无太大影响,但还是需要解决,保不齐哪天成为性能瓶颈。

为了更系统的分析问题,本文将从以下几个方面来展开讨论。

  1. 死锁形成的原因、隔离级别和锁。
  2. 分析线上死锁异常形成的原因。
  3. 死锁异常究竟是哪个事务抛出?我会通过一个实际的例子来说明。
  4. 如何快速定位死锁以及在以后的工作中尽可能的避免死锁。

1、死锁成因

下图左边的两辆车造成死锁了吗?没有!右边四辆车造成死锁了吗?是的!

我们mysql用的存储引擎是innodb,从日志来看,innodb主动探知到死锁,并回滚了某一苦苦等待的事务。问题来了,innodb是怎么探知死锁的?

直观方法是在两个事务相互等待时,当一个等待时间超过设置的某一阀值时,对其中一个事务进行回滚,另一个事务就能继续执行。这种方法简单有效,在innodb中,参数innodb_lock_wait_timeout用来设置超时时间。

仅用上述方法来检测死锁太过被动,innodb还提供了wait-for graph算法来主动进行死锁检测,每当加锁请求无法立即满足需要并进入等待时,wait-for graph算法都会被触发。

1.1、wait-for-graph算法

我们怎么知道上图中四辆车是死锁的?他们相互等待对方的资源,而且形成环路!我们将每辆车看为一个节点,当节点1需要等待节点2的资源时,就生成一条有向边指向节点2,最后形成一个有向图。我们只要检测这个有向图是否出现环路即可,出现环路就是死锁!这就是wait-for graph算法。

innodb将各个事务看为一个个节点,资源就是各个事务占用的锁,当事务1需要等待事务2的锁时,就生成一条有向边从1指向2,最后行成一个有向图。

1.2、InnoDB隔离级别和锁

死锁检测是死锁发生时innodb给我们的救命稻草,我们需要它,但我们更需要的是避免死锁发生的能力,如何尽可能避免?这需要了解innodb中的锁。建议大家先仔细阅读以下这篇文章再来看下面的内容:

总结一下里面重要的几点:

  1. InnoDB的RC存在幻读,并且不存在gap锁。在有索引的时候,会对索引命中行加上X锁,在无索引时,会对表的所有行加上X锁(RC里锁全部行和锁表不同,因为锁全部行还可以insert,但是锁表不可以)
  2. InnoDB的RR不存在幻读(注意:在一般概念里,会强调RR隔离级别不允许脏读和不可重复读,但是可以幻读,但是这里却特别强调RR不存在幻读现象,是因为MySQL InnoDB引擎的实现和标准有所不同),在非唯一索引以及无索引时,会先对命中行加上gap锁来防止幻读,然后再对命中行加上X锁。在无索引时,会先对表的全部行加上gap锁来防止缓存,然后再对全部行加上X锁(RR里锁全部行和锁表功能上类似,但不是锁表)

1.3、死锁成因

了解了innodb锁的基本原理后,下面分析下死锁的成因。如前面所说,死锁一般是事务相互等待对方资源,最后形成环路造成的。下面简单讲下造成相互等待最后形成环路的例子。

1.3.1、不同表相同行冲突

这种情况很好理解,事务A和事务B操作两张表,但出现循环等待锁情况。

1.3.2、相同表记录行锁冲突

种情况比较常见,之前遇到两个job在执行数据批量更新时,jobA处理的的id列表为[1,2,3,4],而jobB处理的id列表为[8,9,10,4,2],这样就造成了死锁。

1.3.3、不同索引锁冲突

这种情况比较隐晦,事务A在执行时,除了在二级索引加锁外,还会在聚簇索引上加锁,在聚簇索引上加锁的顺序是[1,4,2,3,5],而事务B执行时,只在聚簇索引上加锁,加锁顺序是[1,2,3,4,5],这样就造成了死锁的可能性。

1.3.4、gap锁冲突

innodb在RR级别下,如下的情况也会产生死锁,比较隐晦。

2、线上死锁异常分析

线上异常出现的代码如下:

transactionTemplate.execute(new TransactionCallbackWithoutResult() {
    @Override
    protected void doInTransactionWithoutResult(TransactionStatus status) {
        try {
            // ...

            //更新当前逾期单
            if(taskContext.getUpdateOverdue() != null){
                overdueMapper.updateByPrimaryKeySelective(taskContext.getUpdateOverdue());
            }
            //更新历史逾期单
            if(taskContext.getUpdateHisOverdue() != null){
                overdueMapper.updateHistoryOverdue(taskContext.getUpdateHisOverdue());
            }
        } catch (Exception e) {
            // ignore
        }
    }
});

上面的代码是分布式的,有多台机器可能同时执行,在一个事务里面,首先调用updateByPrimaryKeySelective更新当前的逾期单,接着调用updateHistoryOverdue更新历史的逾期单。下面是两个sql(这里我简写了一下)

这里的id是主键,overdue_bill_no字段没有加索引。MySQL的隔离级别为RC。

update overdue set modified_date = now() where 1 = 1 AND id = xxx;
update overdue set modified_date = now() where 1 = 1 AND overdue_bill_no = 'xxx';

这个情况正好和上面说的 3.2相同表记录行锁冲突 类似,只不过第二个update语句会造成表里的所有行加上X锁。

3、死锁异常究竟是哪个事务抛出?

我们有的时候发现,并不是触发死锁的事务抛出Deadlock异常,那么究竟这其中有什么奥秘呢?我们通过一个例子来说明。

表结构如下:

+——-+————-+——+—–+———+——-+
| Field | Type | Null | Key | Default | Extra |
+——-+————-+——+—–+———+——-+
| id | int(11) | NO | PRI | NULL | |
| name | varchar(10) | NO | | NULL | |
+——-+————-+——+—–+———+——-+

事务隔离级别是RR,表里现在又两条记录:

+-+——+
| id | name |
+-+——+
| 1 | new |
| 4 | new |
+-+——+

下面通过两个实验来分析:

—————-实验1:——————————-
事务1begin;
select * from t where id =1 for update;

事务2begin;
update t set name =dwhere id =4 ;

事务1update t set name=dwhere id= 4;
(被Hold住)
事务2update t set name =dwhere id =1;

事务1 此时被检测到死锁,被重启事务了。
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
—————–实验2————————
事务1begin
select * from t where id =1 for update
事务2begin
select * from t where id =4 for update;

事务1update t set name =dwhere id =4;
(被阻塞了)
事务2update t set name =dwhere id =1;
ERROR 1213 (40001): Deadlock …. (后面错误省略)

两个测试,mysql都检测到了死锁,为什么实验1中由事务2触发死锁,重启的是事务1?但是实验2 中,事务2触发死锁,重启的却是事务2? mysql在检测到死锁以后,重启的事务的依据是什么呢?

简单来说,mysql的死锁检测到之后,会选择一个事务进行回滚。而选择的依据:看哪个事务的权重最小,事务权重的计算方法:事务加的锁最少;事务写的日志最少;事务开启的时间最晚。实验1,事务2写了日志,事务1没有,回滚事务1。实验2,都没写日志,但是事务1开始的早,回滚事务2。

4、如何定位死锁并避免

定位死锁成因

1)通过应用业务日志定位到问题代码,找到相应的事务对应的sql。

因为死锁被检测到后会回滚,这些信息都会以异常反应在应用的业务日志中,通过这些日志我们可以定位到相应的代码,并把事务的sql给梳理出来。

start tran
1 deleteHeartCheckDOByToken
2 updateSessionUser
...
commit

此外,我们根据日志回滚的信息发现在检测出死锁时这个事务被回滚。

2)确定数据库隔离级别。

执行select @@global.tx_isolation,可以确定数据库的隔离级别,我们数据库的隔离级别是RC,这样可以很大概率排除gap锁造成死锁的嫌疑;

3)找DBA执行下show InnoDB STATUS看看最近死锁的日志。

这个步骤非常关键。通过DBA的帮忙,我们可以有更为详细的死锁信息。通过此详细日志一看就能发现,与之前事务相冲突的事务结构如下:

start tran
1 updateSessionUser
2 deleteHeartCheckDOByToken
...
commit

5、参考

mysql死锁问题分析



坚持原创技术分享,您的支持将鼓励我继续创作!



分享

评论