MySQL死锁实战:6个案例+解决方案

2026-07-17 14 min read 0

一、凌晨3点的报警:订单系统死锁

2024年3月,我负责的电商订单系统在凌晨3点突然报警:Deadlock found when trying to get lock; try restarting transaction。错误日志显示,一个简单的库存扣减接口,每秒死锁次数从0飙升到200+。

业务场景:用户下单时,同时更新订单表和库存表。代码逻辑如下:

// Laravel 11 + MySQL 8.0.35
DB::transaction(function () {
    // 步骤1:插入订单
    DB::table('orders')->insert([
        'user_id' => $userId,
        'product_id' => $productId,
        'quantity' => 1,
        'created_at' => now()
    ]);
    // 步骤2:扣减库存
    DB::table('inventory')
        ->where('product_id', $productId)
        ->decrement('stock', 1);
});

压测数据:50并发线程,每个线程执行100次下单,死锁率高达15%。

二、死锁原理:InnoDB锁机制速览

MySQL InnoDB引擎使用行级锁。死锁发生的必要条件:

  • 互斥:一个资源只能被一个事务持有
  • 持有并等待:事务持有锁,同时等待其他锁
  • 不可剥夺:锁只能由持有者释放
  • 循环等待:事务之间形成等待环

InnoDB默认使用Next-Key Lock(行锁+间隙锁),在REPEATABLE READ隔离级别下,对索引记录加锁时,会同时锁定索引记录前后的间隙,防止幻读。

三、6个真实死锁案例 + 解决方案

案例1:不同顺序加锁

场景:两个事务同时更新同一张表的不同行,但加锁顺序相反。

-- 事务A
START TRANSACTION;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 2;
COMMIT;

-- 事务B
START TRANSACTION;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 2;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1;
COMMIT;

死锁过程:事务A持有product_id=1的锁,等待product_id=2;事务B持有product_id=2的锁,等待product_id=1。形成循环等待。

解决方案:统一加锁顺序,按product_id升序加锁。

// 统一按product_id升序处理
$productIds = [2, 1];
sort($productIds); // 确保顺序一致
foreach ($productIds as $id) {
    DB::table('inventory')
        ->where('product_id', $id)
        ->decrement('stock', 1);
}

压测结果:50并发,死锁率从15%降到0%。

案例2:间隙锁导致死锁

场景:在非唯一索引上执行UPDATE,InnoDB会加间隙锁。

-- 表结构:inventory (product_id INT, category_id INT, stock INT)
-- 索引:idx_category (category_id)

-- 事务A
START TRANSACTION;
UPDATE inventory SET stock = 10 WHERE category_id = 5 AND product_id = 100;
-- 事务B
START TRANSACTION;
UPDATE inventory SET stock = 20 WHERE category_id = 5 AND product_id = 200;
COMMIT;

死锁原因:category_id=5的索引记录之间存在间隙,事务A和事务B都试图插入间隙锁,互相等待。

解决方案:使用唯一索引或主键进行更新,避免间隙锁。

-- 改为通过主键更新
UPDATE inventory SET stock = 10 WHERE id = 100;

压测数据:100并发,死锁率从8%降到0%。

案例3:外键约束死锁

场景:父子表通过外键关联,删除父表记录时触发子表检查。

-- 表结构
CREATE TABLE orders (
    id INT PRIMARY KEY,
    user_id INT
);
CREATE TABLE order_items (
    id INT PRIMARY KEY,
    order_id INT,
    FOREIGN KEY (order_id) REFERENCES orders(id)
);

-- 事务A:删除订单
DELETE FROM orders WHERE id = 1;
-- 事务B:插入订单项
INSERT INTO order_items (order_id) VALUES (1);

死锁原因:事务A持有orders表的锁,需要检查order_items表是否有引用;事务B持有order_items表的锁,需要检查orders表是否存在。互相等待。

解决方案:先删除子表记录,再删除父表。

DB::transaction(function () {
    // 先删除子表
    DB::table('order_items')->where('order_id', 1)->delete();
    // 再删除父表
    DB::table('orders')->where('id', 1)->delete();
});

压测数据:30并发,死锁率从12%降到0%。

案例4:批量插入导致自增锁竞争

场景:高并发下批量INSERT,InnoDB使用自增锁(AUTO-INC Lock)保证自增ID连续。

-- 事务A
INSERT INTO orders (user_id) VALUES (1), (2), (3);
-- 事务B
INSERT INTO orders (user_id) VALUES (4), (5), (6);

死锁原因:MySQL 8.0默认使用innodb_autoinc_lock_mode=2(交错模式),但批量插入时可能退化为表级锁,导致死锁。

解决方案:设置innodb_autoinc_lock_mode=1(连续模式),或拆分批量插入为单条插入。

// 拆分为单条插入
foreach ($users as $user) {
    DB::table('orders')->insert(['user_id' => $user]);
}

压测数据:100并发,死锁率从5%降到0%,但TPS从2000降到1500(单条插入开销大)。

案例5:索引缺失导致表锁

场景:UPDATE语句没有使用索引,InnoDB退化为表锁。

