MySQL死锁排查最佳实践:锁机制原理与高频死锁模式分析

简介: 以线上订单系统锁等待超时为切入,拆解InnoDB锁类型与死锁日志解读方法,总结交叉更新、唯一索引冲突、间隙锁重叠、外键级联锁四种高频死锁模式的排查与避坑经验

大家好,我是数据库小学妹 👋

前段时间被一个问题搞得焦头烂额。线上订单系统时不时报"Lock wait timeout exceeded"。重启应用能好一阵,过几天又来。一开始以为是慢查询,加了索引,没用。后来连续两天高峰期都出现了,订单创建成功率从99.9%掉到95%,客服开始投诉。

我当时第一反应是:是不是连接池不够?加了连接数,没好转。又以为是表锁,查了半天没发现LOCK TABLE语句。最后才搞明白——死锁。

排查过程挺折腾的。死锁发生得很快,可能就几十毫秒,InnoDB检测到会自动回滚其中一个事务。如果不主动去查日志,根本发现不了。

今天把排查过程中学到的东西整理出来。

30秒判断:是不是死锁

线上报锁等待超时,先别急着排查。跑一条SQL就能确认是不是死锁:

SHOW ENGINE INNODB STATUS\G

看输出里有没有"LATEST DETECTED DEADLOCK"这一段。有,就是死锁。没有,大概率是普通的锁等待超时(比如大事务持锁太久)。

还有一个更快的办法:

SELECT * FROM sys.innodb_lock_waits\G

如果有输出,说明当前有事务在等锁。看blocking_query字段,能直接定位是哪条SQL在阻塞别人。

确认是死锁之后,再往下看排查方法。

死锁是什么

简单说就是两个事务互相等对方释放资源,谁都走不动。

你拿着筷子等勺子,我拿着勺子等筷子,俩人就这么僵住了。数据库里也一样,事务A锁了行1想去锁行2,事务B锁了行2想去锁行1,互相等。

以前做设计的时候,我习惯把复杂流程画成流程图,理清依赖关系。死锁本质上就是流程图里出现了循环依赖。事务之间形成了一个环,谁都等谁,谁都走不了。

InnoDB检测到死锁会回滚其中一个事务,通常是undo log最少的那个,因为回滚成本最低。被回滚的事务会收到一个错误:ERROR 1213 (40001): Deadlock found when trying to get lock

先搞清楚InnoDB有哪些锁

要理解死锁,得先知道锁的类型。我刚接触锁的时候觉得概念特别多,后来自己画了一张表才理清楚。

行锁锁住索引记录。走索引才能用行锁,不走索引会退化成表锁。这个很多人踩过坑,包括我。我之前有个查询没走索引,结果行锁退化成表锁,整张表被锁住,其他事务全被阻塞。

间隙锁锁住索引记录之间的"间隙",防止其他事务在这个范围里插入数据。RR隔离级别下默认会有间隙锁。它是为了防止幻读,但副作用是:两个事务在同一个间隙上加间隙锁不冲突,有事务想INSERT才会被阻塞。

临键锁是行锁加间隙锁的组合,InnoDB默认用这个。它同时锁住记录本身和记录前面的间隙。

意向锁是表级锁,表示事务想加行锁。INSERT操作在间隙里还会加插入意向锁,多个INSERT可以共存。

这几种锁的兼容性决定了死锁会不会发生。我画了个简单的兼容矩阵:

行锁(X) 间隙锁(GAP) 插入意向锁
行锁(X) 冲突 不冲突 冲突
间隙锁(GAP) 不冲突 不冲突 冲突
插入意向锁 冲突 冲突 不冲突

看这个表就明白了:两个事务可以在同一个间隙里都加间隙锁,但有事务想在这个间隙里INSERT就会被阻塞。很多死锁就是这么来的。

怎么看死锁日志

遇到死锁第一步是看日志。

SHOW ENGINE INNODB STATUS

输出内容很多,找"LATEST DETECTED DEADLOCK"部分。里面有几个关键信息:事务信息(每个事务的ID、状态、等什么锁、持有什么锁)、锁信息(类型、模式、加在哪个表的哪个索引上)、被回滚的事务是哪个。

