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%。
MySQL InnoDB引擎使用行级锁。死锁发生的必要条件:
InnoDB默认使用Next-Key Lock(行锁+间隙锁),在REPEATABLE READ隔离级别下,对索引记录加锁时,会同时锁定索引记录前后的间隙,防止幻读。
场景:两个事务同时更新同一张表的不同行,但加锁顺序相反。
-- 事务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%。
场景:在非唯一索引上执行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%。
场景:父子表通过外键关联,删除父表记录时触发子表检查。
-- 表结构
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%。
场景:高并发下批量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(单条插入开销大)。
场景: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。
场景:使用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死锁不是玄学,是锁机制在特定场景下的必然结果。排查死锁的步骤:
SHOW ENGINE INNODB STATUS,定位死锁事务记住:预防死锁比解决死锁更重要。统一加锁顺序、使用唯一索引、降低隔离级别、避免表锁,这些习惯能帮你省下无数个凌晨3点。
专注技术分享与实战