type
Post
status
Published
date
Jul 7, 2024
slug
summary
Mysql锁篇:死锁问题
tags
Mysql
category
技术栈
password

1、死锁的发生

MySQL死锁是两个或多个事务互相持有对方需要的锁,形成循环等待。如果没有外力干涉,这些事务都无法继续执行下去
简单来说:
💡
事务 A 等事务 B 释放锁,事务 B 又等事务 A 释放锁,这种循环等待就会产生死锁。
这里复习一下死锁发生的必要条件:
  1. 互斥(Mutual Exclusion):资源一次只能被一个事务占用。
  1. 请求并保持(Hold and Wait):事务在持有资源的同时请求新的资源。
  1. 不可抢占(No Preemption):已分配给事务的资源不能被其他事务强行夺取,必须由事务主动释放。
  1. 循环等待(Circular Wait):多个事务形成头尾相接的循环等待关系。
再复习一下间隙锁的概念,这也是导致死锁的关键因素。Innodb 引擎为了解决「可重复读」隔离级别下的幻读问题,引入了间隙锁的概念。间隙锁是属于行锁的一种,行级锁有三种类型:
  • Record Lock:记录锁,锁定单条索引项。例如 SELECT * FROM table WHERE id = 1 FOR UPDATE 会对 id=1 的记录加记录锁
  • Gap Lock:间隙锁,锁定索引记录之间的间隙,但不包括记录本身,是一个左开右开的区间。例如 SELECT * FROM table WHERE id BETWEEN 10 AND 20 FOR UPDATE 会对 (10, 20) 范围加锁。
  • Next-Key Lock:Record Lock + Gap Lock 的组合,锁定索引记录及其前面的间隙,是一个左开右闭的区间。例如 SELECT * FROM table WHERE id BETWEEN 10 AND 20 FOR UPDATE 会对 (10, 20] 范围加锁。
另外还有一种特殊的锁:
  • Insert Intention Lock:插入意向锁,当一个事务想往某个间隙里插入数据时,会申请插入意向锁。例如当前有 id = 10 和 id = 20 两条数据,当插入 INSERT INTO user(id, age, name) VALUES(15, 18, 'Tom'); 时,需要在 (10, 20) 这个间隙上加插入意向锁。
这里注意:间隙锁与间隙锁是互相兼容的,但是插入意向锁和间隙锁互不兼容。以上面这个例子为例,如果(10, 20)这个间隙已经被其他事务加了间隙锁,那么插入就会等待。
为什么间隙锁与间隙锁之间是兼容的?
间隙锁的意义只在于阻止区间被插入,因此是可以共存的。一个事务获取的间隙锁不会阻止另一个事务获取同一个间隙范围的间隙锁,共享和排他的间隙锁是没有区别的,他们相互不冲突,且功能相同,即两个事务可以同时持有包含共同间隙的间隙锁。
这里的共同间隙包括两种场景:
  1. 两个间隙锁的间隙区间完全一样。
  1. 一个间隙锁包含的间隙区间是另一个间隙锁包含间隙区间的子集。

2、InnoDB 常见死锁场景

假如存在以下表:
在这个表里面:
  • id 是聚簇索引
  • 叶子节点存储整行数据
  • 二级索引 idx_age 的叶子节点存储的是:age + 主键 id
也就是说,通过二级索引查数据时,通常流程是:
  1. 先在二级索引 idx_age 上找到主键 id
  1. 再回到聚簇索引上查整行数据
所以一个 SQL 可能不只锁一个地方,而是可能锁:
  • 二级索引记录
  • 二级索引间隙
  • 聚簇索引记录
  • 聚簇索引间隙
这就为死锁创造了条件。
结合 聚簇索引间隙锁 来看,常见死锁场景主要有这几类:
  1. 多个事务按不同顺序访问聚簇索引记录
  1. 二级索引和聚簇索引加锁顺序交叉
  1. 范围查询产生间隙锁,和插入意向锁互相等待
  1. 非唯一索引范围扫描锁住大量记录和间隙
  1. 没有合适索引导致大范围扫描,加锁范围扩大

