☰
MySQL InnoDB死锁检测机制与实战排查:从锁原理到事务优化
2026/10/14 4:34:05 网站建设 项目流程

搞数据开发这几年,说起数据库问题,最让人头秃的往往不是慢查询,而是那种偶发性的“死锁”。线上跑得好好的,突然某个角落冒出一条Deadlock found when trying to get lock; try restarting transaction,业务日志里一堆报错,可程序稍后重试又恢复正常。这种神出鬼没的问题,很多时候看一眼报错就过了,但如果你真正打开 InnoDB 的死锁日志,会发现事务之间的冲突远比想象中精彩——有些死锁场景,连写了好几年 SQL 的人都未必见过。

这篇文章我想认真聊聊 MySQL 死锁检测这件事。我会先用一个非常经典的案例把死锁机制讲透,再拆几个容易被忽视的真实冲突场景,最后手把手教你从死锁日志和系统表里定位问题、优化事务设计。无论是后端开发、DBA,还是对数据库底层机制感兴趣的读者,这篇都值得花十分钟慢慢看。理解死锁,本质上是理解资源竞争,理解了它,很多并发下的疑难杂症都能迎刃而解。

1. 死锁到底是怎么发生的:先弄懂锁和事务的关系

1.1 InnoDB 里有哪些锁,事务拿着它们干什么

死锁的前置知识是锁。InnoDB 在事务隔离级别为 REPEATABLE READ(默认)和 READ COMMITTED 下,行级锁的机制有很大差异,但基础锁类型是一致的。

从锁的模式上看,主要有三种:

  • 共享锁(S Lock):读锁,多个事务可以同时持有同一行记录的 S 锁。SELECT ... LOCK IN SHARE MODE会加这种锁。
  • 排他锁(X Lock):写锁,一旦事务持有 X 锁,其他事务既不能加 S 锁也不能加 X 锁。UPDATE、DELETE、INSERT以及SELECT ... FOR UPDATE都会加 X 锁。
  • 意向锁(Intention Lock):表级别的“预告”锁。事务在给某行加 S/X 锁之前,会先给表加意向锁。它是为了快速判断表级锁和行级锁是否冲突,互相之间不阻塞。

除了模式,InnoDB 还在索引记录上维护三种“空间”锁:

  • 记录锁(Record Lock):锁定单条索引记录。
  • 间隙锁(Gap Lock):锁定一个范围,但不包含记录本身。它的作用是防止其他事务在这个间隙中插入新记录,是 RR 隔离级别下解决幻读的核心手段。
  • 临键锁(Next-Key Lock):记录锁和间隙锁的组合,既锁记录,也锁记录前面的间隙。注意,InnoDB 默认是 RR 隔离级别,但这里的 RR 并不是完全无幻读,只是在当前读下通过临键锁避免了幻读。

这些锁之间会形成等待关系。比如事务 A 持有某行的 X 锁,事务 B 想给这行加 X 锁,B 就会进入锁等待状态,一直等到 A 提交或回滚。这种等待是正常的,数据库本来就要靠锁保证并发安全。但问题来了,当两个事务各自持有对方需要的锁,并且谁都不肯让路时,就形成了死锁。

1.2 死锁的四个必要条件,缺一不可

教科书上对死锁的定义永远是那四个条件:互斥、持有并等待、不可剥夺、循环等待。翻译成人话就是:

  1. 资源不能被多个事务同时写(互斥)。
  2. 每个事务都持有一个锁,又在等待另一个锁(持有并等待)。
  3. 已经持有的锁不能被其他事务强行抢走,只能自己释放(不可剥夺)。
  4. 事务之间形成了一个“你等我、我等你”的闭环(循环等待)。

用一个最简单也最经典的案例说明。假设有两行数据 id=1 和 id=2:

事务A: START TRANSACTION; UPDATE t SET name='a1' WHERE id=1; -- 持有 id=1 的锁 UPDATE t SET name='a2' WHERE id=2; -- 等待 id=2 的锁 事务B: START TRANSACTION; UPDATE t SET name='b2' WHERE id=2; -- 持有 id=2 的锁 UPDATE t SET name='b1' WHERE id=1; -- 等待 id=1 的锁