我第一次看这个日志的时候完全看不懂。后来发现一个技巧:先找"WE ROLL BACK TRANSACTION",确定被回滚的是哪个事务。然后看这个事务"HOLDS THE LOCK"和"WAITING FOR THIS LOCK",再看另一个事务的锁信息。两个事务的锁等待关系理清楚,死锁原因就出来了。

举个我实际遇到的例子。有一次线上报死锁,我导出日志,关键部分长这样:

LATEST DETECTED DEADLOCK
------------------------
2026-05-15 14:32:18 0x7f3c

*** (1) TRANSACTION:
TRANSACTION 2814735, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 1523, OS thread handle 0x7f3d, query id 98234
*** WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 28 page no 4 n bits 72 index `idx_user_id` of table `orders`
trx id 2814735 lock_mode X waiting

*** (2) TRANSACTION:
TRANSACTION 2814734, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 1520, OS thread handle 0x7f3e, query id 98230
*** HOLDS THE LOCK(S):
RECORD LOCKS space id 28 page no 4 n bits 72 index `idx_user_id` of table `orders`
trx id 2814734 lock_mode X

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 28 page no 5 n bits 72 index `idx_order_no` of table `orders`
trx id 2814734 lock_mode X waiting

*** WE ROLL BACK TRANSACTION (1)

我当时看这段日志的思路是这样的:先看WE ROLL BACK TRANSACTION (1),知道事务1被回滚了。然后看事务1在等什么——它在等idx_user_id上的X锁。再看事务2——它持有idx_user_id上的X锁,同时在等idx_order_no上的X锁。

两个事务分别在等对方持有的锁。事务1拿到了idx_order_no的锁想去锁idx_user_id,事务2拿到了idx_user_id的锁想去锁idx_order_no。加锁顺序不一致,死锁了。

定位到原因之后,解决办法就清楚了:统一加锁顺序,让所有事务都按idx_user_id→idx_order_no的顺序去锁。

我遇到的几种死锁模式

排查了十几个案例,总结了几种常见情况。

事务交叉更新

两个事务以不同顺序更新同一批记录。这个是最常见的死锁模式。

-- 事务A
UPDATE orders SET status = 1 WHERE id IN (1, 2, 3);

-- 事务B
UPDATE orders SET status = 2 WHERE id IN (3, 2, 1);

我遇到的场景是订单批量状态更新。一个接口把订单从待支付改成已支付,另一个接口把同一批订单从待支付改成已取消。两个请求同时进来,IN列表里的顺序不一样,就死锁了。

日志里表现为:两个事务都在等idx_primary上的X锁,但各自已经持有了不同行的锁。

解决办法:统一按主键顺序更新。看起来简单,但线上代码多了很容易漏。我后来在代码规范里加了一条:批量UPDATE必须先对ID列表排序。

唯一索引冲突

INSERT遇到唯一索引冲突时,InnoDB会在冲突的索引记录上加共享锁。两个事务同时INSERT相同的唯一值就会互相等。

-- 事务A和事务B同时执行
INSERT INTO users (email) VALUES ('test@example.com');

我遇到的场景是用户注册。两个请求用同一个邮箱同时注册,都走唯一索引校验。第一个INSERT成功,第二个等锁。如果第一个事务还没提交,第二个就会一直等。如果这时候还有第三个请求也来注册同一个邮箱,就可能形成死锁。

日志里表现为:一个事务在等S锁(共享锁),另一个事务持有S锁但也在等某个X锁。

解决办法:INSERT前先查,或者用INSERT ... ON DUPLICATE KEY UPDATE。我后来在注册接口加了分布式锁,同一个邮箱同一时刻只允许一个注册请求进来。

间隙锁范围重叠

两个事务的查询条件命中同一个间隙,间隙锁互相阻塞。这种死锁最难排查,因为日志里的锁信息看起来是"空的"——锁的不是具体记录,而是一个范围。

-- 事务A(RR隔离级别)
SELECT * FROM orders WHERE id > 100 FOR UPDATE;
-- 锁住(100, +∞)的间隙

