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
  • 每个事务持有和等待的锁

你需要回答三个问题:

  1. 两个事务分别执行了哪些 SQL
  2. 它们各自已经持有什么锁
  3. 它们正在等待什么锁

如果线上死锁频率高,建议把相关 SQL 模板、业务参数、事务耗时一起打日志,不然只看数据库层信息不够还原业务链路。

真正有效的治理方式

1. 统一事务内的加锁顺序

这是最有效的一条,没有之一。

只要多个事务可能同时修改相同资源,就必须保证获取锁的顺序一致。

比如统一规定:

  1. 先锁活动
  2. 再锁库存
  3. 最后锁用户限购

即使代码入口不同,也必须遵守同一顺序。

我后来专门把这段收敛成一个领域服务,禁止业务方自己拼事务。

@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。多数时候,真正的问题藏在事务边界和锁顺序里。

最后更新 4/20/2026, 6:02:32 AM