锁问题排查及解决

死锁和死锁检测

当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。这里我用数据库中的行锁举个例子
notion image
这时候,事务A在等待事务B释放id=2的行锁,而事务B在等待事务A释放id=1的行锁。 事务A和事务B在互相等待对方的资源释放,就是进入了死锁状态。当出现死锁以后,有两种策略:
  • 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数innodb_lock_wait_timeout来设置。
  • 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数innodb_deadlock_detect设置为on,表示开启这个逻辑
在InnoDB中,innodb_lock_wait_timeout的默认值是50s,意味着如果采用第一个策略,当出现死锁以后,第一个被锁住的线程要过50s才会超时退出,然后其他线程才有可能继续执行。对于在线服务来说,这个等待时间往往是无法接受的。
但是,我们又不可能直接把这个时间设置成一个很小的值,比如1s。这样当出现死锁的时候,确实很快就可以解开,但如果不是死锁,而是简单的锁等待呢?所以,超时时间设置太短的话,会出现很多误伤。
所以,正常情况下我们还是要采用第二种策略,即:主动死锁检测,而且innodb_deadlock_detect的默认值本身就是on。主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,但是它也是有额外负担的。
你可以想象一下这个过程:每当一个事务被锁的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。
那如果是我们上面说到的所有事务都要更新同一行的场景呢?
每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是O(n)的操作。假设有1000个并发线程要同时更新同一行,那么死锁检测操作就是100万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的CPU资源。因此,你就会看到CPU利用率很高,但是每秒却执行不了几个事务。
根据上面的分析,我们来讨论一下,怎么解决由这种热点行更新导致的性能问题呢?问题的症结在于,死锁检测要耗费大量的CPU资源。
一种头痛医头的方法,就是如果你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。但是这种操作本身带有一定的风险,因为业务设计的时候一般不会把死锁当做一个严重错误,毕竟出现死锁了,就回滚,然后通过业务重试一般就没问题了,这是业务无损的。而关掉死锁检测意味着可能会出现大量的超时,这是业务有损的
另一个思路是控制并发度。根据上面的分析,你会发现如果并发能够控制住,比如同一行同时最多只有10个线程在更新,那么死锁检测的成本很低,就不会出现这个问题。一个直接的想法就是,在客户端做并发控制。但是,你会很快发现这个方法不太可行,因为客户端很多。我见过一个应用,有600个客户端,这样即使每个客户端控制到只有5个并发线程,汇总到数据库服务端以后,峰值并发数也可能要达到3000。
因此,这个并发控制要做在数据库服务端。如果你有中间件,可以考虑在中间件实现;如果你的团队有能修改MySQL源码的人,也可以做在MySQL里面。基本思路就是,对于相同行的更新,在进入引擎之前排队。这样在InnoDB内部就不会有大量的死锁检测工作了。
可能你会问,如果团队里暂时没有数据库方面的专家,不能实现这样的方案,能不能从设计上优化这个问题呢?
你可以考虑通过将一行改成逻辑上的多行来减少锁冲突。还是以影院账户为例,可以考虑放在多条记录上,比如10个记录,影院的账户总额等于这10个记录的值的总和。这样每次要给影院账户加金额的时候,随机选其中一条记录来加。这样每次冲突概率变成原来的1/10,可以减少锁等待个数,也就减少了死锁检测的CPU消耗。
这个方案看上去是无损的,但其实这类方案需要根据业务逻辑做详细设计。如果账户余额可能会减少,比如退票逻辑,那么这时候就需要考虑当一部分行记录变成0的时候,代码要有特殊处理。
如果你要删除一个表里面的前10000行数据,有以下三种方法可以做到:
  • 第一种,直接执行delete from T limit 10000;
  • 第二种,在一个连接中循环执行20次 delete from T limit 500;
  • 第三种,在20个连接中同时执行delete from T limit 500
你会选择哪一种方法呢?为什么呢?
在一个连接中循环执行20次 delete from T limit 500
确实是这样的,第二种方式是相对较好的。
第一种方式(即:直接执行delete from T limit 10000)里面,单个语句占用时间长,锁的时间也比较长;而且大事务还会导致主从延迟。
第三种方式(即:在20个连接中同时执行delete from T limit 500),会人为造成锁冲突。

死锁

