数据库死锁是什么?MySQL死锁日志分析与解决方法

线上业务突然报出 Deadlock found when trying to get lock; try restarting transaction,应用日志里一连串这个错误,接口开始超时——很多运维和开发第一次遇到死锁时都会有点慌。死锁听起来吓人,但它其实是数据库并发控制里的正常现象:多个事务互相持有对方需要的锁,谁也不肯放手,形成一个环,数据库必须挑一个"牺牲者"回滚,才能让其他人继续。

这篇文章讲清楚三部分:死锁到底是怎么形成的、InnoDB 是怎么检测的;拿到 SHOW ENGINE INNODB STATUS 里的死锁日志怎么读;以及最常见的原因和预防手段。理解这些之后,你就知道大部分死锁不是"数据库有 bug",而是访问顺序、索引设计或事务粒度的问题。文中参数和日志格式以常见 MySQL 5.7 与 8.0 为例,实际输出以自己环境为准。

死锁是怎么形成的

要理解死锁,先要有行锁的基本概念。InnoDB 在事务里修改数据时会加行锁,主要有共享锁(S 锁,读)和排他锁(X 锁,写)。一个事务拿到某行的 X 锁后,另一个事务想改同一行,就得排队等锁释放。

死锁的经典场景是两个事务以相反的顺序锁同样的两行。事务 A 先锁第 1 行、再想锁第 2 行;事务 B 先锁第 2 行、再想锁第 1 行。A 持有 1 等 2,B 持有 2 等 1,两人都不释放,环就形成了。可以用下面这个例子复现。

-- 会话 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 锁住 id=1
-- 此时先不提交

-- 会话 B
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;   -- 锁住 id=2

-- 会话 A 继续,想锁 id=2,被 B 挡住,等待
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

-- 会话 B 继续,想锁 id=1,被 A 挡住,等待 —— 死锁形成
UPDATE accounts SET balance = balance + 50 WHERE id = 1;

当 InnoDB 检测到环之后,会根据一定规则选择回滚某个事务(通常是修改行数较少、代价较小的那个),让另一个事务继续。被回滚的事务会收到那个 "Deadlock found" 错误,此时正确的处理方式是重试整个事务,而不是重试单条语句。

InnoDB 的死锁检测

InnoDB 默认开启死锁检测,参数是 innodb_deadlock_detect。开启后,每当一个事务要等待锁时,InnoDB 会检查等待关系里有没有形成环,有就立即回滚一个事务。检测本身有成本,在极高并发下可能消耗较多 CPU。

# 查看死锁检测开关和锁超时时间
mysql -uroot -p -e "SHOW VARIABLES LIKE 'innodb_deadlock_detect';"
mysql -uroot -p -e "SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';"

# 查看当前是否有锁等待(MySQL 8.0 用 performance_schema)
mysql -uroot -p -e "SELECT * FROM performance_schema.data_lock_waits\G"

如果关闭死锁检测,InnoDB 就只能靠锁等待超时来打破僵局。innodb_lock_wait_timeout 默认是 50 秒,意味着一个被挡住的事务最多等 50 秒,超时后报错回滚。在死锁检测关闭的场景里,如果这个超时设得太大,业务会长时间卡住;设得太小,又不是真的死锁也可能误伤。所以绝大多数情况还是保持死锁检测开启。

# 临时开启/关闭死锁检测(重启失效,谨慎操作)
mysql -uroot -p -e "SET GLOBAL innodb_deadlock_detect = OFF;"

# 动态调整锁等待超时(单位秒)
mysql -uroot -p -e "SET GLOBAL innodb_lock_wait_timeout = 20;"

风险提示:SET GLOBAL 修改的参数在实例重启后失效,只影响新建立的会话;直接改这个参数会立刻影响线上正在运行的所有连接,务必在业务低峰并评估后进行,改完要持续观察锁等待和超时报错。永久生效需要写入配置文件 my.cnf,改完重启 MySQL 才生效,重启属于高风险操作,一定要先备份并安排维护窗口。

读懂 LATEST DETECTED DEADLOCK

