凌晨 2 点 47 分,告警群里弹出 5 条 MySQL 死锁日志。我从床上爬起来,打开电脑那一刻还想着「不就是死锁吗,Kill 个事务就行」。结果排查到天亮才发现:这不是简单的 A → B、B → A 那种教科书式死锁,是一个让我重新审视 RR 隔离级别 + 间隙锁的真实案例。
本文把当晚的全部过程复盘出来 —— SQL 是什么、锁是怎么加上去的、为什么我们一开始的修法都不对、最后怎么改才稳定。看完你会明白:RR 模式下,INSERT ... ON DUPLICATE KEY UPDATE 在 高并发下是个隐形炸弹。
故障现场
我们的订单服务用 MySQL 8.0,RR 隔离级别(InnoDB 默认)。有个接口:用户领取优惠券,做两件事:
- 往
coupon_grant_log表写一条领取记录(防重复领) - 更新
coupon_stock表的库存(扣 1)
简化后的核心 SQL 类似这样:
表结构如下:
CREATE TABLE coupon_grant_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
coupon_id BIGINT NOT NULL,
granted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_coupon (user_id, coupon_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE coupon_stock (
id BIGINT PRIMARY KEY,
qty INT NOT NULL,
version INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
业务代码也简单(伪 Java,看清逻辑就行,真实代码语言不重要):
@Transactional(rollbackFor = Exception.class)
public void grantCoupon(long userId, long couponId) {
// 1. 防重复领:INSERT ... ON DUPLICATE KEY UPDATE
String sql1 = "INSERT INTO coupon_grant_log (user_id, coupon_id) " +
"VALUES (?, ?) " +
"ON DUPLICATE KEY UPDATE granted_at = NOW()";
jdbc.update(sql1, userId, couponId);
// 2. 扣库存
String sql2 = "UPDATE coupon_stock SET qty = qty - 1 WHERE id = ? AND qty > 0";
int rows = jdbc.update(sql2, couponId);
if (rows == 0) {
throw new BusinessException("out_of_stock");
}
}
跑了一年没问题。直到这次大促,一秒涌进来 800 个用户领同一张券,死锁开始疯狂报。
第一反应:看死锁日志
排查死锁,第一步永远是 SHOW ENGINE INNODB STATUS,翻到 LATEST DETECTED DEADLOCK 段:
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-11-11 02:47:33 0x7f88a8...
*** (1) TRANSACTION:
TRANSACTION 24891672, ACTIVE 0.014 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1136, 3 row lock(s)
MySQL thread id 28912, OS thread handle 0x7f88...
INSERT INTO coupon_grant_log (user_id, coupon_id) VALUES (1001, 88)
ON DUPLICATE KEY UPDATE granted_at = NOW()
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 142 page no 17 n bits 80 index uk_user_coupon
of table `mall`.`coupon_grant_log` trx id 24891672 lock_mode X locks gap before rec insert intention waiting
*** (2) TRANSACTION:
TRANSACTION 24891675, ACTIVE 0.011 sec inserting
mysql tables in use 1, locked 1
4 lock struct(s), heap size 1136, 2 row lock(s)
INSERT INTO coupon_grant_log (user_id, coupon_id) VALUES (1002, 88)
ON DUPLICATE KEY UPDATE granted_at = NOW()
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 142 page no 17 n bits 80 index uk_user_coupon
of table `mall`.`coupon_grant_log` trx id 24891675 lock_mode X
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 142 page no 17 n bits 80 index uk_user_coupon
of table `mall`.`coupon_grant_log` trx id 24891675 lock_mode X locks gap before rec insert intention waiting
*** WE ROLL BACK TRANSACTION (1)
这段日志的关键信息:
- 两个事务都在 INSERT(user_id=1001 和 1002,coupon_id 都是 88)
- 等的都是
uk_user_coupon这个唯一索引 - 锁的类型是
X locks gap before rec insert intention—— 意向插入锁 + 间隙锁 - MySQL 自动回滚了事务 1
看到 gap before rec insert intention 我才意识到:这不是简单的行锁冲突,这是 RR 模式 + 唯一索引 + INSERT ON DUPLICATE 三件套引起的间隙锁死锁。
原理:RR 模式下 INSERT 怎么加锁
RR 隔离级别下,InnoDB 为了防止幻读,在唯一索引上 INSERT 时会加这几种锁(简化版):
- S 锁 / X 锁:具体的索引行
- Gap Lock:索引行之间的"间隙"
- Insert Intention Lock:插入意向锁,要插入新行时申请
当两个事务都要 INSERT 一个 不存在的、相同范围 的索引值时,会发生这样的事:
- T1 INSERT (1001, 88):查 uk_user_coupon 没有这行,先在它"该出现的位置"加 gap lock(防止其他人插进来)
- T2 INSERT (1002, 88):它的 (1002, 88) 在 uk_user_coupon 索引顺序上,也落在 T1 的 gap lock 范围里 —— 它要申请 insert intention lock,被阻塞
- T1 接着 UPDATE coupon_stock,T1 想拿 coupon_stock 主键的 X 锁 —— 假如这个时候 T2 也走到同样位置,两个事务互相等对方的锁,死锁出现
查看具体锁信息可以用 performance_schema:
-- 查看当前所有锁(MySQL 8.0+)
SELECT
OBJECT_NAME,
INDEX_NAME,
LOCK_TYPE,
LOCK_MODE,
LOCK_STATUS,
LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA = 'mall';
-- 查看锁等待
SELECT
waiting_trx_id, waiting_pid, waiting_query,
blocking_trx_id, blocking_pid, blocking_query
FROM sys.innodb_lock_waits;
跑了一下,确实看到大量事务卡在 gap before rec insert intention 状态。
第一次修复:把 INSERT ON DUPLICATE 换成 SELECT + INSERT
当时我想:既然 ON DUPLICATE KEY UPDATE 在唯一索引上加 gap lock 这么坑,那就拆成两步,先查再插:
@Transactional(rollbackFor = Exception.class)
public void grantCoupon(long userId, long couponId) {
// 1. 先查是否已领
Integer count = jdbc.queryForObject(
"SELECT COUNT(*) FROM coupon_grant_log WHERE user_id = ? AND coupon_id = ?",
Integer.class, userId, couponId);
if (count > 0) {
throw new BusinessException("already_granted");
}
// 2. 插入
jdbc.update("INSERT INTO coupon_grant_log (user_id, coupon_id) VALUES (?, ?)",
userId, couponId);
// 3. 扣库存
int rows = jdbc.update(
"UPDATE coupon_stock SET qty = qty - 1 WHERE id = ? AND qty > 0", couponId);
if (rows == 0) throw new BusinessException("out_of_stock");
}
结果上线后 10 分钟就出问题 —— 同一个用户领了 2 张。原因显而易见:这是经典的 check-then-act 竞态,两个事务都查到 count=0,都 INSERT,因为唯一索引会拦住第二个,导致一个事务报 Duplicate entry 异常,但业务以为只是普通失败重试,数据已经写进去了。
这一次修复完全错方向。回滚。
第二次修复:Spring 重试 + 减小事务范围
有人提议:既然 ON DUPLICATE KEY UPDATE 在唯一索引下会死锁,让框架捕获死锁后自动重试就行,反正这两个操作是幂等的。配合事务粒度收紧:
@Retryable(
value = {DeadlockLoserDataAccessException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2, random = true)
)
public void grantCoupon(long userId, long couponId) {
// 第 1 个事务:只做防重复领
grantTx.recordGrant(userId, couponId);
// 第 2 个事务:只做扣库存
// 失败的话补偿:删掉 grant log
try {
stockTx.deduct(couponId);
} catch (Exception e) {
grantTx.revoke(userId, couponId);
throw e;
}
}
这套方案确实把死锁告警降下来了 —— 从 200 条/分钟降到 5 条/分钟。但 5 条还是有,而且偶尔补偿失败会让 grant log 和 stock 不一致。问题没根除,只是缓解。
第三次修复:换成 SELECT ... FOR UPDATE + INSERT IGNORE
翻 MySQL 文档,找到一段关键描述:"INSERT IGNORE" or "INSERT ... ON DUPLICATE KEY UPDATE" on unique key 在 RR 模式下都会加 gap lock,但 "INSERT IGNORE" 在冲突时只是跳过,不会触发 UPDATE,锁的持有时间更短。
同时,把扣库存先用 SELECT ... FOR UPDATE 锁住唯一一行 —— 这样所有领同一张券的事务都会排队走过 coupon_stock 这把锁,避免在 uk_user_coupon 上互相 gap lock。
@Transactional(isolation = Isolation.READ_COMMITTED) // 注意:换成 RC
public void grantCoupon(long userId, long couponId) {
// 1. 先锁库存行(RC 模式下只锁这一行,不加间隙锁)
Integer stock = jdbc.queryForObject(
"SELECT qty FROM coupon_stock WHERE id = ? FOR UPDATE",
Integer.class, couponId);
if (stock == null || stock <= 0) {
throw new BusinessException("out_of_stock");
}
// 2. INSERT IGNORE 防重复领
int inserted = jdbc.update(
"INSERT IGNORE INTO coupon_grant_log (user_id, coupon_id) VALUES (?, ?)",
userId, couponId);
if (inserted == 0) {
throw new BusinessException("already_granted");
}
// 3. 扣库存
jdbc.update("UPDATE coupon_stock SET qty = qty - 1 WHERE id = ?", couponId);
}
关键变化两条:
- 事务隔离级别改成 READ_COMMITTED:RC 模式下 InnoDB 默认不加 gap lock,行锁只锁存在的行,大大减少锁冲突
- 用 coupon_stock 的行锁做"串行点":所有要领 88 号券的事务都必须先拿到这一行的 X 锁,自然不会再在 coupon_grant_log 上互相阻塞
上线后死锁直接归零,接口 P99 从 380ms 降到 60ms。
验证:基准测试
为了证明 RC + FOR UPDATE 真的解决了问题,我们用 sysbench 压测:
# 测试脚本(简化版):2000 个用户并发领同一张库存 5000 的券
sysbench oltp_custom \
--mysql-host=127.0.0.1 \
--mysql-port=3306 \
--mysql-user=test \
--mysql-password=*** \
--mysql-db=mall \
--threads=2000 \
--time=60 \
--custom-sql='CALL grant_coupon(@user_id, 88)' \
run
# 结果对比:
# 原版 (RR + ON DUP) 修复后 (RC + FOR UPDATE)
# 成功 QPS 340 1850
# 死锁次数 4216 0
# 平均延迟 582ms 41ms
# P99 延迟 3.4s 98ms
数据非常清晰。把这份对比贴到事故复盘文档里,leader 才肯签字关闭这个 P0。
真正学到的 5 件事
- RR 模式的 gap lock 不是教科书概念,是生产事故的常客。它和唯一索引、INSERT ON DUPLICATE 组合起来威力倍增。
- "先 SELECT 再 INSERT" 永远是错的。除非加了 FOR UPDATE 锁,不然 check-then-act 必然有竞态。
- RC 模式在 OLTP 业务里更香。除非业务明确需要"事务内多次查询结果一致",否则 RC 比 RR 性能高、锁冲突少。
- "用某一行做串行点"是分布式协调的常用招。Redis 分布式锁、Zookeeper 临时节点本质上都是这思路。
- 排查死锁的标准动作:看 LATEST DETECTED DEADLOCK → 看 performance_schema.data_locks → 看 sys.innodb_lock_waits → 复现 → 修复 → 压测验证。
这场事故的代价是大促当晚有 700+ 用户领券失败,客服群里炸了 2 小时。但从结果看,我们对 InnoDB 锁的理解上了一个台阶,后续做秒杀、抢单这类业务时再也没踩过同类坑。
附:排查死锁的 5 行救命 SQL
-- 1. 看最近一次死锁
SHOW ENGINE INNODB STATUS\G
-- 2. 看当前锁等待
SELECT * FROM sys.innodb_lock_waits;
-- 3. 看具体锁
SELECT object_name, index_name, lock_type, lock_mode, lock_data
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA = 'your_db';
-- 4. 看正在跑的事务
SELECT * FROM information_schema.innodb_trx ORDER BY trx_started;
-- 5. 杀掉具体事务(慎用)
KILL <trx_mysql_thread_id>;
把这 5 行存到 IDE snippets 里。下次再被半夜叫起来,5 分钟就能定位问题,而不是 5 小时。
—— 别看了 · 2026