如果两个事务同时执行,A 拿到了 id=1,B 拿到了 id=2,然后 A 等 B 释放 id=2,B 等 A 释放 id=1,循环等待形成死锁。InnoDB 的死锁检测器会定期检查事务等待图,一旦发现存在回路,就会选择其中一个事务作为牺牲品,回滚它、释放它的锁,让另一个事务继续执行。被回滚的事务抛出 1213 错误,业务代码收到后可以进行重试。

1.3 死锁检测器是怎么“抓”到死锁的

InnoDB 的死锁检测不是靠超时,而是靠一个后台机制:事务在申请锁时,如果无法立即获得,会进入等待队列。InnoDB 此时会构建一个等待图(Wait-for Graph),节点是事务,边表示“事务 A 在等待事务 B 持有的锁”。然后从这个图里找是否有环。

找环这个动作默认是开启的,由参数innodb_deadlock_detect控制。一旦发现环,InnoDB 会选择一个事务回滚。选择的标准不是随机,而是综合考虑:

  • 事务完成的写入量(undo 的大小);
  • 事务已经持有的锁数量;
  • 事务执行的时间长短。

简单说,谁“回滚代价小”就牺牲谁,让代价大的事务存活。这就是为什么有些死锁回滚后,业务侧看到的报错可能是任何一方,不一定是后执行的那个事务。

注意:如果并发事务非常多,死锁检测本身也会消耗一定性能。某些高并发场景下,有人会直接关闭死锁检测,用innodb_lock_wait_timeout兜底。这是个有风险的操作,后面第 3 章我会专门分析利弊。

2. 你从没见过的死锁场景:除了 AB-BA,还有这些隐形冲突

很多人以为死锁就是两条 UPDATE 反着更新,实际上死锁远不止这一种。我整理几个真实的、不那么显眼但非常容易踩中的场景。

2.1 看似无关的 INSERT 之间也会死锁:自增锁与插入意向锁

先说一个非常反直觉的场景——两个事务都是 INSERT,且插入的是不同记录,居然也会死锁。

假设有一张表:

CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, item_no INT NOT NULL, KEY idx_order_id (order_id) ) ENGINE=InnoDB;

事务 A 执行:

INSERT INTO order_item (order_id, item_no) VALUES (1001, 1);

事务 B 执行:

INSERT INTO order_item (order_id, item_no) VALUES (1002, 2);

两者插入的主键不同、二级索引值也不同,看起来完全没有交集。但在 RR 隔离级别下,如果order_id上存在间隙锁,情况就不同了。比如另一个事务 C 之前执行了:

SELECT * FROM order_item WHERE order_id BETWEEN 1000 AND 2000 FOR UPDATE;

这条 SQL 会在idx_order_id上对 (1000, 2000] 范围加临键锁,包括间隙锁。此时事务 A 和 B 的 INSERT 都要往这个间隙里插入记录,就会因为冲突而互斥等待。A 先插入成功,B 等待;B 如果还持有一个 A 需要的锁,死锁就产生了。这就是典型的“插入意向锁”和“间隙锁”的冲突。

插入意向锁是一种特殊的间隙锁,多个事务可以在同一间隙的不同位置同时插入,彼此不冲突。但如果间隙本身已经存在一个间隙锁,所有想插入到这个间隙的事务都要等待。而一旦这些等待的事务之间又存在其他锁依赖,死锁检测器就会介入。

2.2 外键约束引发的“锁蔓延”

很多项目里外键约束用得少,但一旦用了,死锁概率会明显提升。原因是:插入子表记录时,InnoDB 会自动对父表的对应行加 S 锁,用来校验外键是否存在。

例如:

CREATE TABLE parent (id INT PRIMARY KEY); CREATE TABLE child ( id INT PRIMARY KEY, parent_id INT, FOREIGN KEY (parent_id) REFERENCES parent(id) );

事务 A:

UPDATE parent SET ... WHERE id=1; -- 对 parent.id=1 加 X 锁 INSERT INTO child (id, parent_id) VALUES (100, 1); -- 对 parent.id=1 请求 S 锁

