一、 引言
SQL死锁是一个常见且复杂的并发控制问题。当多个事务在数据库中互相等待对方释放锁时,就会形成死锁,从而导致事务无法继续执行,影响系统的性能和可用性。死锁不仅会导致数据库操作的阻塞,增加延迟,还可能对系统的稳定性和响应速度产生严重影响。因此,了解死锁的成因、识别死锁的方式以及采取有效的防范和解决措施,对于数据库管理员和开发人员而言,具有重要的实践意义。本文将根据一次线上SQL死锁的真实案例刨析发生的机制、如何检测死锁及常见的解决策略,以帮助提高数据库系统的效率和可靠性。
二、MySQL中的锁
在介绍死锁之前,我们先来看看MySQL中的锁有哪些。
2.1 共享锁与排他锁
InnoDB 实现了标准的行级锁,包括两种:共享锁(简称 s 锁)、排它锁(简称 x 锁)。
-
共享锁(S锁):允许持锁事务读取一行。
-
排他锁(X锁):允许持锁事务更新或者删除一行。
如果事务 T1 持有行 r 的 s 锁,那么另一个事务 T2 请求 r 的锁时,会做如下处理:
-
T2 请求 s 锁立即被允许,结果 T1 T2 都持有 r 行的 s 锁
-
T2 请求 x 锁不能被立即允许
如果 T1 持有 r 的 x 锁,那么 T2 请求 r 的 x、s 锁都不能被立即允许,T2 必须等待T1释放 x 锁才可以,因为X锁与任何的锁都不兼容。
兼容性 | S | X |
S | 兼容 | 不兼容 |
X | 不兼容 | 不兼容 |
2.2 意向锁
-
意向共享锁( IS 锁):事务想要获得一张表中某几行的共享锁
-
意向排他锁( IX 锁): 事务想要获得一张表中某几行的排他锁
比如:事务1在表1上加了S锁后,事务2想要更改某行记录,需要添加IX锁,由于不兼容,所以需要等待S锁释放;如果事务1在表1上加了IS锁,事务2添加的IX锁与IS锁兼容,就可以操作,这就实现了更细粒度的加锁。
兼容性 | IS | IX | S | X |
IS | 兼容 | 兼容 | 兼容 | 不兼容 |
IX | 兼容 | 兼容 | 不兼容 | 不兼容 |
S | 兼容 | 不兼容 | 兼容 | 不兼容 |
X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
2.3 记录锁(Record Locks)
-
记录锁是最简单的行锁,仅仅锁住一行。如:SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE
-
记录锁永远都是加在索引上的,即使一个表没有索引,InnoDB也会隐式的创建一个索引,并使用这个索引实施记录锁。
-
会阻塞其他事务对其插入、更新、删除
2.4 间隙锁(Gap Locks)
-
间隙锁是一种加在两个索引之间的锁,或者加在第一个索引之前,或最后一个索引之后的间隙。
-
使用间隙锁锁住的是一个区间,而不仅仅是这个区间中的每一条数据。
-
间隙锁只阻止其他事务插入到间隙中,他们不阻止其他事务在同一个间隙上获得间隙锁,所以 gap x lock 和 gap s lock 有相同的作用。
2.5 Next-Key Locks
-
Next-key锁是记录锁和间隙锁的组合,它指的是加在某条记录以及这条记录前面间隙上的锁。
2.6 插入意向锁(Insert Intention)
-
插入意向锁是在插入一行记录操作之前设置的一种间隙锁,这个锁释放了一种插入方式的信号,亦即多个事务在相同的索引间隙插入时如果不是插入间隙中相同的位置就不需要互相等待。
三、线上SQL死锁的背景
之前自己参与过一个农业类项目,在项目初期,我们是没有将读写表分离的,而是基于一个主库完成读写操作。后面随着业务方的推广业务量逐渐增大,我们偶尔会收到系统的异常报警信息,DBA 通知我们数据库出现了死锁异常。业务开始是比较简单的,就是新增订单、修改订单、查询订单等操作,那为什么会出 现死锁呢?经过日志分析,我们发现是作为幂等性校验的一张表经常出现死锁异常。
接下来我将在本地复现该问题,帮助我们更好的去理解SQL死锁的原因。
首先,创建一张订单记录表,该表主要用于校验订单重复创建:
CREATE TABLE `order_record` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`order_no` int(11) DEFAULT NULL,
`status` int(4) DEFAULT NULL,
`create_date` datetime(0) DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
INDEX `idx_order_status`(`order_no`,`status`) USING BTREE
) ENGINE = InnoDB
为了能重现该问题,我们先将事务设置为手动提交。MySQL 数据库默认情况下是自动提交事务,我们可以通过以 下命令行查看自动提交事务是否开启:
先将 MySQL 数据库的事务提交设置为手动提交,我们可以看见此时已经改为手动提交了。
当时订单在做幂等性校验时,先是通过订单号检查订单是否存在,如果不存在则新增订单记录。 知道具体的逻辑之后,我们再来模拟创建产生死锁的运行 SQL 语句。首先,我们模拟新建两个订单,并按照以下顺序执行幂等性校验 SQL 语句
事务A | 事务B |
BEGIN; | BEGIN; |
select id from order_record where order_on = 4 for update; // 检查是否存在order_no = 4的订单 | |
select id from order_record where order_on = 5 for update; // 检查是否存在order_no = 5的订单 | |
insert into order_record (order_no,status,create_date) values (4,1,'2025-03-20 15:30:25'); // 如果没有则插入信息 此时,锁等待 ....... | |
insert into order_record (order_no,status,create_date) values (5,1,'2025-03-20 15:30:25'); // 如果没有则插入信息 此时,锁等待 ....... | |
commit; (未完成) | commit; (未完成) |
此时,我们会发现两个事务已经进入死锁状态。我们可以在 information_schema数据库(版本5.7) 中查询到具体的死锁情况,如下图所示:
你可能会想,为什么 SELECT 要加 for update 排他锁,而不是使用共享锁呢?试 想下,如果是两个订单号一样的请求同时进来,就有可能出现幻读。也就是说,一开始事务 A 中的查询没有该订单号,后来事务 B 新增了一个该订单号的记录,此时事务 A 再新增一条该订单号记录,就会创建重复的订单记录。面对这种情况,我们可以使用锁间隙算法来防止幻读。
四、产生死锁的原因
在开始问题之前我们先来回想一下行锁的具体实现算法有那些?行锁的具体实现算法有三种:record lock、gap lock 以及 next-key lock。record lock 是专门对索引项加锁;gap lock 是对索引项之间的间隙加锁;next-key lock 则是前面两 种的组合,对索引项以其之间的间隙加锁。
只在可重复读或以上隔离级别下的特定操作才会取得 gap lock 或 next-key lock,在 Select、Update 和 Delete 时,除了基于唯一索引的查询之外,其它索引查询时都会获取 gap lock 或 next-key lock,即锁住其扫描的范围。主键索引也属于唯一索引,所以主键 索引是不会使用 gap lock 或 next-key lock。 在 MySQL 中,gap lock 默认是开启的,即 innodb_locks_unsafe_for_binlog 参数值是 disable 的,且 MySQL 中默认的是 RR 事务隔离级别。
当我们执行以下查询 SQL 时,由于 order_no 列为非唯一索引,此时又是 RR 事务隔离级 别,所以 SELECT 的加锁类型为 gap lock,这里的 gap 范围是 (4,+∞)。
SELECT id FROM order_record where order_no = 4 for update
执行查询 SQL 语句获取的 gap lock 并不会导致阻塞,而当我们执行以下插入 SQL 时,会 在插入间隙上再次获取插入意向锁。插入意向锁其实也是一种 gap 锁,它与 gap lock 是 冲突的,所以当其它事务持有该间隙的 gap lock 时,需要等待其它事务释放 gap lock 之 后,才能获取到插入意向锁。
以上事务 A 和事务 B 都持有间隙 (4,+∞)的 gap 锁,而接下来的插入操作为了获取到插 入意向锁,都在等待对方事务的 gap 锁释放,于是就造成了循环等待,导致死锁。
insert into order_record (order_no,states,create_time) values (5,1,'2025-03-20 15:30:25');
五、避免死锁的措施
知道了死锁问题源自哪儿,就可以找到合适的方法来避免它了。
避免死锁最直观的方法就是在两个事务相互等待时,当一个事务的等待时间超过设置的某一 阈值,就对这个事务进行回滚,另一个事务就可以继续执行了。这种方法简单有效,在 InnoDB 中,参数 innodb_lock_wait_timeout 是用来设置超时时间的。
另外,我们还可以将 order_no 列设置为唯一索引列。虽然不能防止幻读,但我们可以利用 它的唯一性来保证订单记录不重复创建,这种方式唯一的缺点就是当遇到重复创建订单时会抛出异常。
六、预防死锁
解决死锁的最佳方式当然就是预防死锁的发生了,我们平时编程中,可以通过以下一些常规 手段来预防死锁的发生:
1. 在编程中尽量按照固定的顺序来处理数据库记录,假设有两个更新操作,分别更新两条 相同的记录,但更新顺序不一样,有可能导致死锁;
2. 在允许幻读和不可重复读的情况下,尽量使用 RC 事务隔离级别,可以避免 gap lock 导 致的死锁问题;
3. 更新表时,尽量使用主键更新;
4. 避免长事务,尽量将长事务拆解,可以降低与其它事务发生冲突的概率;
5. 设置锁等待超时参数,我们可以通过 innodb_lock_wait_timeout 设置合理的等待超时 阈值,特别是在一些高并发的业务中,我们可以尽量将该值设置得小一些,避免大量事务等 待,占用系统资源,造成严重的性能开销。
七 小结
数据库发生死锁的概率并不是很大,一旦遇到了,就一定要彻查具体原因,尽快找出解决方案,我们只有先对 MySQL 的 InnoDB 存储引擎有足够的了解,才能剖析出造成死锁的具体原因。