场景一:聚簇索引记录锁顺序不一致导致死锁

这是最经典的死锁。
表数据:
事务 A:
事务 B:
执行顺序如下:
时间
事务 A
事务 B
1
锁住 id = 1
2
锁住 id = 2
3
等待 id = 2
4
等待 id = 1
此时:
形成等待环:
于是死锁发生。
解决方式
尽量保证多个事务按照相同顺序访问数据,比如都按照主键升序更新:
不要有的事务先更新 id = 1,有的事务先更新 id = 2

场景二:二级索引和聚簇索引交叉加锁导致死锁

这是和聚簇索引关系最密切的一类死锁。
表结构:
数据:
执行过程
事务 A:
这个 SQL 通过主键更新,会先锁住聚簇索引中的:
事务 B:
这个 SQL 通过二级索引 idx_age 查找,会先锁住二级索引记录:
然后它要回表,尝试锁聚簇索引:
id = 1 已经被事务 A 锁住,所以事务 B 等待。
接着事务 A 执行:
事务 A 要修改 age,所以需要修改二级索引 idx_age
它需要处理旧的二级索引记录:
但这个二级索引记录已经被事务 B 锁住。
于是:
形成死锁。
核心原因
通过主键访问时,通常先锁:
通过二级索引访问时,通常先锁:
如果两个事务访问路径不同,就可能出现加锁顺序不一致。
解决方式
尽量让并发事务使用一致的访问路径。
例如:
  • 都通过主键更新
  • 避免一个事务用主键,一个事务用二级索引更新同一批数据
  • 对业务批量更新,先查询主键,再按主键顺序更新
例如:

场景三:间隙锁和插入意向锁导致死锁

这是 InnoDB 在 REPEATABLE READ 隔离级别下很常见的情况。
表结构:
当前数据:
执行过程
事务 A:
由于 id = 15 不存在,InnoDB 会锁住主键索引上的间隙:
事务 B:
id = 16 也不存在。
注意:间隙锁之间通常是不互斥的,所以事务 B 也可以在 (10, 20) 上持有间隙锁。
此时:
然后事务 A 执行:
插入 id = 15 需要插入意向锁。
但事务 B 持有 (10, 20) 的间隙锁,所以事务 A 等待事务 B。
事务 B 执行:
插入 id = 16 也需要插入意向锁。
但事务 A 持有 (10, 20) 的间隙锁,所以事务 B 等待事务 A。
于是:
形成死锁。
这个死锁的根本原因是
  • 间隙锁之间可以共存
  • 但是插入意向锁和其他事务的间隙锁冲突
  • 两个事务都先持有间隙锁,然后都想插入这个间隙,就可能互相等待

场景四:非唯一索引范围查询导致间隙锁死锁

表结构:
数据:
事务 A:
这个查询走二级索引 idx_age
REPEATABLE READ 下,它可能锁住:
事务 B 如果执行:
它要插入 age = 15idx_age 中。
age = 15 位于事务 A 锁住的二级索引间隙中,所以事务 B 可能等待。
如果事务 B 此前还持有事务 A 需要的其他记录锁,就可能形成死锁。
这个引发死锁的根本原因是:非唯一索引更容易产生间隙锁
例如:
即使是等值查询,如果 age 不是唯一索引,InnoDB 也不能确定只有一条记录。
因此它可能锁住:
而不是只锁某一条记录。
这会扩大锁范围,提高死锁概率。

场景五:没有合适索引导致锁范围扩大

例如表结构:
注意:age 没有索引。
执行:
由于 age 没有索引,InnoDB 可能需要扫描聚簇索引。
在扫描过程中,它可能对大量聚簇索引记录加锁。
如果并发事务也在更新这些记录,就更容易出现:
从而发生死锁。
解决方式:给查询条件建立合适索引
但也要注意:
  • 普通索引可能带来间隙锁
  • 唯一索引等值命中时,锁范围通常更小

3、应对死锁