事务 B:

INSERT INTO child (id, parent_id) VALUES (101, 1); -- 对 parent.id=1 加 S 锁(成功) UPDATE parent SET ... WHERE id=1; -- 等待 parent.id=1 的 X 锁

A 持有 X 锁等 S 锁,B 持有 S 锁等 X 锁,死锁。这种情况下,两边的 SQL 看起来一个是 UPDATE,一个是 INSERT,毫无关联,实际上锁冲突发生在父表行上。很多 DBA 排查问题时只盯着当前表的锁,忽略了外键关联的父表,所以这种死锁最难定位。

2.3 唯一键检查引发的“隐性锁”

唯一键冲突也是一个经典雷区。当一个事务插入一条唯一键已存在的记录时,InnoDB 会对这条唯一索引记录加 S 锁,用于判断是否冲突。如果两个事务同时插入相同唯一键但不同主键的记录,就可能互相等待。

举例,表结构:

CREATE TABLE user ( id INT PRIMARY KEY, email VARCHAR(64) UNIQUE );

事务 A:

INSERT INTO user (id, email) VALUES (1, 'a@b.com'); -- 插入成功

事务 B:

INSERT INTO user (id, email) VALUES (2, 'a@b.com'); -- 检测到 email 冲突,对唯一索引记录 a@b.com 加 S 锁等待

如果事务 A 又需要更新某一行,而这行正好被事务 B 锁住,那就不只是“等待唯一键冲突”了,而是会形成双向等待。更麻烦的是,当唯一键冲突发生后,事务并不会立即失败,它会在锁上等待,直到对方提交或回滚后才返回 duplicate key 错误。这个等待过程里极容易掺入其他锁请求,进而形成死锁。

2.4 范围 DELETE 与并发 INSERT 的“幽灵冲突”

还有一种常见场景:业务清理数据时会执行范围 DELETE,同时新数据持续写入。

事务A: DELETE FROM orders WHERE create_time < '2024-01-01'; 事务B: INSERT INTO orders (create_time) VALUES ('2023-12-31');

在 RR 隔离级别下,DELETE 的范围扫描会加很多临键锁,尤其是create_time索引上的间隙锁。INSERT 往这个间隙插入时,需要获取插入意向锁,但间隙已经被 DELETE 锁住,于是 INSERT 等待。如果 INSERT 所在的事务在此之前还持有 DELETE 需要访问的其他行锁,死锁就会出现。

这类问题在定时任务和数据采集同时跑的系统中格外突出。清理脚本是长事务,扫描的行越多,间隙锁覆盖范围越大,与实时写入的冲突概率就越高。

2.5 同一事务内查询和更新顺序不当

很多同学喜欢在一个事务里先 SELECT 再 UPDATE,觉得这样逻辑清晰。但 SELECT 和 UPDATE 使用的锁类型不同,顺序不当同样会死锁。

事务A: SELECT * FROM account WHERE balance > 100 FOR UPDATE; -- 锁定多行 UPDATE account SET balance = balance - 10 WHERE id = 5; 事务B: UPDATE account SET balance = balance + 10 WHERE id = 5; SELECT * FROM account WHERE balance > 100 FOR UPDATE;

两个事务都在自己的第一步拿到了部分锁,然后互相等待对方第二步需要的锁。这类死锁最扎心的是:只要每个事务内部访问记录的顺序一致,或者干脆把查询和更新拆成两个事务,就不会有问题。

3. InnoDB 死锁检测机制与日志解读

3.1 检测原理:等待图与参数权衡

死锁检测的内核逻辑是维护一张事务等待图。每次一个事务等待锁时,InnoDB 会检查这张图是否产生了环。这个检测是实时的,也就是死锁一旦形成,很快就会被发现并回滚其中一个事务。

参数innodb_deadlock_detect默认是 ON。它带来一个副作用:当大量事务同时竞争同一把锁时,每个等待事务的加入都会触发一次环检测,线程间的竞争会让整体吞吐量下降。高并发压测下,你可能会发现 CPU 消耗暴涨,但事务 TPS 反而降低,这就是死锁检测的开销。

