一次真实的死锁排查

什么是死锁

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

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

总结

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

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

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

相关推荐
是小蟹呀^3 小时前
Spring Security + JWT 面试题整理
java·jwt·springsecurity
M158227690553 小时前
跨协议互通利器|SG-PN-EIP (S)-220 Profinet 转 EtherNet/IP 网关,打通欧美 PLC 通讯壁垒
网络·网络协议·tcp/ip
北龙云海4 小时前
案例分享 | 面向公网业务系统境内访问白名单配置实操方案
运维·网络·安全·网络安全·告警·数据泄露·入侵攻击
聆听。。花开雨落4 小时前
mybatis的typeHandler 作用
数据库·mybatis
pxzsky5 小时前
PG17数据库安装中分分词插件:pg_jieba
数据库·postgresql·pg_jieba
闲猫5 小时前
Spring AI对接Deepseek 使用Charles代理api.deepseek.com 拦截报文,查看Tool和Skill的本质
网络·网络协议·http
spencer_tseng5 小时前
Redis + Nacos.bat
java·windows·dos
她说可以呀5 小时前
Redis哨兵
数据库·redis·bootstrap
troyzhxu5 小时前
列表查询的 GraphQL —— 一行代码终结你的 if-else 地狱!
java·springboot·graphql