-- 事务B
INSERT INTO orders (id) VALUES (150);
-- 想在间隙里插入,被阻塞

我遇到的场景是后台管理系统的订单导出。导出功能用了一个范围查询加FOR UPDATE,锁定了一大批记录。同时用户在前台下单,INSERT操作被间隙锁阻塞。并发一上来就死锁了。

解决办法:缩小查询范围,或者用RC隔离级别。RC下没有间隙锁,但要注意RC下可能出现幻读。如果业务允许,RC是更简单的方案。

外键级联锁

父表有外键约束,子表INSERT时会锁父表对应记录。多个事务同时往子表插入关联到同一条父记录的数据就可能死锁。

这个坑我排查了半天才定位到。当时是一个订单系统,order_items表有外键关联orders表。多个订单项同时插入,都关联到同一个订单ID,互相等父表的锁。

日志里表现为:事务在等父表记录上的S锁,但这个S锁和另一个事务的INSERT意向锁冲突。

解决办法:减少外键约束,业务层保证数据一致性。我后来把外键去掉了,改成应用层校验。去掉之后这类死锁再没出现过。

排查工具

除了SHOW ENGINE INNODB STATUS,还有几个实用的。

sys库直接查锁等待:

SELECT * FROM sys.innodb_lock_waits\G

输出里有几个关键字段:waiting_trx_id(等待锁的事务)、blocking_trx_id(阻塞它的事务)、waiting_query(被阻塞的SQL)、blocking_query(阻塞别人的SQL)。看到blocking_query就能快速定位是哪条SQL导致的阻塞。

performance_schema更详细:

SELECT * FROM performance_schema.data_lock_waits;

能看到锁的类型、模式、加在哪个索引上。配合data_locks表可以看到所有锁的详细信息。

我排查的时候一般先用sys.innodb_lock_waits快速定位阻塞关系,再用SHOW ENGINE INNODB STATUS看完整的死锁日志。两个工具配合用,排查效率最高。

慢查询日志也要看。死锁发生时被回滚的事务的SQL会记录在里面,看看是不是查询条件导致锁范围过大。

MySQL 8.0的变化

MySQL 8.0对锁机制做了不少改进,值得了解。

Performance Schema里的锁相关表从metadata_locks拆分成了data_locks和data_lock_waits,查询更精确了。之前的INFORMATION_SCHEMA.INNODB_LOCKS在8.0被废弃了。

死锁检测的效率也有提升。8.0引入了死锁检测的并行化,高并发场景下死锁检测本身不再成为瓶颈。之前遇到过一个场景,几百个事务同时竞争,死锁检测本身消耗了不少CPU,8.0之后好多了。

还有一个变化:8.0默认隔离级别还是RR,但间隙锁行为更精细了。具体哪些场景优化了我说不太全,但实际用下来,死锁概率确实比5.7低了一些。

避坑清单

统一加锁顺序,多表更新时按固定顺序访问。这个规则看起来简单,但线上代码多了很容易漏。我后来的做法是在DAO层封装一个统一的批量更新方法,强制排序。

缩短事务时间,持锁时间越长死锁概率越高。我见过最长的一个事务持锁超过30秒,原因是事务里调了一个外部接口。后来把外部接口调用移到事务外面,死锁问题直接消失了。

走索引,不走索引退化成表锁。之前有个查询条件没走索引,行锁退化成表锁,整张表被锁住,其他事务全被阻塞。加了索引之后锁范围从整张表缩小到几行。

控制锁范围,避免大范围SELECT FOR UPDATE。后台导出、报表查询这类操作尽量不要加FOR UPDATE,用MVCC的快照读就够了。

RC隔离级别可以减少间隙锁。如果业务对幻读不敏感,RC是更简单的方案。但要注意RC下不可重复读,同一个事务里两次读可能结果不一样。

应用层做好重试机制,死锁防不住就做好善后。InnoDB会自动回滚死锁中undo log最少的事务,应用层捕获到1213错误码后重试就行。重试次数建议不超过3次,间隔递增。


我是数据库小学妹,咱们下篇见 👋