因此有些极端场景(比如每秒几万次插入,且基本无锁冲突)会设置:

SET GLOBAL innodb_deadlock_detect = OFF;

关闭后,事务之间真的死锁了也不会立刻被回滚,只能等innodb_lock_wait_timeout超时。这个超时默认是 50 秒。你可以想象,一个等待 50 秒的事务会拖垮多少业务连接。所以关闭死锁检测的前提是:你能确认锁冲突极少,且应用层有完善的超时处理。我的建议是默认开启,不要为了那一点点性能收益去冒长等待的风险。

3.2 如何读懂 LATEST DETECTED DEADLOCK

当死锁发生后,InnoDB 会把最近一次死锁的详细信息记录到错误日志中,可以通过命令查看:

SHOW ENGINE INNODB STATUS\G

不用去翻文件,直接在客户端执行这条命令就能看到一段很详细的事务状态。重点看LATEST DETECTED DEADLOCK部分。模拟一个典型输出(敏感信息已脱敏):

------------------------ LATEST DETECTED DEADLOCK ------------------------ 2024-XX-XX 10:23:45 0x7f9c5a1c2700 *** (1) TRANSACTION: TRANSACTION 18446744073709551615, ACTIVE 1 sec inserting mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 1024, OS thread handle 140145200000000, query id 8723 ... INSERT INTO order_item (order_id, item_no) VALUES (1001, 1) *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 14 page no 3 n bits 72 index idx_order_id of table test.order_item trx id 12345 lock_mode X locks rec but not gap *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 14 page no 5 n bits 80 index PRIMARY of table test.order_item trx id 12345 lock_mode X locks rec but not gap

继续看 Transactions 部分,会列出两个事务各自的:

  • 当前执行的 SQL;
  • HOLDS THE LOCK(S):这个事务已经持有的锁;
  • WAITING FOR THIS LOCK TO BE GRANTED:这个事务正在等待的锁。

从这些信息里可以拿到几个关键线索:

  • 锁的模式是 X 还是 S;
  • 锁的索引名称(比如 PRIMARY、idx_order_id);
  • 是记录锁、间隙锁还是临键锁(输出中会写rec but not gap、gap before rec、rec not gap等,locks rec but not gap表示记录锁,gap before rec表示间隙锁,locks gap before rec insert intention表示插入意向锁)。

我的经验是:先把两个事务的等待锁列出来,画出它们的锁请求图,再回看业务代码,找到这两个事务的 SQL 顺序。80% 的死锁可以在 5 分钟内定位根因。

注意:SHOW ENGINE INNODB STATUS只记录最近一次死锁。如果死锁频繁发生,这条命令看到的只是“最后一位受害者”。要收集更长时间的日志,建议打开innodb_print_all_deadlocks=ON,把所有死锁都打印到错误日志里,方便事后分析。

3.3 用 performance_schema 实时监测锁等待

死锁发生后的日志是“事后”,想要“事前”观察锁等待情况,可以用:

SELECT * FROM performance_schema.data_lock_waits; SELECT * FROM performance_schema.data_locks;

这两个表会显示当前所有事务的锁持有和等待关系。如果线上并发很高,可以用如下 SQL 找出阻塞链:

SELECT waiting_trx_id, waiting_pid, waiting_query, blocking_trx_id, blocking_pid, blocking_query FROM performance_schema.data_lock_waits;

这个查询看不到 SQL 的具体锁模式,但能快速判断“谁在等谁”。再配合:

SELECT * FROM information_schema.innodb_trx WHERE trx_state = 'LOCK WAIT';

可以定位到长时间处于锁等待状态的事务,及时决定是 kill 长事务还是让它继续。

4. 从源头规避死锁:代码和 SQL 层面的实战优化

4.1 统一事务内对资源的访问顺序

死锁最常见的原因是资源访问顺序不一致。解决思路非常朴素:让所有事务以相同的顺序访问同一组资源。比如多个表需要更新时,统一按照表名字母序加锁,或者统一先更新主表再更新子表。只要事务之间的加锁顺序一致,循环等待就不会形成。