死锁发生后,InnoDB 会把最近一次死锁的信息记录下来。查看方式如下。

mysql -uroot -p -e "SHOW ENGINE INNODB STATUS\G" | sed -n '/LATEST DETECTED DEADLOCK/,/WE ROLL BACK/p'

这段日志通常包含这几个部分:发生时间、事务 1 的信息、事务 2 的信息,以及 InnoDB 最后选择了回滚哪个事务。以一个脱敏后的例子来说明怎么读。

------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-03-01 10:22:31 0x7f9c...
*** (1) TRANSACTION:
TRANSACTION 4821, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 88, OS thread handle 1234, query id 9001 localhost root updating
UPDATE accounts SET balance = balance + 100 WHERE id = 2
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 57 page no 4 n bits 72 index PRIMARY ...
lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; ...
 0: len 4; hex 80000002;   -- 对应 id = 2
*** (2) TRANSACTION:
TRANSACTION 4820, ACTIVE 4 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 77, ... updating
UPDATE accounts SET balance = balance + 50 WHERE id = 1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS ... index PRIMARY ...
lock_mode X locks rec but not gap
 0: len 4; hex 80000002;   -- 持有 id = 2 的锁
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS ...
lock_mode X locks rec but not gap waiting
 0: len 4; hex 80000001;   -- 等待 id = 1
*** WE ROLL BACK TRANSACTION (1)

读日志的要点有几个。看 *** (1) TRANSACTION 和 *** (2) TRANSACTION 找到两个互相等待的事务。看 WAITING FOR THIS LOCK TO BE GRANTED 找出每个事务在等什么锁,看 HOLDS THE LOCK(S) 找出它已经持有什么锁。把两者的持有和等待串起来,就能看清楚环是怎么形成的。最后 WE ROLL BACK TRANSACTION (1) 告诉你 InnoDB 回滚了哪个事务,被回滚的通常就是应用收到报错的那一个。

日志里 hex 80000002 这种值需要还原。InnoDB 把主键等整数按大端方式编码打印成十六进制,去掉最高位的符号位标记,80000002 对应的就是整数 2。看到 locks rec but not gap 表示这只是一个记录锁,不涉及间隙;如果出现 locks gap before rec 或 lock_mode X 后面没有 not gap,就说明涉及间隙锁,这一点在下面会重点讲。

常见成因

死锁的具体成因很多,但归纳起来主要集中在几类。

加锁顺序不一致

这是最经典的一类。不同业务逻辑以不同的顺序去更新同一批行,就容易形成环。比如扣减库存时,一个接口先锁商品再锁订单,另一个接口先锁订单再锁商品。解决办法是统一访问顺序,让所有相关操作都按同一个顺序(比如都按主键升序)去加锁。

-- 不好的做法:逐条按业务顺序更新,顺序不确定
UPDATE inventory SET qty = qty - 1 WHERE sku_id = 100;
UPDATE inventory SET qty = qty - 1 WHERE sku_id = 50;

-- 更好的做法:先把要更新的主键排序,再按统一顺序更新
UPDATE inventory SET qty = qty - 1 WHERE sku_id IN (50, 100)
ORDER BY sku_id;

间隙锁

在使用默认隔离级别 READ COMMITTED 时基本只有记录锁,但在 MySQL 默认的 REPEATABLE READ 下,范围条件的加锁会包含间隙锁,用来防止幻读。间隙锁之间是可以互相兼容的,反而更容易造成死锁。典型的例子是两个事务同时对同一范围做插入意向加锁。

-- 表 t 有索引 id,且 id 有值 1、5
-- 会话 A
BEGIN;
SELECT * FROM t WHERE id BETWEEN 3 AND 6 FOR UPDATE;  -- 锁住间隙
-- 会话 B 想插入 id = 4,等待

-- 如果再加上其他操作形成环,就容易触发死锁

间隙锁导致的死锁一般和唯一索引冲突、范围更新、批量插入有关。如果业务能接受读已提交,把隔离级别降为 READ COMMITTED 可以显著减少间隙锁带来的死锁,但会改变隔离语义,需要评估业务是否依赖可重复读。

