ElIauk

MySQL 锁等待与死锁排查入门

死锁不是"数据库坏了" 日志里突然出现 Deadlock found when trying to get lock; try restarting transaction,很多人的第一反应是数据库出问题了。实际上 InnoDB 的死锁检测每天都在正常工作,它发现两个事务互相等待,就会主动回滚其中一

评论
MySQL 锁等待与死锁排查入门

死锁不是"数据库坏了"

日志里突然出现 Deadlock found when trying to get lock; try restarting transaction,很多人的第一反应是数据库出问题了。实际上 InnoDB 的死锁检测每天都在正常工作,它发现两个事务互相等待,就会主动回滚其中一个,让另一个继续。

真正需要关心的是:为什么会产生环路?是 SQL 没走索引锁了太多行,还是两个业务方法更新数据的顺序相反,又或者是长事务把冲突窗口拉大了。这篇文章从锁的类型讲到日志排查,给一套可执行的定位方法。

先分清几种锁

InnoDB 的锁比想象中多,日常排查主要关注这几类:

锁 作用范围 典型触发
记录锁(Record Lock) 单行记录 主键或唯一索引等值更新
间隙锁(Gap Lock) 两条记录之间的间隙 RR 下的范围查询、防止插入
临键锁(Next-Key Lock) 记录 + 前面的间隙 RR 下范围查询的默认行为
插入意向锁 间隙中的插入位置 并发 insert 同一间隙
意向锁(IS/IX) 表级标记 行锁之前自动加
元数据锁(MDL) 表结构 DDL 与 DML 并发

普通 SELECT 是快照读,不加锁;SELECT ... FOR UPDATE、UPDATE、DELETE 是当前读,会加锁。很多人以为"只更新一行"就只锁一行,实际上如果 WHERE 条件没走索引,InnoDB 会扫描全表,把扫描到的行都锁上,冲突概率瞬间放大。

一个能复现的例子

两个账户互相转账,如果代码里更新顺序不一致,很容易死锁。用两个窗口就能复现:

-- 窗口 A
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1;

-- 窗口 B
BEGIN;
UPDATE account SET balance = balance + 100 WHERE id = 2;

-- 窗口 A 继续
UPDATE account SET balance = balance + 100 WHERE id = 2;

-- 窗口 B 继续,此时报 Deadlock
UPDATE account SET balance = balance - 100 WHERE id = 1;

A 持有 id=1 的锁等 id=2,B 持有 id=2 的锁等 id=1,形成环路。InnoDB 检测到后回滚其中一个事务,另一个顺利执行。

避免这类问题的方法很朴素:所有业务按同一个顺序加锁。比如统一按账户 ID 从小到大更新,转账双方都遵守这个顺序,就不会出现交叉等待。

排查步骤

死锁发生后,先拿到现场。MySQL 8.0 提供了几个视图:

-- 最近一次死锁的详细信息
SHOW ENGINE INNODB STATUS\G

-- 当前持锁和等锁情况
SELECT * FROM performance_schema.data_locks\G
SELECT * FROM performance_schema.data_lock_waits\G

-- 正在运行的事务
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx\G

SHOW ENGINE INNODB STATUS 输出里的 LATEST DETECTED DEADLOCK 段最有用,它会把两个事务的 SQL、持有和等待的锁、被回滚的一方都列出来。重点看三件事:

  1. 两个事务分别执行了什么 SQL;
  2. 各自的 WHERE 条件有没有走索引;
  3. 加锁顺序是不是相反。

如果视图里看不到历史死锁,可以开启死锁日志落盘:

SET GLOBAL innodb_print_all_deadlocks = ON;

这样每次死锁都会写进错误日志,方便事后分析。

常见坑

1. 更新条件没走索引

UPDATE account SET status = 1 WHERE phone = '138...',phone 没有索引,InnoDB 会扫全表并锁住扫描到的所有行。并发一高,随便两条 SQL 都能撞上。先 EXPLAIN 确认走索引,再谈锁的问题。

2. 批量更新顺序不一致

两个定时任务都按 IN (1,2,3) 批量更新,但一个顺序是正序、一个是倒序,执行时就会交叉等待。批量操作统一排序,或者拆成单条按固定顺序执行。

3. 唯一索引冲突引发死锁

并发插入相同唯一键时,先插入的事务持有排他锁,后插入的事务等待并持有共享锁,两边互相等待。业务上先查后插、用 INSERT ... ON DUPLICATE KEY UPDATE 或者捕获重复键异常重试,都能缓解。

4. 间隙锁范围过大

RR 隔离级别下,范围查询会锁间隙。WHERE id > 100 AND id < 200 锁住的可能比想象中多,插入 150 的请求会被挡住。确认业务是否真的需要 RR,有些读多写少的场景改成 RC 能减少间隙锁。

5. 长事务放大冲突

事务里做远程调用、等待用户输入、跑大批量计算,锁的持有时间被拉长,冲突概率成倍增加。事务里只放数据库操作,其他逻辑挪到外面。

6. 死锁重试不幂等

捕获死锁异常后直接重试,如果第一次执行已经产生了部分副作用,重试会重复扣款、重复插入。重试前要保证操作幂等,或者让整个事务回滚干净。

7. 只 kill 会话不找根因

KILL 掉一个会话能暂时恢复,但下一个请求还会死锁。必须从 SQL 和加锁顺序上解决。

小结

死锁排查可以记成一句话:看 SQL 有没有走索引,看加锁顺序是否一致,看事务是不是太长。这三点覆盖了大部分场景。日常开发中,给更新条件加索引、统一批量操作顺序、缩短事务,比研究锁的底层实现更有用。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者