MySQL死锁案例分析与解决方案
发布日期: 2026/07/26 阅读总量: 1

一、凌晨两点,我被死锁报警吵醒

“库存服务死锁率飙升到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隔离级别)3287521162
方案一(RC隔离级别)083495
方案二(统一锁顺序)0792101
方案三(乐观锁)0631(含重试)138
方案四(Redis锁)0457(锁竞争)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,不轻易改动。

下次遇到死锁,先看死锁日志,识别锁顺序冲突,再决定用哪个方案。