怎么看死锁?
下图是在出现死锁后,执行show engine innodb status命令得到的部分输出。这个命令会输出很多信息,有一节LATESTDETECTED DEADLOCK,就是记录的最后一次死锁信息。
死锁现场
死锁现场
我们来看看这图中的几个关键信息。
  1. 这个结果分成三部分:
      • (1) TRANSACTION,是第一个事务的信息;
      • (2) TRANSACTION,是第二个事务的信息;
      • WE ROLL BACK TRANSACTION (1),是最终的处理结果,表示回滚了第一个事务。
  1. 第一个事务的信息中:
      • WAITING FOR THIS LOCK TO BE GRANTED,表示的是这个事务在等待的锁信息;
      • index c of table `test`.`t`,说明在等的是表t的索引c上面的锁;
      • lock mode S waiting 表示这个语句要自己加一个读锁,当前的状态是等待中;
      • Record lock说明这是一个记录锁;
      • n_fields 2表示这个记录是两列,也就是字段c和主键字段id;
      • 0: len 4; hex 0000000a; asc ;;是第一个字段,也就是c。值是十六进制a,也就是10;
      • 1: len 4; hex 0000000a; asc ;;是第二个字段,也就是主键id,值也是10;
      • 这两行里面的asc表示的是,接下来要打印出值里面的“可打印字符”,但10不是可打印字符,因此就显示空格。
      • 第一个事务信息就只显示出了等锁的状态,在等待(c=10,id=10)这一行的锁。
      • 当然你是知道的,既然出现死锁了,就表示这个事务也占有别的锁,但是没有显示出来。别着急,我们从第二个事务的信息中推导出来。
  1. 第二个事务显示的信息要多一些:
      • “ HOLDS THE LOCK(S)”用来显示这个事务持有哪些锁;
      • index c of table `test`.`t` 表示锁是在表t的索引c上;
      • hex 0000000a和hex 00000014表示这个事务持有c=10和c=20这两个记录锁;
      • WAITING FOR THIS LOCK TO BE GRANTED,表示在等(c=5,id=5)这个记录锁。
从上面这些信息中,我们就知道:
  1. “lock in share mode”的这条语句,持有c=5的记录锁,在等c=10的锁;
  1. “for update”这个语句,持有c=20和c=10的记录锁,在等c=5的记录锁。
因此导致了死锁。这里,我们可以得到两个结论:
  1. 由于锁是一个个加的,要避免死锁,对同一组资源,要按照尽量相同的顺序访问;
  1. 在发生死锁的时刻,for update 这条语句占有的资源更多,回滚成本更大,所以InnoDB选择了回滚成本更小的lock in share mode语句,来回滚。
lock_mode X waiting表示next-key lock;
lock_mode X locks rec but not gap是只有行锁;
还有一种 “locks gap before rec”,就是只有间隙锁;

锁等待

怎么看锁等待?
看完死锁,我们再来看一个锁等待的例子。
delete导致间隙变化
delete导致间隙变化
 
可以看到,由于session A并没有锁住c=10这个记录,所以session B删除id=10这一行是可以的。但是之后,session B再想insert id=10这一行回去就不行了。
现在我们一起看一下此时show engine innodb status的结果,看看能不能给我们一些提示。锁信息是在这个命令输出结果的TRANSACTIONS这一节。你可以在文稿中看到这张图片
锁等待信息
锁等待信息
 
我们来看几个关键信息。
  1. index PRIMARY of table `test`.`t` ,表示这个语句被锁住是因为表t主键上的某个锁。
  1. lock_mode X locks gap before rec insert intention waiting 这里有几个信息:
      • insert intention表示当前线程准备插入一个记录,这是一个插入意向锁。为了便于理解,你可以认为它就是这个插入动作本身。
      • gap before rec 表示这是一个间隙锁,而不是记录锁。
  1. 那么这个gap是在哪个记录之前的呢?接下来的0~4这5行的内容就是这个记录的信息。
  1. n_fields 5也表示了,这一个记录有5列:
      • 0: len 4; hex 0000000f; asc ;;第一列是主键id字段,十六进制f就是id=15。所以,这时我们就知道了,这个间隙就是id=15之前的,因为id=10已经不存在了,它表示的就是(5,15)。
      • 1: len 6; hex 000000000513; asc ;;第二列是长度为6字节的事务id,表示最后修改这一行的是trx id为1299的事务。
      • 2: len 7; hex b0000001250134; asc % 4;; 第三列长度为7字节的回滚段信息。可以看到,这里的acs后面有显示内容(%和4),这是因为刚好这个字节是可打印字符。
      • 后面两列是c和d的值,都是15。
因此,我们就知道了,由于delete操作把id=10这一行删掉了,原来的两个间隙(5,10)、(10,15)变成了一个(5,15)。
说到这里,你可以联合起来再思考一下这两个现象之间的关联:
  1. session A执行完select语句后,什么都没做,但它加锁的范围突然“变大”了;
  1. 当我们执行select * from t where c>=15 and c<=20 order by c desc lock in share mode; 向左扫描到c=10的时候,要把(5, 10]锁起来。
也就是说,所谓“间隙”,其实根本就是由“这个间隙右边的那个记录”定义的。也就是扫描的最后一个行记录的左边的间隙