mysql死锁问题分析

https://blog.csdn.net/BaiHuaXiu123/article/details/54015279

SHOW GLOBAL VARIABLES LIKE 'innodb_deadlock_detect';

SHOW GLOBAL VARIABLES LIKE 'innodb_lock_wait_timeout';

SHOW GLOBAL VARIABLES LIKE 'innodb_rollback_on_timeout';

1 死锁是怎么被发现的?

1.1 死锁成因&&检测方法

     左图那两辆车造成死锁了吗?不是!右图四辆车造成死锁了吗?是!

                                                                      图2 死锁描述

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

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

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

1.2 wait-for graph原理

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

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

1.2 innodb隔离级别、索引与锁 

      死锁检测是死锁发生时innodb给我们的救命稻草,我们需要它,但我们更需要的是避免死锁发生的能力,如何尽可能避免?这需要了解innodb中的锁。

1.2.1 锁与索引的关系

       假设我们有一张消息表(msg),里面有3个字段。假设id是主键,token是非唯一索引,message没有索引。

id: bigint

token: varchar(30)

message: varchar(4096)

     innodb对于主键使用了聚簇索引,这是一种数据存储方式,表数据是和主键一起存储,主键索引的叶结点存储行数据。对于普通索引,其叶子节点存储的是主键值。

                                                                                 图4 聚簇索引和二级索引
     下面分析下索引和锁的关系。
1)delete from msg where id=2;

     由于id是主键,因此直接锁住整行记录即可。
                                                                               图5
2)delete from msg where token=’ cvs’;

    由于token是二级索引,因此首先锁住二级索引(两行),接着会锁住相应主键所对应的记录;
                                                                       图6
3)delete from msg where message=订单号是多少’;

     message没有索引,所以走的是全表扫描过滤。这时表上的各个记录都将添加上X锁。
                                                                        图7

1.2.2 锁与隔离级别的关系

     大学数据库原理都学过,为了保证并发操作数据的正确性,数据库都会有事务隔离级别的概念:1)未提交读(Read uncommitted);2)已提交读(Read committed(RC));3)可重复读(Repeatable read(RR));4)可串行化(Serializable)。我们较常使用的是RC和RR。

     提交读(RC):只能读取到已经提交的数据。

     可重复读(RR):在同一个事务内的查询都是事务开始时刻一致的,InnoDB默认级别。

     我们在1.2.1节谈论的其实是RC隔离级别下的锁,它可以防止不同事务版本的数据修改提交时造成数据冲突的情况,但当别的事务插入数据时可能会出现问题。

       如下图所示,事务A在第一次查询时得到1条记录,在第二次执行相同查询时却得到两条记录。从事务A角度上看是见鬼了!这就是幻读,RC级别下尽管加了行锁,但还是避免不了幻读。

                                                                     图8

     innodb的RR隔离级别可以避免幻读发生,怎么实现?当然需要借助于锁了!

     为了解决幻读问题,innodb引入了gap锁。

      在事务A执行:update msg set message=‘订单’ where token=‘asd’;

      innodb首先会和RC级别一样,给索引上的记录添加上X锁,此外,还在非唯一索引’asd’与相邻两个索引的区间加上锁。

       这样,当事务B在执行insert into msg values (null,‘asd',’hello’); commit;时,会首先检查这个区间是否被锁上,如果被锁上,则不能立即执行,需要等待该gap锁被释放。这样就能避免幻读问题。
                                                                           图9

     推荐一篇好文,可以深入理解锁的原理:http://hedengcheng.com/?p=771#_Toc374698322

3 死锁成因

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

3.1不同表相同记录行锁冲突

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

                                                                       图10

3.2相同表记录行锁冲突

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

                                                                          图11

3.3不同索引锁冲突

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

                                                                          图12

3.4 gap锁冲突

     innodb在RR级别下,如下的情况也会产生死锁,比较隐晦。不清楚的同学可以自行根据上节的gap锁原理分析下。
                                                                               图13

4 如何尽可能避免死锁

1)以固定的顺序访问表和行。比如对第2节两个job批量更新的情形,简单方法是对id列表先排序,后执行,这样就避免了交叉等待锁的情形;又比如对于3.1节的情形,将两个事务的sql顺序调整为一致,也能避免死锁。

2)大事务拆小。大事务更倾向于死锁,如果业务允许,将大事务拆小。

3)在同一个事务中,尽可能做到一次锁定所需要的所有资源,减少死锁概率。

4)降低隔离级别。如果业务允许,将隔离级别调低也是较好的选择,比如将隔离级别从RR调整为RC,可以避免掉很多因为gap锁造成的死锁。

