外观
从 8 秒雪崩到 200ms:订单扣库存死锁排查实录
问题背景:库存没扣完,订单却大量超时
一次大促压测中,订单提交接口的 P99 从 300ms 飙升到 8s,应用线程池被打满,数据库 CPU 并不高,但 SHOW ENGINE INNODB STATUS 中出现大量锁等待和死锁回滚。更反直觉的是,商品库存表仍有剩余库存,用户却持续收到“下单失败,请重试”。
典型下单事务通常包含三步:校验库存、扣减多个 SKU 库存、写入订单与订单明细。高并发下,如果多个事务以不同顺序锁定同一批库存行,就会形成循环等待;如果更新语句没有命中合适索引,InnoDB 还可能扫描并锁住大量记录,进一步放大锁等待范围。
方案对比:应用重试不是根治手段
常见处理方式有三类。第一类是捕获死锁后直接重试,实现简单,但热点商品下会造成同一批事务反复竞争,数据库空转,接口尾延迟仍然很高。第二类是把库存放到 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 个库存桶,请求按用户或随机数路由到桶扣减,失败后再尝试其他桶,最后汇总可售库存。它降低的是单一行的竞争强度,但不能替代统一加锁顺序,因为跨桶补货和汇总仍可能出现多行锁冲突。
避坑总结:这些现象最容易误判
误把“库存不足”当成死锁原因。 常见现象是接口返回失败,业务日志只打印“扣减失败”,实际数据库错误码是 1213 或 1205。修正方法是区分
DuplicateKeyException、影响行数为 0、Lock wait timeout exceeded和死锁回滚;1213 可以有限重试,库存不足不能重试。更新条件没有命中唯一索引,导致锁定范围扩大。 例如使用
WHERE sku_code = ?但sku_code无索引,InnoDB 会扫描并锁住多条记录,甚至出现间隙锁。修正方法是用EXPLAIN确认type为const/ref、key命中uk_sku_id,并避免在锁条件列上使用函数或隐式类型转换。在事务内调用外部服务。 现象是数据库活跃连接不高,但
innodb_trx中事务持续时间很长,后续请求全部堆在同一行上。修正方法是事务前置完成价格、优惠券、风控等查询,事务内只访问本地数据库,并设置合理的lock_wait_timeout。无上限地自动重试。 死锁后立即重试会造成惊群,尤其在热点商品刚开卖时。修正方法是仅对幂等订单做 1 到 2 次短指数退避重试,重试时仍按
sku_id排序,并通过order_no唯一键保证不会重复扣库存。只看 CPU,不看锁等待时间。 行锁排队时数据库可能 CPU 很低,但应用线程已经阻塞。应持续采集
innodb_row_lock_waits、innodb_row_lock_time_avg、死锁次数和慢事务,把锁指标纳入大促看板,而不是等到线程池打满才排查。
最终优化后,压测链路的 P99 稳定在 200ms 左右,死锁回滚基本消失。这个结果并不是因为数据库“突然变快”,而是因为加锁顺序确定、索引精准、事务足够短,并且监控能够把锁等待从模糊的接口超时还原成清晰的事务等待图。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。