数据库运维9 min read次阅读

MySQL 死锁了怎么办:从锁类型到死锁日志怎么看

应用日志里蹦出一行:

Deadlock found when trying to get lock; try restarting transaction

很多人第一反应是加个重试就完事。重试确实能让业务不报错,但死锁频繁说明并发控制的设计有问题,光重试是把火药桶盖住了,没拆引信。

死锁为什么会发生

两个事务互相握着对方要的锁,谁也不放手,InnoDB 检测到环就挑一个代价小的回滚(通常是改动行数少的那个)。

最常见的是加锁顺序不一致

-- 事务 A
UPDATE account SET balance = balance - 100 WHERE id = 1;
UPDATE account SET balance = balance + 100 WHERE id = 2;

-- 事务 B(顺序反了)
UPDATE account SET balance = balance + 50 WHERE id = 2;
UPDATE account SET balance = balance - 50 WHERE id = 1;

A 锁住 1 等 2,B 锁住 2 等 1,环就形成了。解决办法朴素得让人意外:让所有业务按同一个顺序加锁(比如都按 id 升序)。这一条能消掉一大半死锁。

InnoDB 到底锁了什么

理解锁的类型,才知道为什么"改一行"会锁住一片。

  • 记录锁(Record Lock):锁单条索引记录。WHERE id = 1(id 是主键/唯一索引)时就是这个。
  • 间隙锁(Gap Lock):锁两条记录之间的空隙,防止别的事务往中间插数据。比如已有 id=1,5,锁住 (1,5) 这个区间,别人插 id=3 会被挡住。
  • 临键锁(Next-Key Lock):记录锁 + 间隙锁,锁的是左开右闭区间,比如 (1,5]。这是 RR(可重复读)隔离级别下的默认加锁单位。

关键点:InnoDB 的行锁是加在索引上的,不是加在行上。如果你的 WHERE 条件没走索引,就会退化成锁全表(其实是锁所有记录 + 所有间隙),并发直接归零,死锁概率飙升。

-- 危险:type 列没索引,这条更新会锁住全表所有记录和间隙
UPDATE orders SET status = 'PAID' WHERE type = 'VIP';

-- 相对安全:走主键,只锁这一行
UPDATE orders SET status = 'PAID' WHERE id = 1001;

RR 隔离级别下的坑

MySQL 默认是 RR(可重复读),靠间隙锁来防止幻读。代价是:RR 下间隙锁的范围比 RC 大得多,死锁率高不少。

很多高并发业务会选择改成 RC(读已提交) + binlog_format=ROW

[mysqld]
transaction-isolation = READ-COMMITTED
binlog_format        = ROW      # RC 下 binlog 必须用 ROW,否则主从数据会不一致

RC 下基本没有间隙锁(除了外键检查和唯一性检查),锁范围小、并发高、死锁少。代价是同一事务内两次 SELECT 可能看到不同结果(不可重复读),但对大多数 OLTP 业务来说这点无所谓——本来一个事务里也不会查两遍还要求结果一模一样。

要不要改 RC 得看业务:金融、对账这类对一致性读要求严格的,老实用 RR;普通互联网高并发写入场景,RC + ROW 基本是标配。

死锁日志怎么读

出事了看这里:

SHOW ENGINE INNODB STATUS\G

找到 LATEST DETECTED DEADLOCK 这一段,典型长这样(简化过):

------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 421312, 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)
UPDATE orders SET status='PAID' WHERE user_id=100 AND sku_id=7

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 58 page no 4 n bits 80 index PRIMARY of table `shop`.`orders`
trx id 421312 lock_mode X locks rec but not gap

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ... index idx_user of table `shop`.`orders`
trx id 421312 lock_mode X locks gap before rec

*** (2) TRANSACTION:
TRANSACTION 421313, ...
UPDATE orders SET status='CANCEL' WHERE user_id=101 AND sku_id=7

*** (2) HOLDS THE LOCK(S): ...
*** (2) WAITING FOR THIS LOCK TO BE GRANTED: ...

*** WE ROLL BACK TRANSACTION (2)

逐行看什么:

  • (1) TRANSACTION / (2) TRANSACTION:两个互相等待的事务,各自的 SQL 会列出来——先确认是哪些语句在打架。
  • HOLDS THE LOCK(S):这个事务已经拿着什么锁。lock_mode X 是排他锁,locks rec but not gap 是纯记录锁,locks gap before rec 是间隙锁。
  • WAITING FOR THIS LOCK:它在什么锁。配合上面一条就能画出等待环。
  • index PRIMARY / index idx_user:锁在哪个索引上。如果显示的是你没想到的索引,说明优化器选的索引和你预期不一致,可能要加 hint 或建更合适的索引。
  • WE ROLL BACK TRANSACTION (2):InnoDB 决定回滚谁。如果总是回滚你不想回滚的那个(比如回滚了重要业务),可以考虑在事务里减少改动行数、或者拆事务。

注意:SHOW ENGINE INNODB STATUS 只保留最近一次死锁。要看历史死锁,得开 innodb_print_all_deadlocks,让死锁写进 error log,或者靠监控定期采集。

# 让所有死锁都写进 error log,方便事后追溯
innodb_print_all_deadlocks = 1

减少死锁的几个实操手段

  1. 固定加锁顺序:批量更新时先 ORDER BY id 排个序,让所有事务按同一顺序拿锁。
  2. 缩短事务:事务里别掺 RPC、别发邮件、别等用户输入。锁持有时间越短,撞车概率越低。
  3. WHERE 走索引:没索引 = 锁全表,这是死锁的头号元凶。用 EXPLAIN 确认。
  4. 用主键/唯一索引精准命中WHERE id = ? 只锁一行;范围条件会触发 next-key 锁锁住一片。
  5. 降低隔离级别到 RC(配合 ROW binlog):能大幅减少间隙锁。
  6. 拆分大事务:一次更新 10 万行拆成每批 1000 行,锁范围和时间都变小。
  7. 热点行排队:秒杀扣库存这种,考虑用 Redis 预扣或者把一行的更新拆成多行(分段库存)减少争抢。

排查死锁的标准流程

  1. 抓死锁 SQL(SHOW ENGINE INNODB STATUS 或开了 innodb_print_all_deadlocks 后的 error log);
  2. 看两个事务各自持有什么、等什么、锁在哪个索引;
  3. EXPLAIN 那几条 SQL,确认走了预期的索引;
  4. 看加锁顺序是否一致、事务是否过大;
  5. 针对性改:调整索引、统一顺序、拆事务、降隔离级别。

最后

死锁不是"重试就行了"的小毛病,它是并发设计的一面镜子。偶尔一次可能是运气,一天几十次一定是代码或索引的问题。真正治本的办法就那几个:索引建对、顺序统一、事务做小。把这三件事做好,死锁日志会安静很久。

分享:

相关文章

评论区