简介:本文深入解析关系型数据库在可重复读隔离级别下产生死锁的底层机制,结合幂等校验等常见业务场景,阐述next-key锁的工作原理及死锁形成条件,并给出优化SQL写法、调整索引策略、降低事务粒度等实用避坑方案,帮助开发者有效规避线上死锁风险。
在高并发业务系统中,数据库死锁是一种极具破坏性的异常现象。一旦发生,轻则导致事务回滚、请求超时,重则引发服务雪崩。死锁的本质是两个或多个事务互相持有对方所需的锁资源,形成循环等待,最终谁也无法继续执行。
本文以一个典型的订单幂等校验场景为切入点,系统剖析在主流事务型存储引擎、可重复读(Repeatable Read,RR)隔离级别下,死锁是如何一步步发生的,并在此基础上提供一套完整的预防与处理策略。
假设有一个订单系统,核心操作包括新增订单、修改订单和查询订单。由于业务要求订单不能重复,开发者在插入前引入了幂等性校验逻辑:
-- 伪代码示意BEGIN;SELECT * FROM t_order WHERE order_no = ? FOR UPDATE;-- 若不存在,则执行插入INSERT INTO t_order (order_no, create_date) VALUES (?, NOW());COMMIT;
关键在于使用了 SELECT ... FOR UPDATE 语句来锁定查询结果,防止并发事务在校验窗口内插入相同订单号,从而规避幻读。
CREATE TABLE `t_order` (`id` INT NOT NULL AUTO_INCREMENT,`order_no` INT DEFAULT NULL,`create_date` DATETIME DEFAULT NULL,PRIMARY KEY (`id`),KEY `idx_order_no` (`order_no`) USING BTREE) ENGINE=InnoDB;
表中已存在 order_no 为 1001~1006 的 6 条记录。
此时有两个并发事务同时发起:
order_no = 1007order_no = 1008两者都先执行 SELECT ... FOR UPDATE 进行存在性校验:
| 步骤 | 事务 A | 事务 B |
|---|---|---|
| 1 | 查询 1007,未命中,加间隙锁(锁定 1006 与 1008 之间的空隙) | 查询 1008,未命中,加间隙锁(锁定 1007 与更大值之间的空隙) |
| 2 | 尝试插入 1007,需要获取 1007 记录上的插入意向锁 | 尝试插入 1008,需要获取 1008 记录上的插入意向锁 |
| 3 | 等待事务 B 释放 1007~1008 之间的间隙锁 | 等待事务 A 释放 1006~1007 之间的间隙锁 |
两个事务相互等待,形成环形依赖,死锁由此产生。
在可重复读隔离级别下,同一个事务内多次读取相同范围的数据,结果应当一致。然而,如果仅依靠记录锁(Record Lock),其他事务仍可在两条记录之间插入新数据,导致同一事务前后两次查询结果不一致——这就是幻读问题。
为解决幻读,InnoDB 引入了 Next-Key Lock,它是以下两种锁的组合:
当执行 SELECT ... FOR UPDATE 且查询条件未命中任何记录时,InnoDB 不会返回空结果就结束,而是会在相应的间隙上加 Gap Lock,以防止其他事务在该间隙中插入数据。这正是上述死锁场景中,两个事务各自持有对方所需间隙锁的根本原因。
在插入操作时,InnoDB 还会使用 Insert Intention Lock,它是一种特殊的间隙锁,表示事务有意向在某个间隙中插入记录。当多个事务的插入意向锁与对方持有的 Gap Lock 冲突时,就会产生锁等待,进而可能升级为死锁。
最直接的方式是改变幂等校验的实现方式:
方案一:利用唯一索引约束,直接插入并捕获异常
-- 在 order_no 上建立唯一索引ALTER TABLE t_order ADD UNIQUE INDEX uk_order_no (order_no);-- 业务层直接插入,通过异常判断是否重复BEGIN;INSERT INTO t_order (order_no, create_date) VALUES (?, NOW());-- 若违反唯一约束则回滚,表示订单已存在COMMIT;
此方案将幂等校验交给数据库引擎在原子层面完成,彻底消除 SELECT ... FOR UPDATE 带来的间隙锁争用。
方案二:先 SELECT 后 INSERT,但不加 FOR UPDATE
BEGIN;SELECT COUNT(*) FROM t_order WHERE order_no = ?;-- 若为 0,则插入INSERT INTO t_order (order_no, create_date) VALUES (?, NOW());COMMIT;
这种写法在高并发下可能出现少量重复,但可通过定时对账、补偿机制来兜底,适合对极端一致性要求不高的场景。
SELECT 1 FROM t_order WHERE order_no = ? 配合合适索引,减少锁范围。如果业务中涉及多表或多行操作,务必保证所有事务以相同的顺序获取锁。例如,总是先锁定 order_no 较小的记录,再锁定较大的记录,这样可以从根本上打破循环等待条件。
数据库死锁并非不可预测的黑箱问题,其本质是锁机制与并发控制策略的必然产物。在可重复读隔离级别下,Next-Key Lock 虽有效解决了幻读,却也为间隙锁冲突埋下了隐患。
开发者应当从以下三个层面系统性地规避死锁:
只有将这些策略有机结合,才能在高并发场景下既保障数据一致性,又维持系统的可用性与稳定性。