跳到主内容

从 8 秒雪崩到 200ms:订单扣库存死锁排查实录 ​

问题背景:库存没扣完,订单却大量超时 ​

一次大促压测中,订单提交接口的 P99 从 300ms 飙升到 8s,应用线程池被打满,数据库 CPU 并不高,但 SHOW ENGINE INNODB STATUS 中出现大量锁等待和死锁回滚。更反直觉的是,商品库存表仍有剩余库存,用户却持续收到“下单失败,请重试”。

典型下单事务通常包含三步:校验库存、扣减多个 SKU 库存、写入订单与订单明细。高并发下,如果多个事务以不同顺序锁定同一批库存行,就会形成循环等待;如果更新语句没有命中合适索引,InnoDB 还可能扫描并锁住大量记录,进一步放大锁等待范围。

事务 A 先锁 SKU10事务 B 先锁 SKU11A 再等待 SKU11B 再等待 SKU10循环等待触发 Deadlock,后到事务被回滚

方案对比:应用重试不是根治手段 ​

常见处理方式有三类。第一类是捕获死锁后直接重试,实现简单,但热点商品下会造成同一批事务反复竞争,数据库空转,接口尾延迟仍然很高。第二类是把库存放到 Redis 预扣,再异步落库,性能好,但引入分布式一致性、超卖补偿和对账复杂度。第三类是先修正 MySQL 事务边界、索引和加锁顺序,再对极热点 SKU 做分段库存或缓存预热。

对大多数电商业务而言,第三类方案投入产出比最高:先保证关系型数据库中的扣减链路稳定、可观测,再决定是否需要缓存系统承接更高流量。

复现实操:建表、造数与并发更新 ​

下面的 SQL 可在 MySQL 5.7/8.0 的 InnoDB 环境直接运行,用于复现多行加锁顺序问题。

