一次真实的死锁排查
📅 2026/7/5 2:50:47
👁️ 阅读次数
📝 编程学习
什么是死锁
死锁是指两个或多个事务互相持有对方所需的锁资源,形成循环等待,导致所有相关事务都无法继续执行的状态。
事务A: 持有资源1的锁 → 等待资源2的锁 事务B: 持有资源2的锁 → 等待资源1的锁死锁产生的四个必要条件
- 互斥条件— 资源同一时刻只能被一个事务持有
- 持有并等待— 事务持有已获得的锁,同时等待其他锁
- 不可剥夺— 已获得的锁不能被强制释放,只能由持有者主动释放
- 循环等待— 事务之间形成环形的锁等待链
四个条件同时满足,死锁才会发生。
常见死锁场景
1. 不同顺序访问多行记录
-- 事务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),导致不同事务锁住的范围产生重叠和冲突。
-- 表: 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)冲突
-- 表中 id 有 1, 5, 10 -- 事务A SELECT * FROM t WHERE id > 5 FOR UPDATE; -- 间隙锁 (5, +∞) -- 事务B INSERT INTO t (id) VALUES (7); -- 等待间隙锁4. 批量操作未排序
-- 事务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真实案例:优惠券批量核销死锁
问题背景
电商大促期间,用户下单时需要批量核销优惠券(标记为已使用)。高并发场景下,批量更新优惠券状态频繁出现死锁。
表结构简化如下:
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) );原始代码(有死锁风险)
<!-- 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>并发场景复现:
-- 事务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 加锁,锁范围重叠 → 死锁死锁原因分析
(user_id, coupon_id)是非唯一组合索引,不是主键- 通过非唯一索引定位行时,InnoDB 使用 Next-Key Lock,锁定范围比实际匹配行更大
- 并发请求中,不同事务的锁范围相互交叉,形成循环等待
- 每个事务内多个
(user_id, coupon_id)组合的加锁顺序不固定,进一步增大冲突概率
修复方案:改为主键更新
// 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); } }<!-- 第一步:查询主键 --> <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、不做耗时计算 | 减少持锁时间 |
代码层面
// 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;处理层面
// 死锁重试 @Retryable(value = DeadlockLoserDataAccessException.class, maxAttempts = 3) public void doBatchUpdate(...) { ... }排查工具
-- 查看死锁日志 SHOW ENGINE INNODB STATUS\G -- 查看当前锁等待 SELECT * FROM information_schema.INNODB_LOCK_WAITS; -- 查看当前事务 SELECT * FROM information_schema.INNODB_TRX;总结
| 阶段 | 关键动作 |
|---|---|
| 设计时 | 更新操作尽量走主键、统一加锁顺序 |
| 编码时 | 先查主键再更新、批量操作排序、设置超时 |
| 运行时 | 自动重试、监控告警、定期分析死锁日志 |
死锁不可能完全避免,核心思路是:降低发生概率 + 快速检测恢复。
本次案例的核心教训:批量更新时,非唯一索引条件会引入间隙锁,造成不可预测的锁范围。改为主键条件更新,让锁精确落在目标行上,是最直接有效的死锁修复手段。
编程学习
技术分享
实战经验