相关文章
|
4月前
|
SQL 算法 关系型数据库
数据库大表ALTER最佳实践:pt-osc、gh-ost原理与生产调优
数据库小学妹分享大表ALTER避坑指南:详解Instant DDL(8.0+毫秒加字段)、pt-osc(触发器同步)与gh-ost(binlog解析)三大方案,剖析MDL锁、COPY/INPLACE算法及磁盘空间陷阱,助你安全完成在线DDL变更。(239字)
|
2月前
|
SQL 人工智能 自然语言处理
上线第一周就拦下1条危险SQL:Agent连库四道防线
从团队试点AI Agent做数据问答差点出事的真实经历出发,梳理Agent连数据库与传统用户连库的本质区别,拆解提示注入、误操作、查询风暴、敏感泄露四类风险,给出最小权限只读账号、高危SQL拦截、全链路审计、速率控制四道防线的实操方案与避坑清单。
|
2月前
|
SQL 监控 关系型数据库
磁盘98%告警,ibdata1占了320G:五个大户排查记录
以凌晨磁盘告警事故切入,逐一排查binlog、InnoDB表空间、undo日志、临时表、慢日志五个磁盘大户,覆盖MySQL 8.0的undo表空间管理和TempTable引擎变化,附自动清理脚本与监控配置
|
1月前
|
SQL 关系型数据库 MySQL
死锁报错看了三遍没看懂?我拆给你看(附定位SQL)
从一次真实死锁现场切入,讲清行锁、间隙锁、插入意向锁的加锁机制与死锁形成原理,手把手教你怎么用show engine innodb status和information_schema定位死锁,并给出加锁顺序设计等避坑清单。
|
2月前
|
SQL 运维 监控
慢查询日志的“高级用法”:从找慢SQL到做容量规划
慢查询日志是DBA最熟悉的工具,但大多数人只用它来找“跑得慢的SQL”。如果只做到这一步,你只用了慢查询日志20%的价值——剩下的80%是建立性能基线、预测容量瓶颈、评估优化效果、发现潜在风险。本文从慢查询日志的进阶用法出发,讲解如何通过持续记录慢查询建立性能基线、如何通过慢查询趋势预测容量瓶颈、如何将慢查询日志从“故障排查工具”升级为“容量规划工具”,帮助读者从“出了问题再查”升级到“看着趋势主动调整”。
|
1月前
|
存储 关系型数据库 MySQL
面试总问的B+树,我把磁盘IO到底怎么算的讲清楚了
从磁盘IO的底层约束讲起,逐层对比哈希、二叉、红黑树、B树与B+树,讲清MySQL为什么选B+树(矮胖树、顺序IO、范围查询、查询稳定),并用这套底层理解反过来指导覆盖索引、前缀索引、最左前缀等日常索引设计。
|
2月前
|
缓存 监控 NoSQL
命中率98%跌至23%,17条告警齐发:Redis缓存三大故障复盘
从618促销缓存雪崩事故切入,深度解析缓存穿透、击穿、雪崩的底层机制、生产级防御方案与监控告警策略,附布隆过滤器实现和分布式锁代码
|
2月前
|
人工智能 关系型数据库 MySQL
10分钟配置MCP,让AI Agent直接查你的MySQL
从"AI Agent怎么访问数据库"这个现实问题出发,梳理Agent连库方式的演进,讲清MCP协议的原理与价值,用MySQL实战演示如何配置一个MCP Server,并给出权限、安全、审计上的注意事项与避坑清单。
|
2月前
|
安全 关系型数据库 MySQL
切换从32秒缩到10秒,MHA到InnoDB Cluster升级复盘
从MHA停维护近十年、份额跌至12%的现实切入,完整记录从MHA一主两从升级到InnoDB Cluster的路径,含MySQL Shell建集群、Router切换、数据迁移与验证下线
|
2月前
|
存储 关系型数据库 MySQL
读写混合TPS差六倍,PostgreSQL与MySQL架构差异实测
从架构设计、索引实现、事务隔离、复制机制、运维体验五个维度深度对比PostgreSQL与MySQL,覆盖MySQL 9.0向量检索与PostgreSQL 17新特性,附权威基准数据和选型决策框架