这在多表更新的业务里尤其有效。比如一个下单流程要更新user、account、order三张表,所有相关的异步任务都按照“user → account → order”的顺序更新,就不会出现 A 事务先锁 user 再锁 account,而 B 事务先锁 account 再锁 user 的情况。

但注意,这只能解决代码层面的加锁顺序问题。如果索引原因导致实际加锁的记录范围不同,依然可能死锁,所以还要看下一条。

4.2 减小事务范围,缩短持锁时间

很多死锁的根本原因是事务太长、锁持太久。一个事务里塞了 10 条 SQL,中间还要调远程接口,持锁时间可能达到几百毫秒甚至秒级。这时候其他事务插入或更新同一范围内的记录,冲突概率会指数级上升。

优化的思路是:

  • 把无关的读操作移出事务;
  • 远程调用、外部 API 请求放到事务提交之后;
  • 大批量更新拆成小批次,比如每 100 条一个事务;
  • 如果事务里既有复杂查询又有更新,先把查询结果算好,再开启写事务。

4.3 索引设计影响锁粒度

死锁跟索引的关系,很多人没意识到。锁是加在索引记录上的,如果一次 UPDATE 没有走索引,InnoDB 会锁住所有扫描到的记录。即使是只更新一行,也可能把整张表的相关记录都锁住。

举例:

UPDATE t SET status=1 WHERE order_no='X'; -- order_no 无索引

这条 SQL 会扫描主键索引,对所有满足条件或不满足条件的记录都加上锁(RR 模式下是临键锁),范围可能非常大。另一个并发事务插入新订单,如果插到该扫描范围内,就会产生间隙锁冲突。

因此,所有 WHERE 条件尽量都走索引,最好用唯一索引或者高区分度索引。走唯一索引时,命中唯一记录会退化为记录锁,锁范围小;走普通索引且范围大时,锁的就是整个范围。

4.4 隔离级别调整:RR 还是 RC

REPEATABLE READ是 MySQL 默认隔离级别,但它引入了间隙锁,而死锁很大比例跟间隙锁有关。如果业务本身允许,把隔离级别调整为READ COMMITTED可以显著减少间隙锁,降低死锁概率。

设置方式:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

或者全局层面调整:

SET GLOBAL transaction_isolation = 'READ-COMMITTED';

RC 级别下,InnoDB 只保留记录锁,不加间隙锁,幻读问题由业务自己取舍。在很多互联网业务中,RC 是完全可以接受的,还能减少锁冲突和死锁。

提醒:全局调整隔离级别影响所有连接,改之前先跟团队确认业务是否依赖 RR 的可重复读语义。

4.5 业务侧一定要做重试

死锁无法做到 100% 消灭。无论怎么优化事务、加索引、调隔离级别,总会有极端情况触发死锁。所以业务代码里的重试机制是最后一道保险。

收到 1213 错误码时,合理做法是:捕获异常,判断错误码是否为ER_LOCK_DEADLOCK,然后回滚并重试整个事务。为了防止无限重试,一般加一个最大重试次数,比如 3 次,每次重试间隔随机 10-50 毫秒。

伪代码示例:

import pymysql import random import time max_retry = 3 for attempt in range(max_retry): try: conn = pymysql.connect(...) with conn.cursor() as cur: cur.execute("BEGIN") cur.execute("UPDATE t SET status=1 WHERE id=%s", (id,)) cur.execute("UPDATE t2 SET cnt=cnt+1 WHERE id=%s", (id,)) conn.commit() break except pymysql.err.MySQLError as e: if e.args[0] == 1213 and attempt < max_retry - 1: time.sleep(random.uniform(0.01, 0.05)) continue raise

这套逻辑几乎适用于所有重试场景。死锁不是逻辑错误,而是并发冲突的合理失败,重试是官方建议的恢复手段。

5. 常见问题与排查技巧实录

5.1 1213 和 1205 错误有什么区别

很多人把死锁和锁等待超时混在一起,排查方向就错了。先区分两个错误码:

错误码信息触发原因
1213Deadlock found when trying to get lock; try restarting transaction死锁被检测器发现,事务被回滚
1205Lock wait timeout exceeded; try restarting transaction事务等待锁超过innodb_lock_wait_timeout(默认50秒),主动放弃

