当前位置: 首页 > news >正文

一次真实的死锁排查

什么是死锁

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

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

死锁产生的四个必要条件

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

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

常见死锁场景

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 加锁,锁范围重叠 → 死锁

死锁原因分析

  1. (user_id, coupon_id)非唯一组合索引,不是主键
  2. 通过非唯一索引定位行时,InnoDB 使用 Next-Key Lock,锁定范围比实际匹配行更大
  3. 并发请求中,不同事务的锁范围相互交叉,形成循环等待
  4. 每个事务内多个(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;

总结

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

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

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

http://www.jsqmd.com/news/1125556/

相关文章:

  • 当我们在浏览器里点开一把小锁:SSL/TLS是怎么保护我们的
  • AI agent求职党必看:48小时笔试题多Agent怎么破
  • 【OpenCloudOS、CubeSandbox安装体验】
  • 去中心化 AI 计费:链上结算前先解决用量可信
  • DeepSeek接入指南:从零到一,轻松集成AI编程助手
  • 【Wox】实现快捷键 自动读取剪贴板内容触发翻译\配合AHK实现快速查词
  • AI 科普组件:复杂概念要给读者台阶
  • 官方表态PDC and Silverlight [原文]
  • 精馏塔背压波动总坏泄压件?ZOOK爆破片分材质选型方案
  • MagicWorld 实现长时交互视频世界建模
  • 西门子S7-1200 PLC轴运动控制配置与优化指南
  • Ghostunnel:给后端服务加一层 TLS 代理
  • 2026华为OD面试题001:两个字符串间的最短路径问题
  • 防止对话上下文腐败(Context Corruption)的策略
  • 泡沫的是估值与投机,不是技术本身:不要天天看,而是了解行业,消除噪音报价
  • 数据指标 SLA:报表准时不代表指标可信
  • 老鸟对菜鸟的一些建议
  • JSM2300 20V/6A N 沟道功率 MOSFET
  • 操作系统死锁避免核心:银行家算法超详细图解+实战案例
  • 告别技术空谈:九尾狐AI发布2026年最新企业AI培训体系,主推‘战略到变现‘全周期陪跑模式
  • Scikit-learn 1.5.0 心脏病预测实战:5种分类算法调参与模型融合策略
  • 若依系统登录密码RSA加密实战:jsencrypt前端加密与Spring Boot后端解密
  • web第十、十一次作业
  • AI上台模特AI特效全面探索,服饰行业高效换装实测对比
  • 智慧滑坡监测数据集构建与YOLO模型训练指南
  • 打破显存瓶颈TESHY 活体架构与全维异步管道的端侧革命从静态文件到呼吸生命
  • 探索虚幻引擎游戏资产的终极利器:FModel深度解析与实战指南
  • 企业微信二次开发中的文件系统设计:媒体资源、临时文件与业务附件
  • 从零到一:使用OWASP ZAP对DVWA进行自动化安全扫描实战
  • 从零构建AI Agent:基于LangChain的智能数据查询助手实战