一次真实的死锁排查

什么是死锁

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

复制代码
事务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;

总结

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

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

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

相关推荐
小白勇闯网安圈9 小时前
Django 响应对象、文件上传与类视图
数据库·django·sqlite
努力的小雨10 小时前
同一份 SQL 跑多套环境:Ksql 变量怎么用才安全
数据库
嘻哈∠※10 小时前
0061基于 SpringBoot 的投稿与稿件处理系统设计与实现
java·spring boot·后端
科技绘图10 小时前
快鲸GEO vs 传统AI搜索优化:全链路自动化与高效内容生产在转化闭环上的对比
数据库·人工智能·自动化
ltl11 小时前
数据库作为 LLM 记忆体:语义缓存、RAG 与一致性
数据库
ltl11 小时前
WAL 与崩溃恢复:ARIES 协议怎么跑通
数据库
ltl11 小时前
TEE 数据库:EnclaveDB、Oblivious 原语与机密 SQL
数据库
上海安当技术11 小时前
固件签名与Secure Boot:安当CAS如何构建汽车固件信任链
网络·汽车·信任链·boot·secure·固件签名·安当cas
白狐_79811 小时前
408数据结构第8章:排序②——性质对比秒杀、场景选择与外部排序
java·数据结构·算法
zander25811 小时前
LeetCode 84:柱状图中的最大矩形——单调栈如何确定左右边界
java·数据结构·算法