1205 不一定是死锁,可能是单纯的长事务持锁不释放,比如一个事务忘提交了,或者批处理跑了很久。遇到 1205,重点查哪个事务持有锁时间过长;遇到 1213,重点分析等待图中两个事务的加锁顺序。

5.2 一次真实的线上排查过程

我分享一个自己处理过的案例。某公司后台管理系统的导出功能偶尔报死锁,报错集中在凌晨定时任务跑完后的一小段时间。

第一步通过SHOW ENGINE INNODB STATUS看到死锁双方:一方是凌晨的统计数据回写事务,执行多行 UPDATE;另一方是运营后台的报表查询事务,它的 SQL 里用了SELECT ... FOR UPDATE。

第二步看了锁信息后发现,后台的查询条件status IN (1,2,3)是个普通索引,范围很大,锁住了很多行的间隙。统计回写事务也恰好更新这这些记录。两者互相等待,形成死锁。

第三步的解法很直接:把查询条件拆成单值循环查询,减少锁范围;同时把统计回写事务按分区拆成小事务,每条分区提交一次。上线后再没出现过死锁。

这个案例给我的启发是:死锁排查不能只看 SQL 本身,要结合业务调用时间、索引设计、事务粒度一起看。尤其是那些“夜间批处理 + 白天实时事务”并存的系统,本身就是死锁高发区。

5.3 死锁日志轮转与监控预警

死锁日志虽然默认输出到错误日志,但错误日志通常很大,而且 MySQL 本身会对错误日志做轮转。我的习惯是打开innodb_print_all_deadlocks=ON,把每个死锁的详细信息都输出,然后建立定时任务,定期从错误日志里提取死锁片段。

提取死锁日志的命令:

grep -A 50 "LATEST DETECTED DEADLOCK" /var/log/mysql/error.log

放在 crontab 里每天跑一次,把结果汇总到独立文件,再配合监控宝、夜莺或者自研监控系统,设置“死锁出现次数超过阈值就告警”的规则。有了连续的日志,你可以在一个季度内看到死锁趋势,提前优化最频繁出现的冲突场景。

5.4 容易踩的坑:自增主键不是万能避风港

有人以为只要所有表都有自增主键、都用主键更新,就不会死锁。这其实是个误区。死锁的本质是多个事务对多个资源的加锁顺序不一致,即使主键完全相同,二级索引的间隙锁也可能引发冲突。比如前面 2.1 节的两个 INSERT 死锁场景,主键完全不同,照样死锁。

另一个误区是“死锁了重试就行,不用分析”。如果每天只死锁一两次,确实可以靠重试兜底;但如果死锁次数每分钟都出现,重试只会让系统在死锁边缘反复横跳,最终还是会影响响应时间。重试是手段,优化才是根本。

5.5 实操心得:几个靠谱的预防习惯

最后分享几个我长期实践下来的习惯:

  • 开启innodb_print_all_deadlocks=ON,这个设置几乎零成本,但对事后排查看价值巨大。
  • 对压测环境注入并发冲突,用工具模拟高并发 UPDATE 相同行,观察业务的重试逻辑是否有效。很多人生产环境死锁发生后,代码里根本没有 1213 的捕获,这会直接导致整条业务链路失败。
  • 设计表时,高频更新的表尽量少加二级索引,避免维护索引时的额外锁开销。
  • 对批量任务,每条记录独立事务或小批事务,可以有效避免一个坏记录把整个批次拖入深渊。
  • 当死锁中涉及 DELETE 时,优先检查 WHERE 条件的索引情况,范围删除极易产生大间隙锁,尤其是和外键、唯一约束叠加时。

我个人在实际操作中最大的感受是:死锁不是程序 bug,而是并发系统里必然存在的一种现象。你消灭不了它,但可以通过事务设计、索引优化、隔离级别调整和重试机制,把它对业务的影响降到最低。每次遇到死锁,沉下心打开日志,把锁等待图还原出来,你会对数据库的并发模型有更深的理解。把这些经验沉淀成团队规范,后面的人就少踩很多坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询