3.1 如果避免间隙锁的发生

间隙锁是导致死锁发生的重要因素,所以我们应该尽可能避免产生间隙锁。

什么情况下会发生间隙锁

在 InnoDB 中,间隙锁主要出现在:
  • 使用REPEATABLE READ 隔离级别:MySQL InnoDB 默认隔离级别是:REPEATABLE READ。在这个级别下,为了防止幻读,范围查询会使用间隙锁或临键锁。
  • 使用当前读:例如以下这些语句属于当前读,可能加锁:
    • 范围查询:这些语句很容易产生 Next-Key Lock。
      • 非唯一索引等值查询:例如下面这个语句,如果 age 是普通索引,不是唯一索引,那么 InnoDB 可能锁住 age = 18 的多条记录以及相关间隙。
        • 唯一索引未命中:例如下面这个语句,如果 id = 15 不存在,当前已有 id = 10id = 20 两条记录,那么就会锁住 (10, 20)

          什么情况下不会发生间隙锁

          • 使用 READ COMMITTED 隔离级别。READ COMMITTED 下,InnoDB 大多数情况下会减少间隙锁的使用。但某些场景仍可能使用间隙锁,例如:
            • 外键约束检查
            • 唯一键冲突检查
            • 部分特殊的索引检查场景
          • 唯一索引等值命中:例如下面这个语句,如果 id 是主键或唯一索引,并且 id = 10 存在,通常只会锁住这条记录。

            3.2 如何避免死锁发生

            如果系统未发生死锁问题,可以通过以下手段尽可能避免死锁发生:
            1. 保证数据访问顺序一致:例如多个事务都按照主键升序更新,不要一个事务升序,一个事务降序。
            1. 尽量使用主键或唯一索引精确更新:优先使用 UPDATE user SET name = 'X' WHERE id = 1;,尽量少用 UPDATE user SET name = 'X' WHERE age BETWEEN 10 AND 20;
            1. 为查询条件建立合适索引:避免无索引扫描导致大量记录被锁。
            1. 缩短事务时间:事务越小,持有锁的时间就越短,与其他事务冲突的概率就越低。不要在事务里做远程调用、处理大量业务逻辑、等待用户输入等耗时操作。
            1. 避免大范围当前读:谨慎使用以下语句,尤其是范围条件 ><BETWEENLIKE 'xxx%'
              1. 在单条SQL中完成操作:如果可能,用一条更复杂的SQL代替多条简单的SQL。例如,UPDATE table SET value = value + 1 WHERE id IN (1, 2) 比分别更新 id=1 和 id=2 的两条语句更好。
              1. 使用乐观锁代替悲观锁:不要使用悲观锁(SELECT ... FOR UPDATE),而是通过版本号(version)或时间戳字段在更新时进行校验。这本质上是将锁争用转移到了应用层,通过重试来解决冲突,非常适合读多写少的场景。

              3.3 当死锁发生时如何处理

              如果系统已经发生死锁,要解决死锁问题需要破坏上述四个必要条件(互斥、请求与保持、不可抢占、循环等待)中的任意一个,如资源一次性分配、允许抢占、强制释放资源等。在数据库层面,有两种策略通过「打破循环等待条件」来解除死锁状态:
              • 设置事务等待锁的超时时间。当一个事务的等待时间超过该值后,就对这个事务进行回滚,于是锁就释放了,另一个事务就可以继续执行了。在 InnoDB 中,参数 innodb_lock_wait_timeout 是用来设置超时时间的,默认是 50 秒。
              • 开启死锁自动检测。主动死锁检测在发现死锁后,会自动强制回滚死锁链条中的一个代价较小的事务(通常是影响行数较少的事务),让其他事务得以继续执行。被回滚的事务会收到一个错误:ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction。在 InnoDB 中,将参数 innodb_deadlock_detect 设置为 on,表示开启(默认开启)。
              分布式事务:实现汇总Mysql锁篇:锁汇总(间隙锁、共享/排他锁、悲观/乐观锁)
              Loading...