一、凌晨两点,我被死锁报警吵醒
“库存服务死锁率飙升到80%!”——凌晨2:17,Prometheus告警短信把我从床上炸醒。登录MySQL监控,SHOW ENGINE INNODB STATUS 滚出三屏的死锁信息。简单SQL:UPDATE inventory SET stock = stock - 1 WHERE sku_id = ? AND stock > 0,两个并发请求同时扣减同一个SKU,死锁概率高得离谱。
这篇文章不聊理论,直接给方案。环境:MySQL 8.0.35,事务隔离级别默认REPEATABLE READ,表引擎InnoDB。
二、问题重现:两条“无辜”的UPDATE
建表语句:
CREATE TABLE `inventory` (
`id` int NOT NULL AUTO_INCREMENT,
`sku_id` varchar(32) NOT NULL,
`stock` int NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_sku` (`sku_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
并发执行以下两个事务(均使用REPEATABLE READ):
-- Session A
START TRANSACTION;
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 'SKU001' AND stock > 0;
DO SLEEP(1); -- 模拟延迟
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 'SKU002' AND stock > 0;
COMMIT;
-- Session B(同时执行)
START TRANSACTION;
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 'SKU002' AND stock > 0;
DO SLEEP(1);
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 'SKU001' AND stock > 0;
COMMIT;
执行后,Session A和Session B各持有SKU001和SKU002的行锁,然后又各自等待对方持有的锁,形成死锁。InnoDB检测到后立即回滚其中一个事务。
三、死锁原因分析
查看死锁日志:
SHOW ENGINE INNODB STATUS\G
关键输出:
------------------------
LATEST DETECTED DEADLOCK
------------------------
Transaction 1:
HOLDS THE LOCK(S): record lock on `test`.`inventory` index `uk_sku` of trx id 3855 lock_mode X locks rec but not gap
WAITING FOR THIS LOCK TO BE GRANTED: record lock on `test`.`inventory` index `uk_sku` of trx id 3855 lock_mode X locks rec but not gap
Transaction 2:
HOLDS THE LOCK(S): record lock on `test`.`inventory` index `uk_sku` of trx id 3856 lock_mode X locks rec but not gap
WAITING FOR THIS LOCK TO BE GRANTED: record lock on `test`.`inventory` index `uk_sku` of trx id 3856 lock_mode X locks rec but not gap
WE ROLL BACK TRANSACTION (2)
根本原因:两个事务以不同顺序更新相同行集,并且都处于REPEATABLE READ隔离级别。InnoDB会对UPDATE语句中的WHERE条件加行锁(实际上包括间隙锁,但此处因为用唯一索引且等值条件,只加记录锁)。当两个事务相互等待对方持有的行锁时,InnoDB死锁检测机制触发。
四、四种解决方案对比
我们测试了四种方案,下文给出完整代码和压测数据。
方案一:降低隔离级别为READ COMMITTED
READ COMMITTED不会对间隙加锁(除非有外键或冲突),只加行锁。死锁概率大幅降低,但需要业务容忍不可重复读和幻读。
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 'SKU001' AND stock > 0;
UPDATE inventory SET stock = stock - 1 WHERE sku_id = 'SKU002' AND stock > 0;
COMMIT;
缺点:可能会读到其他事务已提交的修改,业务要求不高时可使用。
方案二:统一加锁顺序(按sku_id排序更新)
业务代码中强制所有更新操作按同一顺序获取锁,比如按sku_id升序。
<?php
// 方案二:统一加锁顺序
$skus = ['SKU001', 'SKU002'];
sort($skus); // 保证锁顺序
$pdo->beginTransaction();
foreach ($skus as $sku) {
$stmt = $pdo->prepare('UPDATE inventory SET stock = stock - 1 WHERE sku_id = ? AND stock > 0');
$stmt->execute([$sku]);
if ($stmt->rowCount() == 0) {
$pdo->rollBack();
throw new \Exception('库存不足');
}
}
$pdo->commit();
注意:事务中不能有外部IO或延迟操作,否则影响并发。
方案三:乐观锁(CAS方式)
不显式加锁,通过版本号或条件更新后检查影响行数,失败则重试。
<?php
// 方案三:乐观锁
$maxRetries = 3;
$skus = ['SKU001', 'SKU002'];
for ($i = 0; $i < $maxRetries; $i++) {
$pdo->beginTransaction();
try {
// 先查版本号
$stmt = $pdo->prepare('SELECT stock, version FROM inventory WHERE sku_id = ? FOR UPDATE');
$stmt->execute(['SKU001']);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row['stock'] <= 0) throw new \Exception('库存不足');
// 更新且版本号+1
$stmt = $pdo->prepare('UPDATE inventory SET stock = stock - 1, version = version + 1 WHERE sku_id = ? AND version = ?');
$stmt->execute(['SKU001', $row['version']]);
if ($stmt->rowCount() == 0) {
$pdo->rollBack();
continue; // 版本冲突,重试
}
// 同样处理SKU002
// ...省略重复代码
$pdo->commit();
break;
} catch (\Exception $e) {
$pdo->rollBack();
if ($i == $maxRetries - 1) throw $e;
}
}
注意:高并发下重试次数多,可配合分布式锁降低冲突。
方案四:引入分布式锁(以Redis为例)
在应用层用Redis互斥锁保证同一时刻只有一个线程操作同一组SKU。
<?php
// 方案四:Redis分布式锁
$lockKey = 'lock:inventory:'.implode(':', $skus);
$redis = new \Redis();
$redis->connect('127.0.0.1', 6379);
$locked = $redis->set($lockKey, 1, ['NX', 'EX' => 5]); // 5秒过期
if (!$locked) {
throw new \Exception('系统繁忙,请稍后重试');
}
try {
$pdo->beginTransaction();
foreach ($skus as $sku) {
$stmt = $pdo->prepare('UPDATE inventory SET stock = stock - 1 WHERE sku_id = ? AND stock > 0');
$stmt->execute([$sku]);
}
$pdo->commit();
} finally {
$redis->del($lockKey); // 释放锁
}
注意:Redis锁超时可能导致其他请求长时间等待,需合理设置过期时间并考虑锁续期。
五、压测效果数据
测试环境:MySQL 8.0.35, CPU 4核8G, 千兆局域网, 并发100客户端, 每个客户端随机请求扣减2个SKU(死锁场景)。每个方案跑10分钟,记录死锁次数和TPS。
| 方案 | 死锁次数 | TPS(事务/秒) | 平均响应时间(ms) |
|---|---|---|---|
| 原始(RR隔离级别) | 3287 | 521 | 162 |
| 方案一(RC隔离级别) | 0 | 834 | 95 |
| 方案二(统一锁顺序) | 0 | 792 | 101 |
| 方案三(乐观锁) | 0 | 631(含重试) | 138 |
| 方案四(Redis锁) | 0 | 457(锁竞争) | 189 |
方案一和方案二死锁率为0,TPS高。但方案一改变了事务语义,需要业务允许不可重复读。方案二最推荐:不改变隔离级别,只调整代码顺序。
六、避坑指南
- 坑1:UPDATE WHERE子句用非索引列导致全表锁。 某次线上,开发写了
UPDATE inventory SET stock = 0 WHERE name LIKE '%test%',name列没有索引,导致SQL对全表加行锁(实际上InnoDB会锁所有扫描到的行),大量死锁。修复:建立索引或用主键更新。 - 坑2:事务中不要做外部IO或sleep。 上述重现中的sleep(1)就是模拟外部操作。事务耗时越长,锁持有越久,死锁概率呈指数增长。一定要把事务控制在纯数据库操作内。
- 坑3:REPEATABLE READ下的间隙锁。 即使在唯一索引上等值查询,如果该行不存在,InnoDB会加间隙锁。如果有两个事务同时插入同一个gap,就会死锁。解决方案:使用READ COMMITTED或降级为唯一索引的等值查询(确保行存在)。
- 坑4:不要依赖InnoDB的死锁自动回滚。 虽然InnoDB会回滚代价较小的那个事务,但回滚后业务需要重试。如果没有重试逻辑,直接抛出异常,用户会看到失败。所有死锁方案都必须配合应用层重试。
- 坑5:使用分布式锁时忘记处理锁过期。 Redis锁5秒过期,但业务处理超过5秒,锁自动释放导致第二个请求进入,可能造成超卖。需要锁续期机制(如Redisson的看门狗)。
七、总结
MySQL死锁不是洪水猛兽,但需要清晰的排错思路。我的生产环境选择:标准业务(如订单、库存)用方案二:统一加锁顺序,配合乐观锁重试作为兜底。极端高并发场景(例如秒杀)加一层Redis分布式锁。隔离级别保持默认RR,不轻易改动。
下次遇到死锁,先看死锁日志,识别锁顺序冲突,再决定用哪个方案。