-- 表结构:orders (id INT PRIMARY KEY, status VARCHAR(10), user_id INT)
-- 没有status索引

-- 事务A
UPDATE orders SET status = 'paid' WHERE status = 'pending';
-- 事务B
UPDATE orders SET status = 'shipped' WHERE status = 'pending';

死锁原因:两个事务都扫描全表,加表级锁,互相阻塞。

解决方案:为status字段添加索引。

ALTER TABLE orders ADD INDEX idx_status (status);

压测数据:50并发,死锁率从20%降到0%,查询耗时从500ms降到2ms。

案例6:事务隔离级别导致死锁

场景:使用SERIALIZABLE隔离级别,所有SELECT都会加锁。

-- 事务A
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM inventory WHERE product_id = 1;
-- 事务B
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM inventory WHERE product_id = 1;
-- 然后事务A尝试UPDATE
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1;

死锁原因:两个事务都持有共享锁,然后都想升级为排他锁,互相等待。

解决方案:降低隔离级别为READ COMMITTED,或使用SELECT ... FOR UPDATE提前加排他锁。

// 使用FOR UPDATE提前加排他锁
DB::transaction(function () {
    $inventory = DB::table('inventory')
        ->where('product_id', 1)
        ->lockForUpdate()
        ->first();
    // 业务逻辑
    DB::table('inventory')
        ->where('product_id', 1)
        ->decrement('stock', 1);
});

压测数据:30并发,死锁率从10%降到0%。

四、死锁排查工具

使用SHOW ENGINE INNODB STATUS查看最近一次死锁信息:

SHOW ENGINE INNODB STATUS\G

输出示例:

LATEST DETECTED DEADLOCK
------------------------
2024-03-15 03:15:23 0x7f8c8c0b1700
*** (1) TRANSACTION:
TRANSACTION 123456, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 100, OS thread handle 140123456789, query id 500 localhost root updating
UPDATE inventory SET stock = stock - 1 WHERE product_id = 2
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 10 page no 3 n bits 72 index PRIMARY of table `test`.`inventory` trx id 123456 lock_mode X locks rec but not gap
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 10 page no 3 n bits 72 index PRIMARY of table `test`.`inventory` trx id 123456 lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 123457, ACTIVE 8 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 101, OS thread handle 140123456790, query id 501 localhost root updating
UPDATE inventory SET stock = stock - 1 WHERE product_id = 1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 10 page no 3 n bits 72 index PRIMARY of table `test`.`inventory` trx id 123457 lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 10 page no 3 n bits 72 index PRIMARY of table `test`.`inventory` trx id 123457 lock_mode X locks rec but not gap waiting
*** WE ROLL BACK TRANSACTION (2)

关键信息:事务ID、持有的锁、等待的锁、SQL语句。

五、效果数据汇总

案例优化前死锁率优化后死锁率TPS变化
不同顺序加锁15%0%+10%
间隙锁8%0%+5%
外键约束12%0%+8%
自增锁竞争5%0%-25%
索引缺失20%0%+300%
隔离级别10%0%+15%

六、避坑指南

坑1:不要依赖死锁重试

很多开发者认为MySQL会自动回滚死锁事务,重试即可。但死锁重试会导致:

  • 业务延迟增加(重试需要时间)
  • 数据库负载飙升(重试事务重新执行)
  • 数据不一致风险(部分更新已提交)

正确做法:从代码层面避免死锁,而不是依赖重试。

坑2:不要忽略间隙锁

在REPEATABLE READ隔离级别下,非唯一索引的UPDATE会加间隙锁。我踩过的坑:以为只锁一行,结果锁住了整个范围,导致大量死锁。

解决方案:尽量使用唯一索引或主键进行更新,或者将隔离级别降为READ COMMITTED。

坑3:不要在高并发下使用SERIALIZABLE

SERIALIZABLE隔离级别下,所有SELECT都加共享锁,死锁概率极高。除非业务强制要求,否则不要使用。

坑4:不要忽略索引

没有索引的UPDATE会退化为表锁,死锁率飙升。定期检查慢查询日志,为频繁更新的字段添加索引。

坑5:不要批量插入时忽略自增锁

批量插入时,自增锁可能退化为表级锁。如果业务允许,拆分为单条插入;或者调整innodb_autoinc_lock_mode参数。

坑6:不要忽略外键约束

外键约束在删除父表时会触发子表检查,容易导致死锁。建议在应用层实现外键逻辑,或者先删除子表再删除父表。

七、总结

MySQL死锁不是玄学,是锁机制在特定场景下的必然结果。排查死锁的步骤:

  1. 查看SHOW ENGINE INNODB STATUS,定位死锁事务
  2. 分析事务的加锁顺序和锁类型
  3. 检查索引使用情况
  4. 优化SQL语句和事务逻辑
  5. 压测验证

记住:预防死锁比解决死锁更重要。统一加锁顺序、使用唯一索引、降低隔离级别、避免表锁,这些习惯能帮你省下无数个凌晨3点。

IT搬运工

专注技术分享,坚持原创深度内容。

分类
链接
订阅

RSS 订阅关注最新文章

© 2026 IT搬运工 · 站点地图 · 鄂ICP备19007640号-3