大家好,我是数据库小学妹 👋
前段时间被一个问题搞得焦头烂额。线上订单系统时不时报"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次,间隔递增。
我是数据库小学妹,咱们下篇见 👋