简介:本文从一个真实的订单幂等校验场景切入,系统剖析InnoDB在可重复读隔离级别下死锁的形成机理,重点讲解间隙锁与插入意向锁的冲突过程,并给出降低死锁概率的工程实践建议,帮助开发者在高并发业务中写出更稳健的数据库代码。
在交易类系统中,订单的幂等性校验几乎是绕不开的一环。很多团队为了避免重复下单,会在写入之前先做一次“存在性判断”,确认不存在后再插入。这个思路本身没有问题,但在高并发场景下,如果实现方式不够谨慎,就可能把一次普通的写入操作演变成数据库死锁。本文从一个订单幂等校验的案例出发,分析死锁产生的完整链路,并给出可落地的规避策略。
假设有一张订单表,结构如下:
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 是普通二级索引,并非唯一索引。表中已经有 6 条记录,order_no 分别为 1001 到 1006。
业务逻辑要求订单号不能重复,因此新增订单前先执行:
SELECT * FROM t_order WHERE order_no = ? FOR UPDATE;
如果查询不到记录,再执行 INSERT。这条 SELECT ... FOR UPDATE 的目的是加锁,防止在判断与插入之间,有其他事务插入了相同订单号,从而破坏幂等性。
表面上看,这个逻辑是自洽的。但当两个事务同时插入 1007 和 1008 时,问题就出现了:两个事务都查不到记录,都认为自己应该插入,最终却互相等待对方释放锁,形成死锁。
要理解死锁,必须先理解 InnoDB 在可重复读隔离级别下的加锁规则。这里涉及两类关键锁:
假设当前表中 order_no 索引上的记录为 1001、1002、1003、1004、1005、1006。
事务 A 执行:
SELECT * FROM t_order WHERE order_no = 1007 FOR UPDATE;
由于 1007 不存在,InnoDB 会在 order_no 索引上定位到大于 1007 的第一个记录,也就是 supremum 伪记录之前的区间,并对这个区间加上间隙锁。此时事务 A 持有了 (1006, +∞) 这个间隙的锁。
事务 B 执行:
SELECT * FROM t_order WHERE order_no = 1008 FOR UPDATE;
1008 同样不存在,事务 B 也需要对 (1006, +∞) 区间加间隙锁。间隙锁之间并不互斥,因此事务 B 也能成功加上间隙锁。此时两个事务都持有同一区间的间隙锁,看起来相安无事。
接下来,事务 A 执行插入:
INSERT INTO t_order (order_no, create_date) VALUES (1007, NOW());
插入操作需要申请插入意向锁。但 (1006, +∞) 区间上已经有事务 B 持有的间隙锁,插入意向锁与间隙锁冲突,因此事务 A 被阻塞,进入等待状态。
事务 B 随后执行插入:
INSERT INTO t_order (order_no, create_date) VALUES (1008, NOW());
同样地,事务 B 需要申请插入意向锁,而该区间上有事务 A 持有的间隙锁,于是事务 B 也被阻塞。
此时,事务 A 等待事务 B 释放间隙锁,事务 B 等待事务 A 释放间隙锁,双方形成循环等待,死锁正式产生。如果没有开启死锁检测,或者检测机制未能及时介入,两个事务就会一直挂起。
有读者可能会想:既然 FOR UPDATE 会加间隙锁,那改成普通 SELECT 是不是就没事了?
普通 SELECT 在可重复读隔离级别下确实不加锁,但它也无法阻止其他事务插入。如果两个请求携带相同的订单号同时进入,都执行普通 SELECT,都查不到记录,然后都执行 INSERT,最终就会产生两条重复订单。这就是典型的幻读问题,幂等性校验也就形同虚设。
所以问题的根源不在于“要不要加锁”,而在于“加锁的方式是否与业务语义匹配”。用普通二级索引做存在性判断,再依赖间隙锁来防重,本身就是一种脆弱的方案。
最直接、最可靠的方案是给 order_no 建立唯一索引,然后直接插入,依赖数据库的唯一约束来保证幂等:
ALTER TABLE t_order ADD UNIQUE INDEX uk_order_no (order_no);
插入时捕获唯一键冲突异常,转为业务上的“订单已存在”处理。这样既不需要间隙锁,也不会产生死锁,还能把并发控制交给数据库的唯一性约束,语义更加清晰。
如果业务确实需要先查询再插入,可以考虑:
需要注意的是,RC 下虽然间隙锁被关闭,但唯一性校验仍然需要唯一索引兜底,否则依然可能出现重复数据。
如果业务中不可避免要加多把锁,应确保所有事务按照相同的顺序访问资源。例如,先按订单号排序,再依次加锁,避免交叉等待。统一的加锁顺序是打破循环等待条件的经典手段。
InnoDB 默认开启死锁检测,一旦发现循环等待,会主动回滚代价较小的事务。应用层应当捕获死锁异常并做重试。同时可以设置 innodb_lock_wait_timeout,避免事务长时间挂起。对于高并发写入场景,还可以考虑关闭死锁检测以降低检测开销,但必须配合应用层的重试机制。
死锁往往发生在事务持有锁的时间过长时。应避免在事务中执行远程调用、文件操作、复杂计算等耗时逻辑。事务应只包含必要的数据库操作,并在操作完成后尽快提交。
对于订单号这类业务唯一键,可以在应用层使用分布式锁或乐观锁做前置控制,减少数据库层面的并发冲突。数据库唯一索引作为最终兜底,形成多层防护。
MySQL 死锁并不是随机发生的,它通常源于加锁顺序不一致、锁范围过大、事务持有锁时间过长等问题。本文案例中的死锁,本质上是普通二级索引上的间隙锁与插入意向锁相互冲突,加上两个事务交叉等待造成的。
规避死锁的核心思路可以归纳为三点:能用唯一索引就不用间隙锁,能缩小锁范围就不扩大锁范围,能缩短事务就不延长事务。在此基础上,配合统一的加锁顺序、合理的超时设置和应用层重试机制,就能在大多数高并发场景下有效降低死锁发生的概率。
数据库锁机制是工具,不是目的。理解业务语义,选择与语义匹配的并发控制手段,才是写出稳健代码的关键。