一次真实的死锁排查

什么是死锁

死锁是指两个或多个事务互相持有对方所需的锁资源,形成循环等待,导致所有相关事务都无法继续执行的状态。

复制代码
事务A: 持有资源1的锁 → 等待资源2的锁
事务B: 持有资源2的锁 → 等待资源1的锁

死锁产生的四个必要条件

  1. 互斥条件 --- 资源同一时刻只能被一个事务持有
  2. 持有并等待 --- 事务持有已获得的锁,同时等待其他锁
  3. 不可剥夺 --- 已获得的锁不能被强制释放,只能由持有者主动释放
  4. 循环等待 --- 事务之间形成环形的锁等待链

四个条件同时满足,死锁才会发生。

常见死锁场景

1. 不同顺序访问多行记录

sql 复制代码
-- 事务A
UPDATE account SET balance = balance - 100 WHERE id = 1;  -- 锁住 id=1
UPDATE account SET balance = balance + 100 WHERE id = 2;  -- 等待 id=2

-- 事务B
UPDATE account SET balance = balance - 50 WHERE id = 2;   -- 锁住 id=2
UPDATE account SET balance = balance + 50 WHERE id = 1;   -- 等待 id=1 → 死锁

2. 非唯一索引/组合条件导致的锁范围不确定

使用非唯一索引作为 WHERE 条件时,InnoDB 的加锁行为不像主键那样精确定位单行,可能涉及间隙锁(Gap Lock)临键锁(Next-Key Lock),导致不同事务锁住的范围产生重叠和冲突。

sql 复制代码
-- 表: user_coupon,有 idx_user_coupon(user_id, coupon_id) 非唯一索引

-- 事务A: 核销用户100的优惠券
UPDATE user_coupon SET status = 1
WHERE (user_id, coupon_id) IN ((100, 201), (100, 202));

-- 事务B: 过期用户100的优惠券
UPDATE user_coupon SET status = 2
WHERE (user_id, coupon_id) IN ((100, 202), (100, 203));

在非唯一索引上,InnoDB 会对索引记录及其间隙加锁。两个事务的锁范围存在交叉时,就可能产生死锁。

3. 间隙锁(Gap Lock)冲突

sql 复制代码
-- 表中 id 有 1, 5, 10
-- 事务A
SELECT * FROM t WHERE id > 5 FOR UPDATE;  -- 间隙锁 (5, +∞)

-- 事务B
INSERT INTO t (id) VALUES (7);            -- 等待间隙锁

4. 批量操作未排序

sql 复制代码
-- 事务A: UPDATE t SET ... WHERE id IN (1, 2, 3)  加锁顺序 1→2→3
-- 事务B: UPDATE t SET ... WHERE id IN (3, 2, 1)  加锁顺序 3→2→1

真实案例:优惠券批量核销死锁

问题背景

电商大促期间,用户下单时需要批量核销优惠券(标记为已使用)。高并发场景下,批量更新优惠券状态频繁出现死锁。

表结构简化如下:

sql 复制代码
CREATE TABLE user_coupon (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id INT NOT NULL,
    coupon_id INT NOT NULL,
    status TINYINT DEFAULT 0 COMMENT '0-未使用 1-已使用 2-已过期',
    update_time INT,
    INDEX idx_user_coupon (user_id, coupon_id)
);

原始代码(有死锁风险)