5)为表添加合理的索引。可以看到如果不走索引将会为表的每一行记录添加上锁,死锁的概率大大增大。

5 如何定位死锁成因

     下面以本文开头的死锁案例为例,讲下如何排查死锁成因。

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

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

start tran
1 deleteHeartCheckDOByToken
2 updateSessionUser
...
commit
  • 1
  • 2
  • 3
  • 4

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

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

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

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

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

start tran
1 updateSessionUser
2 deleteHeartCheckDOByToken
...
commit
  • 1
  • 2
  • 3
  • 4
  • 5

  这不就是图10描述的死锁嘛!

innodb_deadlock_detect
如果关闭innodb_deadlock_detect,也即关闭了死锁自动监测机制时,当两个或多个session间存在死锁的情况下,MySQL怎么去处理?
这里会涉及到另外一个参数:锁超时,也即innodb_lock_wait_timeout,该参数指定了“锁申请时候的最长等待时间”
官方的解释是:The length of time in seconds an InnoDB transaction waits for a row lock before giving up.
innodb_lock_wait_timeout默认值是50秒,也就是意味着session请求时,申请不到锁的情况下最多等待50秒钟,然后呢,就等价于死锁,自动回滚当前事物了?其实不是的,事情没有想象中的简单。

innodb_rollback_on_timeout
这里就涉及到另外一个参数:innodb_rollback_on_timeout,默认值是off,该参数的决定了当前请求锁超时之后,回滚的是整个事物,还是仅当前语句,
官方的解释是:InnoDB rolls back only the last statement on a transaction timeout by default。
默认值是off,也就是回滚当前语句(放弃当前语句的锁申请),有人强烈建议打开这个选项(on),也就是一旦锁申请超时,就回滚整个事物。
需要注意的是,默认情况下只回滚当前语句,而不是整个事物,当前的事物还在继续,连接也还在,这里与死锁自动监测机制打开之后会主动牺牲一个事物不同,锁超时后并不会主动牺牲其中任何一个事物。
这意味着会出现一种非常严重的情况,举个例子,可以想象一下如下这种情况:


session1                       session2
start transaction;                  start transaction;
update A set val = 'xxx' where id  = 1        update B set val = 'yyy' where id = 1 

……                          ……

update B set val = 'xxx' where id = 1        update A set val = 'yyy' where id  = 1

if 锁超时                        if 锁超时

  #继续申请锁                   #继续申请锁
  update B set val = 'xxx' where id = 1        update A set val = 'xxx' where id = 1


关闭了死锁监测机制后,在innodb_rollback_on_timeout保持默认的off的情况下,session1和session2都是无法正常执行下去的,且永远都无法执行下去。
任意一个session出现锁超时,放弃当前的语句申请的锁,而不是整个事物持有的锁,当前session并不释放其他session请求的锁资源,
即便是继续下去,依旧如此,两者又陷入了相互等待,相互锁请求超时,继续死循环。
从这里可以看到,与死锁自动检测机制在发现死锁是主动选择一个作为牺牲品不同,一旦关闭了innodb_deadlock_detect,Session中的任意一方都不会主动释放已经持有的锁。
此时如果应用程序如果不足够的健壮,继续去申请锁(比如重试机制,尝试重试相关语句),session双方会陷入到无限制的锁超时死循环之中。

事实上推论是不是成立的?做个测试验证一下,数据库环境信息如下

模拟事物双方在当前语句的锁超时之后,继续申请锁,确实是会出现无限制的锁超时的死循环之中。


以上就比较有意思了,与死锁主动监测并牺牲其中一个事物不同,此时事物双方互不相让,当然也都无法成功执行。

这只不过是一个典型的负面场景,除此之外,还会有哪些问题值得思考?
1,因为事物无法快速提交或者回滚,那么连接持有的时间会增加,一旦并发量上来,连接数可能成为一个问题。
2,锁超时时间肯定要设置为一个相对较小的时间,但具体又设置为多少靠谱。
3,关闭死锁检测,带来的收益,与副作用相比哪个更高,当前业务类型是否需要关闭死锁检测,除非数据库中相关操作大部分都是短小事物且所冲突的可能性较低。
4,面对锁超时,应用程序端如何合理地处理锁超时的情况,是重试还是放弃。
5,与此关联的innodb_rollback_on_timeout如何设置,是保持默认的关闭(锁超时的情况下,取消当前语句的所申请),还是打开(锁超时的情况下,回滚整个事物)

最后,其实这个问题属于一个系统工程,不是一个单点问题,除此之外还有可能潜在一些其他的问题,原作者是大神,当然是一个整体方案,需要在整体架构上做处理,作者也给出了一个客观的处理方式。

