简介:本文将结合一个典型的订单幂等性校验场景,深入分析 MySQL InnoDB 在可重复读隔离级别下产生死锁的底层机制,重点解释记录锁、间隙锁与 Next-Key Lock 的相互作用。文章还将给出可落地的避免死锁策略,帮助开发者在高并发写入场景下降低锁冲突与事务回滚风险。
在订单系统中,一个非常常见的需求是:订单不能重复创建。为了保证幂等性,很多团队会在插入订单之前先执行一次查询,判断相同订单号的记录是否已经存在。为了保证并发场景下查询与插入之间的逻辑一致性,往往会使用 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 `index_order` (`order_no`) USING BTREE) ENGINE=InnoDB;
其中 id 是主键索引,order_no 是普通非唯一索引。
表中已经存在若干条记录,例如订单号从 1001 到 1006。
现在有两个事务:
两个事务在插入前都需要通过 select ... for update 查询当前订单号是否已存在。执行顺序如下:
事务 A:1. SELECT * FROM t_order WHERE order_no = 1007 FOR UPDATE;2. 判断不存在;3. 准备 INSERT 订单 1007;事务 B:1. SELECT * FROM t_order WHERE order_no = 1008 FOR UPDATE;2. 判断不存在;3. 准备 INSERT 订单 1008;
如果这两个事务几乎同时执行,就可能出现双方都持有各自间隙锁,又都等待对方释放锁的情况,最终导致死锁。
要理解上面的死锁,必须回到 InnoDB 的锁机制,尤其是在可重复读隔离级别下为了解决幻读而引入的 Next-Key Lock。
在可重复读隔离级别下,普通的一致性读通过 MVCC 机制读取快照版本,不会产生幻读。但对于当前读,例如 select ... for update、update、delete,读取的是最新已提交版本,并且需要加锁。
如果只对查询到的记录加记录锁,那么当其他事务插入一条满足相同查询条件的记录时,当前事务再次执行相同查询就可能看到新的记录,这就是幻读。
InnoDB 为了防止当前读场景下的幻读,引入了三种锁:
例如,当执行:
SELECT * FROM t_order WHERE order_no = 1007 FOR UPDATE;
如果 order_no = 1007 的记录不存在,InnoDB 不会只返回空结果,而是会在 order_no 索引上加一个间隙锁,锁住 1007 前后相邻记录之间的空隙。
这种间隙锁会阻止其他事务在该区间插入新的 order_no 记录。它的存在虽然解决了幻读问题,但也会带来额外的锁冲突。
假设表中 order_no 已有 1001 到 1006,且两个事务分别查询 1007 和 1008。这两个订单号在当前索引树中都位于 1006 之后,因此它们对应的间隙锁范围可能都包含了从 1006 到正无穷的区间。
具体过程如下:
SELECT ... WHERE order_no = 1007 FOR UPDATE,由于记录不存在,加间隙锁;SELECT ... WHERE order_no = 1008 FOR UPDATE,同样加间隙锁;当 InnoDB 的死锁检测开启时,会主动回滚其中一个事务;如果关闭死锁检测,则可能出现相互等待持续较长时间的情况。
需要注意的是,上述加锁过程主要发生在 order_no 普通索引上。如果查询条件使用主键索引,并且查询的记录不存在,则通常只会在主键索引对应位置加间隙锁,影响范围较小。
而在普通索引上,由于索引结构为 B+Tree,间隙锁的粒度往往比主键索引更大,尤其当多个并发事务都查询靠近同一区间的记录时,锁冲突会显著增加。
死锁无法完全杜绝,但通过合理的表设计、索引设计、事务编排和 SQL 写法,可以大幅降低发生概率,并将影响控制在可接受范围内。
事务持锁时间越长,与其他事务发生锁冲突的概率越高。优化思路包括:
例如,订单场景中,可以在应用层先执行一次普通查询,确认大概率不存在后,再进入短事务执行锁定与插入操作。
如果业务上要求订单号唯一,最直接有效的方式是在 order_no 字段上建立唯一索引:
ALTER TABLE t_order ADD UNIQUE KEY uk_order_no (order_no);
这样插入重复订单号时,数据库会直接返回唯一键冲突错误,应用层可以捕获该错误并转换为幂等返回。
这种方式避免了先查询再加锁的复杂逻辑,也减少了间隙锁的使用,能显著降低死锁概率。
在普通索引上执行范围查询并加锁时,InnoDB 通常会加多个间隙锁,锁范围较大。例如:
SELECT * FROM t_order WHERE order_no > 1000 FOR UPDATE;
这种语句在高并发下容易锁住大量区间,与其他插入操作发生冲突。应尽量改为主键精确查询,或者缩小范围条件。
当多个事务需要访问相同的多张表或索引记录时,尽量保证访问顺序一致。例如:
事务 A:先处理账户 1,再处理账户 2;事务 B:也先处理账户 1,再处理账户 2;
避免交叉访问导致循环等待。
在订单场景中,如果同时涉及用户账户扣款、库存扣减、订单写入,建议所有事务都按相同顺序执行这些操作。
如果业务能够容忍一定的并发一致性问题,可以考虑将隔离级别调整为“读已提交”。在读已提交隔离级别下,间隙锁的使用会减少很多,从而降低死锁概率。
但需要注意:读已提交隔离级别无法解决部分幻读场景,需要结合业务特点评估是否适用。
InnoDB 默认开启死锁检测。当检测到死锁时,会选择一个代价较小的事务进行回滚。应用层应捕获死锁异常,并实现有限次数的自动重试。
示例逻辑:
1. 执行事务;2. 如果捕获到死锁异常;3. 等待随机小段时间;4. 重试事务;5. 超过最大重试次数则返回失败。
随机等待的目的是避免多个事务同时重试再次发生冲突。
针对本文最初提到的订单幂等性校验问题,可以给出更稳妥的设计方案。
INSERT INTO t_order(order_no, create_date)VALUES (1007, NOW());
如果 order_no 已存在,数据库返回唯一键冲突。应用层捕获冲突后,再查询一次返回已有订单信息。
优点:
如果无法使用唯一索引,可以在查询条件中增加主键范围,减少间隙锁影响:
SELECT * FROM t_orderWHERE order_no = 1007AND id > 0FOR UPDATE;
这种写法并不能完全消除间隙锁,但可以帮助开发者更清楚地理解锁范围。
更推荐的做法是,在查询不到记录后,立即 INSERT,并保持事务尽量短。
在应用层引入分布式锁,保证相同订单号的请求串行处理。也可以设计一张幂等请求表,通过唯一请求号进行幂等控制。
这类方案适合跨服务、跨库场景,但会引入额外组件与复杂度,需要结合具体架构评估。
MySQL InnoDB 在可重复读隔离级别下,通过 Next-Key Lock 解决当前读场景的幻读问题。这种机制虽然有效,但在普通索引、间隙锁重叠以及多事务交叉插入时容易诱发死锁。
本文通过一个订单幂等性校验场景,展示了两个事务查询不存在的订单号后分别插入时,如何因为间隙锁互相等待而陷入死锁。
避免死锁的关键不在于完全消除锁,而在于:
理解锁的底层行为,才能在设计阶段做出更合理的决策,从源头减少高并发场景下的死锁风险。