凌晨三点的 MySQL 死锁:一次电商库存事故复盘

📅 2026-05-20 · 👁 6 次阅读

事故时间线

2024-11-15 凌晨 02:47,监控群报警:订单接口 500 错误率飙升到 35%。

02:50,我爬起来打开电脑,看到错误日志:

SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock;
try restarting transaction

死锁。

03:12,定位到是 stock 表的扣减逻辑。

03:30,临时方案上线(降级为缓存扣减 + 异步落库),错误率归零。

04:50,事故平息,损失统计:10 分钟内 30 万订单失败,间接 GMV 损失约 200 万。

业务场景

电商核心库存表,简化后:

CREATE TABLE `stock` (
  `sku_id` bigint PRIMARY KEY,
  `qty` int NOT NULL,
  `version` int NOT NULL
) ENGINE=InnoDB;

扣减库存的代码(出问题的版本):

DB::transaction(function () use ($skuId, $num) {
    $row = DB::table('stock')
        ->where('sku_id', $skuId)
        ->lockForUpdate()  // SELECT ... FOR UPDATE
        ->first();

    if ($row->qty < $num) {
        throw new OutOfStockException();
    }

    DB::table('stock')
        ->where('sku_id', $skuId)
        ->decrement('qty', $num);
});

看起来很安全——加了行锁,再扣减。但在秒杀场景下,QPS 飙到 3000+ 时,死锁开始出现。

死锁日志分析

执行 SHOW ENGINE INSTDDB STATUS\G,看到死锁日志:

*** (1) TRANSACTION:
TRANSACTION 1234567, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 100, OS thread handle 0x7f..., query id 2000 localhost 127.0.0.1 user updating
UPDATE stock SET qty = qty - 1 WHERE sku_id = 100

*** (2) TRANSACTION:
TRANSACTION 1234568, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 101, OS thread handle 0x7f..., query id 2001 localhost 127.0.0.1 user updating
UPDATE stock SET qty = qty - 1 WHERE sku_id = 100

*** (2) HOLDS THE LOCK(S):
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:

根因:MySQL InnoDB 的行锁是加在索引上的。当多个事务同时 SELECT ... FOR UPDATE 同一行时,会先加 gap lock + next-key lock,并发场景下相互等待,形成死锁。

解决方案

方案一:把行锁变成乐观锁(最终采纳)

去掉 lockForUpdate,用版本号:

while ($retry-- > 0) {
    $row = DB::table('stock')->where('sku_id', $skuId)->first();

    if ($row->qty < $num) {
        throw new OutOfStockException();
    }

    $affected = DB::table('stock')
        ->where('sku_id', $skuId)
        ->where('version', $row->version)
        ->update([
            'qty' => $row->qty - $num,
            'version' => DB::raw('version + 1'),
        ]);

    if ($affected) return;
}
throw new RetryExhaustedException();

死锁消失,QPS 提升到 5000+。

方案二:Redis 预扣减

热点商品(秒杀)走 Redis:

$stockKey = "stock:{$skuId}";
$remaining = Redis::decrBy($stockKey, $num);
if ($remaining < 0) {
    Redis::incrBy($stockKey, $num); // 还回去
    throw new OutOfStockException();
}
// 投递到 MQ,异步落库

最终我们用的是方案一 + 方案二的组合:普通商品用乐观锁,秒杀商品用 Redis 预扣。

反思

这次事故给我三个教训:

  1. 加锁的代码不是越安全越好FOR UPDATE 在低并发是保护,在高并发是炸弹。
  2. 死锁在压测时就该暴露。我们压测时 QPS 只到 1000,没复现;上线后双十一秒杀直接 3000+。
  3. 核心链路必须有降级方案。如果不是 30 分钟内切了 Redis 方案,损失至少翻 5 倍。

最后说一句:所有看起来没问题的代码,在足够大的并发下都会出问题。生产环境的真相,永远比单元测试残酷。

推荐阅读

← 返回首页