MySQL 库存扣减为什么总死锁,问题往往不在那条 Update 语句
MySQL 库存扣减为什么总死锁,问题往往不在那条 Update 语句
很多人第一次遇到库存死锁,都会盯着这句 SQL 看半天:
UPDATE inventory
SET available = available - 1
WHERE sku_id = 1001 AND available > 0;
然后陷入一个误区:语句这么简单,为什么还能死锁?
真实情况是,库存扣减的死锁,往往不是单条 UPDATE 本身复杂,而是业务事务里同时锁了太多对象,而且加锁顺序不一致。
一个真实场景
我们在一个电商系统里做预售库存扣减,事务里要做几件事:
- 校验活动状态
- 扣减商品库存
- 写订单明细
- 更新用户限购记录
事务伪代码是这样的:
BEGIN;
SELECT * FROM promotion WHERE id = ? FOR UPDATE;
SELECT * FROM inventory WHERE sku_id = ? FOR UPDATE;
SELECT * FROM user_quota WHERE user_id = ? AND sku_id = ? FOR UPDATE;
UPDATE inventory SET available = available - ? WHERE sku_id = ?;
UPDATE user_quota SET used = used + ? WHERE user_id = ? AND sku_id = ?;
INSERT INTO order_item (...);
COMMIT;
一开始压测没问题,上线后秒杀场景下开始大量出现:
Deadlock found when trying to get lock; try restarting transaction
问题不是 MySQL “扛不住高并发”,而是事务的锁顺序在不同入口里不一致。
为什么会死锁
典型死锁链路一般长这样:
- 事务 A 先锁库存,再锁用户限购
- 事务 B 先锁用户限购,再锁库存
于是:
- A 拿着库存锁等限购锁
- B 拿着限购锁等库存锁
这就是最经典的环路等待。
很多团队会以为“我都用了主键查询,怎么还会死锁”。主键查询只能减少锁范围,不能保证不会形成锁环。
另一个常见原因:范围查询引入 next-key lock
如果库存逻辑里还带有范围判断,比如:
SELECT * FROM inventory_log
WHERE sku_id = ? AND created_at >= ?
FOR UPDATE;
在 REPEATABLE READ 下,InnoDB 可能加的不只是记录锁,还会带 gap lock / next-key lock。这样一来,冲突面会比你以为的大很多。
尤其是:
- 没走合适索引
- 条件不是唯一等值
- 范围查询放在事务里
这些都会把“本来只锁一行”的事务,扩大成“锁一片区间”。
我是怎么定位的
死锁问题别靠猜,先看现场。
最有用的命令还是:
SHOW ENGINE INNODB STATUS;
重点看两块:
LATEST DETECTED DEADLOCK- 每个事务持有和等待的锁
你需要回答三个问题:
- 两个事务分别执行了哪些 SQL
- 它们各自已经持有什么锁
- 它们正在等待什么锁
如果线上死锁频率高,建议把相关 SQL 模板、业务参数、事务耗时一起打日志,不然只看数据库层信息不够还原业务链路。
真正有效的治理方式
1. 统一事务内的加锁顺序
这是最有效的一条,没有之一。
只要多个事务可能同时修改相同资源,就必须保证获取锁的顺序一致。
比如统一规定:
- 先锁活动
- 再锁库存
- 最后锁用户限购
即使代码入口不同,也必须遵守同一顺序。
我后来专门把这段收敛成一个领域服务,禁止业务方自己拼事务。
@Transactional
public void reserve(Long promotionId, Long skuId, Long userId, int quantity) {
Promotion promotion = promotionRepo.lockById(promotionId);
Inventory inventory = inventoryRepo.lockBySkuId(skuId);
UserQuota quota = quotaRepo.lockByUserAndSku(userId, skuId);
validate(promotion, inventory, quota, quantity);
inventory.decrease(quantity);
quota.increase(quantity);
orderRepo.save(...);
}
你可以说它“重”,但它确实能把死锁概率压下去。
2. 把非必要查询移出事务
很多事务之所以锁时间长,不是因为更新复杂,而是夹杂了大量“顺手查一下”的逻辑。
比如:
- 查商品展示信息
- 查营销文案
- 查用户头像
- 查埋点配置
这些东西不参与一致性,别放进事务里。
事务越长,持锁时间越久,越容易跟别人形成交叉等待。
3. 让 SQL 精准命中索引,减少锁范围
库存和限购这类表,相关语句必须保证走唯一索引或高选择性索引。
例如:
ALTER TABLE user_quota
ADD UNIQUE KEY uk_user_sku (user_id, sku_id);
对应查询:
SELECT * FROM user_quota
WHERE user_id = ? AND sku_id = ?
FOR UPDATE;
如果没有这个索引,MySQL 可能扫描大量记录,锁范围自然会扩大。
4. 拆掉一个事务里过多的资源竞争
有些动作不必跟库存扣减强绑定,比如:
- 写审计日志
- 发 MQ 通知
- 更新推荐系统计数
这些都可以放到事务提交后做。
Spring 里常见做法是事务成功后发事件:
@Transactional
public void createOrder(...) {
// 核心事务
publisher.publishEvent(new OrderCreatedEvent(orderId));
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
mqTemplate.send(...);
}
这样至少不会让库存锁陪着一堆附带动作一起等待。
5. 做重试,但别把重试当根治
死锁是数据库层主动选择牺牲一个事务来保整体进度,所以业务上做有限重试是合理的。
for (int i = 0; i < 3; i++) {
try {
reserveStock(cmd);
return;
} catch (DeadlockLoserDataAccessException e) {
sleep(50L * (i + 1));
}
}
throw new BizException("库存扣减失败,请稍后重试");
但重试只能兜底,不能当主要方案。根因还是锁顺序、事务范围和索引设计。
一个容易误判的点:不是并发高才会死锁,是访问模式有环才会死锁
有些系统并发并不夸张,照样高频死锁。原因是它的事务设计天然有环。
相反,有些高并发系统因为资源获取顺序非常统一,反而很少死锁。
所以判断死锁风险,不要只看 QPS,要看:
- 同一资源是否被多个事务争抢
- 获取顺序是否一致
- 事务是否过长
- SQL 是否扩大了锁范围
后来我们是怎么收敛住的
做完这些事之后,库存链路稳定了很多:
- 统一锁顺序
- 核心事务缩短
- 增加复合唯一索引
- 非关键动作移出事务
- 对死锁异常做有限重试
结果是秒杀高峰时死锁数下降到原来的十分之一以下,剩下的少量冲突也能被业务重试兜住。
如果你们现在也在查库存死锁,别只盯着那条 UPDATE。多数时候,真正的问题藏在事务边界和锁顺序里。
