死锁不是"数据库坏了"
日志里突然出现 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、持有和等待的锁、被回滚的一方都列出来。重点看三件事:
- 两个事务分别执行了什么 SQL;
- 各自的
WHERE条件有没有走索引; - 加锁顺序是不是相反。
如果视图里看不到历史死锁,可以开启死锁日志落盘:
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 有没有走索引,看加锁顺序是否一致,看事务是不是太长。这三点覆盖了大部分场景。日常开发中,给更新条件加索引、统一批量操作顺序、缩短事务,比研究锁的底层实现更有用。
评论