MySQL 间隙锁 (Gap Lock) 与 Next-Key Lock 导致的线上死锁分析实战
MySQL 间隙锁 (Gap Lock) 与 Next-Key Lock 导致的线上死锁分析实战
某日促销峰值期间,线上“优惠券发放与扣减”服务频繁触发报警,监控显示数据库出现大量 Deadlock found when trying to get lock; try restarting transaction (Error 1213) 异常,导致订单创建失败率飙升至 5%。
经过排查,该死锁并非发生在常规的“同一行记录交叉更新”场景,而是由非唯一索引上的 UPDATE 与 INSERT 混合并发触发。本文将通过真实的 SHOW ENGINE INNODB STATUS 日志,深入剖析 InnoDB 引擎中的 Gap Lock(间隙锁) 与 Next-Key Lock 作用机制,并给出可落地的重构方案。
一、问题背景与业务痛点
在优惠券业务中,存在如下逻辑:当用户领取优惠券时,系统需要更新当前用户已有的“待使用”优惠券状态(如锁定或作废),随后插入一条新的优惠券明细记录。
简化后的表结构定义如下(注意 idx_user_status 为非唯一复合索引):
CREATE TABLE `user_coupon` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` bigint NOT NULL COMMENT '用户ID',
`coupon_code` varchar(64) NOT NULL COMMENT '券码',
`status` tinyint NOT NULL DEFAULT '0' COMMENT '状态: 0-未激活, 1-可使用, 2-已锁定',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_status` (`user_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户优惠券表';
分析线上抓取到的日志片段:
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 2847591, ACTIVE 0 sec starting index read lock
MYSQL thread id 102, OS thread handle 13982312, query id 89201 updating
UPDATE user_coupon SET status = 2 WHERE user_id = 10086 AND status = 0
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 42 page no 5 n bits 72 index idx_user_status of table `shop`.`user_coupon`
trx id 2847591 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
*** (2) TRANSACTION:
TRANSACTION 2847592, ACTIVE 0 sec inserting
INSERT INTO user_coupon(user_id, coupon_code, status) VALUES (10086, 'CP_999', 0)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 42 page no 5 n bits 72 index idx_user_status of table `shop`.`user_coupon`
trx id 2847592 lock_mode X locks gap before rec
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 42 page no 5 n bits 72 index idx_user_status of table `shop`.`user_coupon`
trx id 2847592 lock_mode X locks gap before rec insert intention waiting
*** WE ROLL BACK TRANSACTION (1)
分析结论:两个事务在并发执行时,均持有 idx_user_status 上的 Gap Lock(间隙锁),随后同时尝试申请 Insert Intention Lock(插入意向锁)。由于间隙锁与插入意向锁互斥,导致双方互相等待对方释放间隙锁,形成了循环依赖,最终触发死锁回滚。
二、
▲ 权威参考图:MyBatis 核心执行链路与动态插件拦截器执行原理 (已转存博客园图床)
核心设计与解决思路
1. InnoDB 锁机制解密
在 MySQL 可重复读(Repeatable Read, 简称 RR)隔离级别下,InnoDB 默认采用 Next-Key Lock(临键锁)来进行范围查询和更新,以此解决幻读问题。
- Record Lock:行锁,仅锁定单个索引记录。
- Gap Lock:间隙锁,锁定索引记录之间的间隙,不包含记录本身(左开右开
(A, B))。 - Next-Key Lock:Record Lock + Gap Lock,锁定记录本身及其前面的间隙(左开右闭
(A, B])。 - Insert Intention Lock:插入意向锁,在
INSERT之前设置的一种特殊的 Gap 锁。多个事务插入同一个 Gap 时,只要插入的位置不重合,插入意向锁之间不会阻塞;但插入意向锁会被其他事务持有的 Gap Lock/Next-Key Lock 阻塞。
2. 系统分层与锁竞争拓扑图
下图展示了从业务网关到 InnoDB 锁引擎的调用链路,以及死锁发生的物理区域:
flowchart TD
Client[客户端并发请求] --> NettyGateway[API 网关]
NettyGateway --> BizService[优惠券业务服务 Cluster]
subgraph Spring Boot 业务逻辑
BizService --> Tx1[事务 1: 线程 A]
BizService --> Tx2[事务 2: 线程 B]
end
subgraph MySQL InnoDB 存储引擎 (RR 隔离级别)
Tx1 -->|1. UPDATE 申请 | Lock1[idx_user_status 间隙锁 Gap Lock]
Tx2 -->|2. UPDATE 申请 | Lock2[idx_user_status 间隙锁 Gap Lock]
Tx1 -->|3. INSERT 申请 | Wait2[等待 Tx2 释放 Gap Lock]
Tx2 -->|4. INSERT 申请 | Wait1[等待 Tx1 释放 Gap Lock]
Wait2 <-->|互斥等待: 死锁检测引擎检测到环路| Wait1
end
Wait1 -->|触发回滚| Rollback1[回滚 事务 1]
3. 端到端请求执行时序图
假设当前数据库中存在 user_id = 10086, status = 0 的记录(ID=10),以及下一条记录(ID=20)。
当事务 A 和事务 B 并发执行 UPDATE 与 INSERT 时,时间轴上的死锁形成过程如下:
▲ 时序图 2:端到端请求处理与调用时序链路
4. 解决方案技术选型对比
| 优化方案 | 技术原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 方案一:隔离级别降级 (RC) | 将 DB 隔离级别由 RR 调整为 Read Committed (RC) | 彻底消除 Gap Lock,极大地减少死锁概率 | 需保障 Binlog 为 ROW 格式,存在不可重复读现象 | 中大型业务通用,推荐 |
| 方案二:主键/唯一索引更新 | 先查出 Primary Key,再精准基于 WHERE id = ? 更新 |
将锁范围缩小为单个 Record Lock,不再锁定 Gap | 增加一次 DB 查询开销 | 业务改造成本低 |
| 方案三:Redis 分布式锁 | 将 user_id 维度的并发强行转化为串行执行 |
保证 DB 层绝无死锁 | 依赖外部缓存,增加系统复杂度和延迟 | 超高并发、写冲突极为频繁的场景 |
三、完整实战代码与配置
下面基于 Spring Boot 3.x + Java 17 + MyBatis-Plus,展示优化前后的代码及配置。
1. 产生死锁的错误示例代码
以下代码在并发时极易引发死锁:
// UserCouponServiceImpl.java (错误示范)
package com.example.coupon.service.impl;
import com.example.coupon.entity.UserCoupon;
import com.example.coupon.mapper.UserCouponMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class UserCouponServiceImpl {
private final UserCouponMapper userCouponMapper;
@Transactional(rollbackFor = Exception.class)
public void issueNewCouponVulnerable(Long userId, String newCouponCode) {
// 1. 基于非唯一索引批量更新(加 X 方向的 Next-Key Lock / Gap Lock)
userCouponMapper.updateStatusByUserIdAndStatus(userId, 0, 2);
// 2. 插入新记录(申请 Insert Intention Lock,触发死锁)
UserCoupon newCoupon = new UserCoupon();
newCoupon.setUserId(userId);
newCoupon.setCouponCode(newCouponCode);
newCoupon.setStatus(0);
userCouponMapper.insert(newCoupon);
}
}
2. 改造后的最佳实践代码
我们采用 “先查主键,再走主键更新 + Redis 锁控制” 的组合拳对代码进行重构。
UserCouponOptServiceImpl.java:
package com.example.coupon.service.impl;
import com.example.coupon.entity.UserCoupon;
import com.example.coupon.mapper.UserCouponMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserCouponOptServiceImpl {
private final UserCouponMapper userCouponMapper;
private final RedissonClient redissonClient;
/**
* 安全发放新优惠券,规避间隙锁死锁
*/
public void issueNewCouponSafe(Long userId, String newCouponCode) {
String lockKey = "lock:user:coupon:" + userId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 1. 业务加锁,将同一个 user_id 的并发写操作串行化
if (lock.tryLock(3, 5, TimeUnit.SECONDS)) {
try {
// 调用事务方法
executeIssueTransaction(userId, newCouponCode);
} finally {
lock.unlock();
}
} else {
throw new RuntimeException("系统繁忙,请稍后再试");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("加锁中断", e);
}
}
@Transactional(rollbackFor = Exception.class)
public void executeIssueTransaction(Long userId, String newCouponCode) {
// 2. 先查询匹配的主键列表(避免直接对非唯一索引范围加锁)
List<Long> needUpdateIds = userCouponMapper.selectList(
new LambdaQueryWrapper<UserCoupon>()
.select(UserCoupon::getId)
.eq(UserCoupon::getUserId, userId)
.eq(UserCoupon::getStatus, 0)
).stream().map(UserCoupon::getId).toList();
// 3. 根据主键精准更新,锁粒度降级为精确的 Record Lock (行锁)
if (!needUpdateIds.isEmpty()) {
userCouponMapper.updateStatusByIds(needUpdateIds, 2);
}
// 4. 执行插入操作,不再产生锁冲突
UserCoupon newCoupon = new UserCoupon();
newCoupon.setUserId(userId);
newCoupon.setCouponCode(newCouponCode);
newCoupon.setStatus(0);
userCouponMapper.insert(newCoupon);
}
}
3. XML 批量主键更新 SQL 配置
UserCouponMapper.xml:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.coupon.mapper.UserCouponMapper">
<!-- 精准基于主键 ID 批量更新,仅触发行锁(Record Lock) -->
<update id="updateStatusByIds">
UPDATE user_coupon
SET status = #{targetStatus}
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</update>
</mapper>
4. 数据库及应用配置信息
针对 MySQL 及 Spring Boot 隔离级别的调整如下:
bootstrap.yml:
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://127.0.0.1:3306/shop?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root_password
hikari:
maximum-pool-size: 20
minimum-idle: 5
# 推荐显式声明隔离级别为 READ_COMMITTED
transaction-isolation: TRANSACTION_READ_COMMITTED
logging:
level:
com.example.coupon.mapper: debug
MySQL 数据库全域配置文件建议:
my.cnf:
[mysqld]
# 隔离级别推荐设置为 RC,能够显著避免绝大多数 Gap Lock 死锁
transaction-isolation = READ-COMMITTED
# 必须使用 ROW 格式的 Binlog,确保主从同步的数据一致性
binlog_format = ROW
# 开启死锁检测 (默认开启)
innodb_deadlock_detect = ON
四、避坑指南与总结验证
1. 线上避坑指南
- 切忌使用“不存在的值”做带有锁定意图的条件更新:
在 RR 隔离级别下,若使用非唯一索引去SELECT ... FOR UPDATE或UPDATE一条不存在的记录,InnoDB 会锁定记录可能插入的整个“区间”。并发下极易触发死锁。 - 尽量使用主键(Primary Key)或唯一索引(Unique Index)更新:
若条件为主键,且记录存在,InnoDB 会将锁降级为 Record Lock(单行记录锁),不加 Gap Lock;若使用的是非唯一索引,无论记录是否存在,均会加 Gap Lock 或 Next-Key Lock。 - 隔离级别降级至 Read Committed (RC):
互联网高并发业务场景下,推荐设置transaction-isolation = READ-COMMITTED,配合binlog_format = ROW。RC 模式下无幻读保护的 Gap Lock,能消除 90% 以上的 InnoDB 死锁概率。 - 统一多表/多行资源的加锁顺序:
如果事务内部需要同时更新多行记录,必须在业务层先对 ID 等主键进行正序排序(List.sort()),再顺序执行更新,防止因交叉竞争导致的死锁。
2. 压测效果验证
我们使用 Apache JMeter 对改造前后的接口进行了 100 线程并发压测(每秒 2000 个请求):
- 改造前(RR 隔离级别 + 非唯一索引更新):
- TPS:~450
- Error Rate:4.8%(抛出大量的 Deadlock exception)
- 数据库 CPU 使用率:90%+(死锁检测线程频繁耗用 CPU 资源)
- 改造后(RC 隔离级别/Redis锁 + 主键更新):
- TPS:~1850
- Error Rate:0%
- 平均响应时间 (RT):从 180ms 降至 22ms,系统吞吐量提升约 4 倍。

本文深入剖析 MySQL InnoDB 引擎在 RR 隔离级别下,非唯一索引引发的 Gap Lock 与 Next-Key Lock 锁冲突导致的线上死锁问题。结合 SHOW ENGINE INNODB STATUS 死锁日志,手把手还原并发场景下的锁升级链路,并给出从数据库隔离级别调整到 Java 业务层改造的完整落地方案。
浙公网安备 33010602011771号