sql
-- 建立库存表:sku_id 必须有唯一索引,更新时才能精确定位聚簇索引记录
CREATE TABLE stock (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  sku_id BIGINT UNSIGNED NOT NULL COMMENT '商品 SKU ID',
  available_qty INT NOT NULL DEFAULT 0 COMMENT '可售库存',
  version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本,可选',
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_sku_id (sku_id),
  KEY idx_available_qty (available_qty)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品库存表';

-- 写入测试库存
INSERT INTO stock(sku_id, available_qty)
VALUES (10, 100), (11, 100), (12, 100);

-- 建立订单表,用唯一键保证幂等,避免用户重复提交产生多次扣减
CREATE TABLE order_info (
  order_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  order_no VARCHAR(32) NOT NULL COMMENT '业务订单号',
  user_id BIGINT UNSIGNED NOT NULL,
  status TINYINT NOT NULL DEFAULT 1 COMMENT '1待支付 2已取消',
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (order_id),
  UNIQUE KEY uk_order_no (order_no),
  KEY idx_user_created (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';

分别开启两个会话,按相反顺序更新同一批 SKU,即可制造死锁。

sql
-- 会话 A:先扣 SKU10,不提交,再尝试扣 SKU11
START TRANSACTION;
UPDATE stock
SET available_qty = available_qty - 1
WHERE sku_id = 10 AND available_qty >= 1;

-- 此处暂停,在会话 B 执行后再继续
UPDATE stock
SET available_qty = available_qty - 1
WHERE sku_id = 11 AND available_qty >= 1;
COMMIT;
sql
-- 会话 B:先扣 SKU11,再尝试扣 SKU10,形成相反加锁顺序
START TRANSACTION;
UPDATE stock
SET available_qty = available_qty - 1
WHERE sku_id = 11 AND available_qty >= 1;

UPDATE stock
SET available_qty = available_qty - 1
WHERE sku_id = 10 AND available_qty >= 1;
COMMIT;

其中一个会话会收到 ERROR 1213 (40001): Deadlock found when trying to get lock。这不是库存条件失败,而是 InnoDB 检测到等待图成环,主动选择回滚成本较小的事务。

根因定位:用系统表看清锁等待链路 ​

MySQL 8.0 可直接查询 performance_schema,确认谁在等待、谁持有锁、锁了哪张表和哪条记录。

sql
-- 开启锁监控,部分版本默认已开启
UPDATE performance_schema.setup_instruments
SET ENABLED = 'YES', TIMED = 'YES'
WHERE NAME = 'wait/lock/innodb/%';

UPDATE performance_schema.setup_consumers
SET ENABLED = 'YES'
WHERE NAME LIKE '%events_waits%';

-- 查看当前锁等待:阻塞事务与被阻塞事务
SELECT
  r.ENGINE_TRANSACTION_ID AS waiting_trx_id,
  b.ENGINE_TRANSACTION_ID AS blocking_trx_id,
  r.OBJECT_SCHEMA AS db_name,
  r.OBJECT_NAME AS table_name,
  r.INDEX_NAME,
  r.LOCK_TYPE,
  r.LOCK_MODE,
  r.LOCK_STATUS,
  r.LOCK_DATA
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks r
  ON w.REQUESTING_ENGINE_LOCK_ID = r.ENGINE_LOCK_ID
JOIN performance_schema.data_locks b
  ON w.BLOCKING_ENGINE_LOCK_ID = b.ENGINE_LOCK_ID;

-- 查看正在执行的阻塞 SQL
SELECT
  t.THREAD_ID,
  t.PROCESSLIST_ID,
  t.PROCESSLIST_INFO AS current_sql,
  t.PROCESSLIST_TIME AS running_seconds,
  t.PROCESSLIST_STATE
FROM performance_schema.threads t
WHERE t.PROCESSLIST_ID IN (
  SELECT trx_mysql_thread_id
  FROM information_schema.innodb_trx
);

MySQL 5.7 可使用:

sql
-- 查看 InnoDB 事务执行时间、锁等待状态和线程 ID
SELECT
  trx_id,
  trx_state,
  trx_started,
  trx_mysql_thread_id AS thread_id,
  trx_rows_locked,
  trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;

-- 查看最近一次死锁详情,重点看 RECORD LOCKS、索引名和等待顺序
SHOW ENGINE INNODB STATUS;

排查时重点看三件事:INDEX NAME 是否为 uk_sku_id 或主键,若显示错误索引甚至全表扫描,说明 SQL 没有精准命中;trx_rows_locked 是否明显大于本次购买 SKU 数;两个事务的加锁顺序是否相反。

链路优化:统一排序加锁并缩短事务 ​

最核心的修复是:应用层在提交订单前,将所有 SKU 按 sku_id 升序排列,然后在一个事务中逐条更新。无论购物车中的商品顺序如何,数据库看到的加锁顺序都一致,等待图无法成环。

sql
-- 正确方式:所有请求都按 sku_id 升序更新
START TRANSACTION;

UPDATE stock
SET available_qty = available_qty - 2
WHERE sku_id = 10 AND available_qty >= 2;

UPDATE stock
SET available_qty = available_qty - 1
WHERE sku_id = 11 AND available_qty >= 1;

-- order_no 唯一键负责幂等,重复提交只会产生一次订单
INSERT INTO order_info(order_no, user_id, status)
VALUES ('NO202501010001', 10001, 1);

COMMIT;

应用层伪代码如下:

java
// 按 skuId 升序排序,保证所有事务对库存行的加锁顺序全局一致
List<SkuItem> items = request.getItems().stream()
    .sorted(Comparator.comparing(SkuItem::getSkuId))
    .toList();

Connection conn = dataSource.getConnection();
try {
    conn.setAutoCommit(false);

    for (SkuItem item : items) {
        int affected = stockMapper.deduct(conn, item.getSkuId(), item.getQty());
        // 必须检查影响行数:0 表示库存不足或商品不存在
        if (affected == 0) {
            conn.rollback();
            throw new BizException("库存不足");
        }
    }

    orderMapper.insert(conn, buildOrder(request));
    conn.commit();
} catch (DuplicateKeyException e) {
    conn.rollback();
    // 同一 order_no 重复提交,返回原订单结果,而不是再次扣库存
} catch (Exception e) {
    conn.rollback();
    throw e;
} finally {
    conn.close();
}

同时要把 RPC 调用、优惠券计算、风控校验等慢操作移到事务外。事务内只做“锁库存行、写订单、写明细”这类必须原子化的本地操作。否则一个远程接口超时 3 秒,事务就会持有行锁 3 秒,热点 SKU 的排队时间会被线性放大。

对于单 SKU 极致热点,可以使用分段库存:将一个 SKU 拆成 10 个库存桶,请求按用户或随机数路由到桶扣减,失败后再尝试其他桶,最后汇总可售库存。它降低的是单一行的竞争强度,但不能替代统一加锁顺序,因为跨桶补货和汇总仍可能出现多行锁冲突。

避坑总结:这些现象最容易误判 ​

  1. 误把“库存不足”当成死锁原因。 常见现象是接口返回失败,业务日志只打印“扣减失败”,实际数据库错误码是 1213 或 1205。修正方法是区分 DuplicateKeyException、影响行数为 0、Lock wait timeout exceeded 和死锁回滚;1213 可以有限重试,库存不足不能重试。

  2. 更新条件没有命中唯一索引,导致锁定范围扩大。 例如使用 WHERE sku_code = ? 但 sku_code 无索引,InnoDB 会扫描并锁住多条记录,甚至出现间隙锁。修正方法是用 EXPLAIN 确认 type 为 const/ref、key 命中 uk_sku_id,并避免在锁条件列上使用函数或隐式类型转换。

  3. 在事务内调用外部服务。 现象是数据库活跃连接不高,但 innodb_trx 中事务持续时间很长,后续请求全部堆在同一行上。修正方法是事务前置完成价格、优惠券、风控等查询,事务内只访问本地数据库,并设置合理的 lock_wait_timeout。

  4. 无上限地自动重试。 死锁后立即重试会造成惊群,尤其在热点商品刚开卖时。修正方法是仅对幂等订单做 1 到 2 次短指数退避重试,重试时仍按 sku_id 排序,并通过 order_no 唯一键保证不会重复扣库存。

  5. 只看 CPU,不看锁等待时间。 行锁排队时数据库可能 CPU 很低,但应用线程已经阻塞。应持续采集 innodb_row_lock_waits、innodb_row_lock_time_avg、死锁次数和慢事务,把锁指标纳入大促看板,而不是等到线程池打满才排查。

最终优化后,压测链路的 P99 稳定在 200ms 左右,死锁回滚基本消失。这个结果并不是因为数据库“突然变快”,而是因为加锁顺序确定、索引精准、事务足够短,并且监控能够把锁等待从模糊的接口超时还原成清晰的事务等待图。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。