大事务

事务越大、持锁时间越长,与他人冲突的概率越高。一个事务里做了几十条更新、跨多张表、还夹着远程调用或大批量导入,持锁时间线性增长,踩到死锁和锁等待的概率就大幅上升。应尽量把大事务拆成小事务,保证事务里只有必要的数据库操作,不要在里面做网络请求、文件读写等耗时动作。

索引缺失导致锁范围扩大

如果更新条件上没有合适的索引,InnoDB 可能扫描大量行并对它们逐一加锁,锁的范围远超预期,冲突自然变多。给更新、删除条件加上合适的索引,能大幅缩小加锁范围,这是性价比很高的一项优化。

预防与处理

针对上面这些成因,预防手段也相对明确。

手段 作用 注意事项
统一访问顺序 打破环形等待 需要业务代码配合改造
缩短事务 减少持锁时间 事务内不做远程调用和 IO
补合适索引 缩小加锁范围 结合慢查询和执行计划验证
降低隔离级别 减少间隙锁 需评估是否影响业务一致性
小批量提交 降低单次锁量 批量操作分批处理
应用侧重试 从死锁错误恢复 重试整个事务,加退避策略

应用侧一定要有重试机制。死锁被回滚后,重新执行整个事务通常就能成功,但要加退避和次数上限,避免持续冲突时把数据库压垮。重试的是完整事务,不是单条语句,否则前面已完成的操作会丢失一致性。

# 持续观察死锁是否还在发生:定期看死锁计数
mysql -uroot -p -e "SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';"

# 查看锁等待的相关状态
mysql -uroot -p -e "SHOW GLOBAL STATUS LIKE 'Innodb_row_lock%';"

Innodb_row_lock_waits 和 Innodb_row_lock_time 能反映锁等待的频率和总时长,Innodb_deadlocks 反映累计死锁次数。把这些指标接入监控,就能在死锁突增时及时发现问题,而不是等用户投诉。

常见问题

死锁会自动恢复吗?

会。InnoDB 检测到死锁后会立即回滚其中一个事务,另一个继续执行。被回滚的事务会收到错误,需要应用重试整个事务。

只有 MySQL 会死锁吗?

不是,只要使用锁做并发控制,关系型数据库都可能出现死锁。Oracle、PostgreSQL、SQL Server 都有各自的检测和处理机制,只是日志格式和参数不同。

降低隔离级别能彻底解决死锁吗?

不能彻底解决,但能减少间隙锁导致的死锁。记录锁之间仍可能因加锁顺序不一致产生死锁,根本还是要靠统一的访问顺序和合理的事务设计。

为什么不干脆关掉死锁检测?

关掉检测后,冲突只能靠锁等待超时来化解,业务会被卡住更长时间,还可能误伤正常等待。只有在极端高并发且检测本身成为瓶颈时才考虑,并且要配合很短的超时时间。

死锁日志只显示一条,够用吗?

SHOW ENGINE INNODB STATUS 默认只保留最近一次死锁。MySQL 8.0 可以通过 innodb_print_all_deadlocks 让所有死锁都写入错误日志,便于收集分析。

# 开启后所有死锁都会记录到 MySQL 错误日志(需评估日志量)
mysql -uroot -p -e "SET GLOBAL innodb_print_all_deadlocks = ON;"

总结

  • 死锁是多个事务互相等待对方持有的锁形成的环,InnoDB 会检测到并回滚其中一个事务来打破僵局。
  • SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 段落要按"事务—持有锁—等待锁—回滚对象"的顺序读,十六进制值可以还原成主键值。
  • 常见成因是加锁顺序不一致、间隙锁、大事务和索引缺失导致锁范围过大。
  • 预防手段包括统一访问顺序、缩短事务、补充合适的索引、必要时降低隔离级别,以及分批提交。
  • 应用侧必须对死锁错误做整事务重试,并加退避和上限,同时把死锁相关指标接入监控。
  • 死锁检测参数和锁等待超时不要随手改,调整要在业务低峰进行并保留回滚方式。