凌晨三点的 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 预扣。
反思
这次事故给我三个教训:
-
加锁的代码不是越安全越好。
FOR UPDATE在低并发是保护,在高并发是炸弹。 - 死锁在压测时就该暴露。我们压测时 QPS 只到 1000,没复现;上线后双十一秒杀直接 3000+。
- 核心链路必须有降级方案。如果不是 30 分钟内切了 Redis 方案,损失至少翻 5 倍。
最后说一句:所有看起来没问题的代码,在足够大的并发下都会出问题。生产环境的真相,永远比单元测试残酷。