参考链接
https://dev.mysql.com/doc/refman/5.7/en/innodb-parameters.html#sysvar_innodb_lock_wait_timeout

如何尽可能避免死锁

  • 以固定的顺序访问表和行。比如两个更新数据的事务,事务A 更新数据的顺序 为1,2;事务B更新数据的顺序为2,1。这样更可能会造成死锁;

  • 大事务拆小。大事务更倾向于死锁,如果业务允许,将大事务拆小;

  • 在同一个事务中,尽可能做到一次锁定所需要的所有资源,减少死锁概率;

  •  降低隔离级别。如果业务允许,将隔离级别调低也是较好的选择,比如将隔离级别从RR调整为RC,可以避免掉很多因为gap锁造成的死锁。(我司 MySQL 规范做法);

  • 为表添加合理的索引。可以看到如果不走索引将会为表的每一行记录添加上锁,死锁的概率大大增大。

MySQL行级锁

行锁是引擎提供的,在MySQL中,InnoDB支持行锁,MyISAM不支持行锁,这也是为什么MySQL推荐我们使用InnoDB引擎,因为行锁对并发访问更友好。
但是和MDL释放锁类似,在InnoDB事务请求中,行锁在需要的时候添加,在事务提交后才释放,也就是说,当begin一个事务,update一个字段,未commit时,其他事务的update操作必须等待前一个事务commit后,释放掉行锁才能执行更新操作。

当我们使用事务操作多张表,加多个行锁时,一定要注意将影响最大的那一行最后加锁,减少锁时间,拿丁奇老师的例子来说明:

业务如下:
1、从顾客A账户扣除影票金额
2、从影院B账户添加该金额
3、记录交易日志

为了保证交易的原子性,这三条操作需要放到一个事务中处理,因为操作2的行记录是其他事务也需要使用的,所以为了保证锁等待时间最短,最优的解决办法是将执行顺序变为3,1,2。

死锁

正常的业务中,我们涉及到的更新操作不会是上面的例子中那么简单,当更新操作涉及到多表多字段的时候,如果不慎,很容易陷入死锁。
即:当并发时,不同线程出现循环资源依赖,涉及的线程在互相等待对方释放锁,就会导致陷入死循环的状态,最终导致死锁。
如:
死锁.jpg
事务A在等事务B释放id=2的行锁,事务B又在等待事务A释放id=1的行锁,就导致了死锁现象。
MySQL中针对死锁有两种处理方式:
1、innodb_lock_wait_timeout=50超时自动退出
2、innodb_deadlock_detect=on死锁检测
针对第一种超时退出而言,让第一个被锁住的事务超时50s后自动退出,其他请求再执行,对我们的业务来说是无法接受的。虽然我们可以将超时时间设置的小一些,但是太小如1s的话,可能会波及到正常的事务提交,是不可取的。
所以,最好的方式还是第二种,死锁检测。但是需要注意的是因为每个需要加行锁的事务,都需要顺藤摸瓜的去检测是否会导致死锁,虽然不是扫描所有的事务,但是当请求量很大的时候,死锁检测也是很耗费CPU资源的,你会发现,时间都浪费在了检测上,事务却没执行几个,CPU利用率还很高。

InnoDB是通过索引来实现的行锁,当更新列上没有索引时,其更新会锁整张表。如update T set name='zhangsan' where age=10,若age列无索引,这条语句将会锁整表。这是因为InnoDB需要确保当你执行该语句时,必须阻止其他事务插入age=10的行数据。
但是如果说执行的是update T set name='zhangsan' where age=10 limit 1的话,就会只锁定一行。

大并发的性能解决

那么对于热点行更新,我们最好怎么解决其性能为题呢?
个人认为,最优的解决方案就是使用数据库中间件控制到库的并发量。
原理就是,请求到来时,中间件将其接住,放入队列,每次释放一定量的请求入库操作,这样能有效的控制并发量,减轻数据库的死锁检测压力。
(在使用连接池的情况下,由于连接会复用,如果一个连接执行了set sql_select_limit=1<该参数为设置从SELECT语句返回的最大行数>,当其他业务复用该连接是,该设置也会生效。为了避免这种情况,5.7版本,MySQL提供了一个reset_connection接口,我们调用后连接会被重置,历史数据被清空,避免以上问题。)
网上还看到其他的解决方案:
1、关闭数据库的死锁检测(关闭的风险颇高)
2、将热点行拆分为多行或多表,分流操作以达到减少并发量的效果(此种方式需要注意数据的操作合法性,如行数据不能为0,但当前分行已为0了该怎么办?)

博客地址:https://blog.csdn.net/xiang__liu,https://www.cnblogs.com/xiang--liu/
原文地址:https://www.cnblogs.com/xiang--liu/p/14889250.html