xml 复制代码
<!-- MyBatis Mapper:通过 user_id + coupon_id 组合条件批量更新 -->
<update id="batchUseCoupons">
    UPDATE user_coupon
    SET status = #{status}, update_time = #{updateTime}
    WHERE (user_id, coupon_id) IN
    <foreach collection="pairs" item="pair" open="(" separator="," close=")">
        (#{pair.userId}, #{pair.couponId})
    </foreach>
</update>

并发场景复现:

sql 复制代码
-- 事务A:用户下单,核销优惠券 (user_id=100, coupon_id=201), (user_id=100, coupon_id=202)
UPDATE user_coupon SET status = 1 WHERE (user_id, coupon_id) IN ((100,201),(100,202));

-- 事务B:后台定时任务,过期同一用户的优惠券 (user_id=100, coupon_id=202), (user_id=100, coupon_id=203)
UPDATE user_coupon SET status = 2 WHERE (user_id, coupon_id) IN ((100,202),(100,203));

-- 两个事务通过非唯一索引 idx_user_coupon 加锁,锁范围重叠 → 死锁

死锁原因分析

  1. (user_id, coupon_id)非唯一组合索引,不是主键
  2. 通过非唯一索引定位行时,InnoDB 使用 Next-Key Lock,锁定范围比实际匹配行更大
  3. 并发请求中,不同事务的锁范围相互交叉,形成循环等待
  4. 每个事务内多个 (user_id, coupon_id) 组合的加锁顺序不固定,进一步增大冲突概率

修复方案:改为主键更新

java 复制代码
// Service 层:先查主键,再按主键更新
public void batchUseCoupons(List<UserCouponPair> pairs, int status) {
    int updateTime = DateUtil.currentSecond();
    // 第一步:通过业务条件查出主键列表
    List<Long> ids = couponDao.getIdsByUserAndCoupon(pairs);
    if (ids != null && !ids.isEmpty()) {
        // 第二步:按主键批量更新,加锁精确到行
        couponDao.batchUpdateStatusByIds(ids, status, updateTime);
    }
}
xml 复制代码
<!-- 第一步:查询主键 -->
<select id="getIdsByUserAndCoupon" resultType="java.lang.Long">
    SELECT id FROM user_coupon
    WHERE (user_id, coupon_id) IN
    <foreach collection="pairs" item="pair" open="(" separator="," close=")">
        (#{pair.userId}, #{pair.couponId})
    </foreach>
</select>

<!-- 第二步:按主键更新,锁范围精确 -->
<update id="batchUpdateStatusByIds">
    UPDATE user_coupon
    SET status = #{status}, update_time = #{updateTime}
    WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</update>

为什么有效

对比项 修复前 修复后
WHERE 条件 非唯一组合索引 (user_id, coupon_id) 主键 id
锁类型 Next-Key Lock(行+间隙) Record Lock(仅行锁)
锁范围 可能锁住多行及间隙 精确锁住目标行
并发冲突 锁范围重叠导致死锁 锁不重叠,无死锁

核心原理:通过主键(唯一索引)定位行时,InnoDB 只加行锁(Record Lock),不需要间隙锁,锁的范围最小且确定,从根本上消除了锁交叉的可能性。

通用解决方案总结

预防层面

策略 做法 原理
用主键更新 先查主键,再按主键批量更新 消除间隙锁,精确加行锁
固定加锁顺序 按 id 升序排列后再操作 破坏循环等待
缩小锁粒度 只锁必要的行 减少冲突范围
缩短事务时间 事务中不做 RPC、不做耗时计算 减少持锁时间

代码层面

java 复制代码
// 1. 批量操作前排序
List<Long> ids = getTargetIds();
Collections.sort(ids);
for (Long id : ids) {
    updateById(id);
}

// 2. 乐观锁代替悲观锁
UPDATE account SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = #{oldVersion};

// 3. 合理的锁等待超时
SET innodb_lock_wait_timeout = 5;

处理层面

java 复制代码
// 死锁重试
@Retryable(value = DeadlockLoserDataAccessException.class, maxAttempts = 3)
public void doBatchUpdate(...) { ... }

排查工具

sql 复制代码
-- 查看死锁日志
SHOW ENGINE INNODB STATUS\G

-- 查看当前锁等待
SELECT * FROM information_schema.INNODB_LOCK_WAITS;

-- 查看当前事务
SELECT * FROM information_schema.INNODB_TRX;

总结

阶段 关键动作
设计时 更新操作尽量走主键、统一加锁顺序
编码时 先查主键再更新、批量操作排序、设置超时
运行时 自动重试、监控告警、定期分析死锁日志

死锁不可能完全避免,核心思路是:降低发生概率 + 快速检测恢复

本次案例的核心教训:批量更新时,非唯一索引条件会引入间隙锁,造成不可预测的锁范围。改为主键条件更新,让锁精确落在目标行上,是最直接有效的死锁修复手段。

相关推荐
硕风和炜2 小时前
【LeetCode: 2492. 两个城市间路径的最小分数 + DFS】
java·算法·leetcode·深度优先·dfs·bfs·并查集
格子软件2 小时前
2026年GEO贴牌代理:分布式多级分账状态机源码深度解构
java·vue.js·分布式·vue·geo
画中有画3 小时前
论向量数据库在项目中的应用
数据库
spider_xcxc3 小时前
Redis 数据库高质量实践指南(一)
运维·数据库·redis·oracle·云计算
我是一颗柠檬3 小时前
【Java项目技术亮点】加权轮询负载均衡算法
java·算法·负载均衡
灯厂码农3 小时前
C语言动态内存分配完全指南(malloc、calloc、realloc、free)
java·c语言·算法
l1t4 小时前
在linux和windows中解决duckdb 1.6dev版本输出执行计划报错问题
linux·运维·数据库·windows·duckdb
执子手 吹散苍茫茫烟波4 小时前
RC 隔离级别下 MySQL InnoDB 死锁典型案例
数据库·mysql
HavenlonLabs4 小时前
Havenlon 对抗性完整(十七):安全不是“防住攻击”,而是控制失败方式
网络·人工智能·架构·安全威胁分析·安全架构·havenlon
梦梦代码精4 小时前
电商系统不是技术堆叠:LikeShop如何用分层Hold住复杂业务?
java·docker·代码规范