死锁这个问题,做后端的基本都遇到过。日志拍在群里,两个事务互相等锁,看着像两头牛顶角,谁也不让谁。但真正要定位是谁先加的锁、卡在哪条索引、哪条SQL是元凶,光靠肉眼是不行的。这篇文章是MySQL实战系列的第13讲,闲话少说,直接聊最硬核的两张表:performance_schema.data_locks和performance_schema.data_lock_waits。这就是我说的“两个小公举”,一个负责告诉你“谁锁了谁”,一个负责告诉你“谁在等谁”。配合SHOW ENGINE INNODB STATUS里的死锁日志一起看,90%的MySQL死锁现场都能快速还原。这一讲的内容偏实战,适合已经被死锁折磨过的同学,也适合刚入门但想直接掌握排查套路的DBA和业务开发。
1. 死锁排查的整体思路拆解
1.1 死锁的本质:不是灵异事件,是资源等待闭环
先建立一个基础认知:InnoDB的死锁,本质上是两个或多个事务互相持有对方需要的锁,形成一个循环等待。注意关键词是“循环”。如果只是事务A等事务B,事务B不等事务A,那叫锁等待(lock wait),不叫死锁(deadlock)。死锁是锁等待的升级版,是“你等我、我等你、谁也没法退出”的状态。
MySQL检测到这个循环之后,并不会无限等下去。InnoDB内部有一个后台死锁检测器,一旦发现环,就会立刻挑选一个代价较小的事务回滚,释放它持有的锁。这就是你在业务日志里经常看到的Deadlock found when trying to get lock; try restarting transaction这条报错的由来。
很多人第一次遇到死锁,第一反应是“是不是代码写得有问题”。实际上,死锁和“代码写错”没有必然关系,它更像是一种资源调度的概率事件。同一段代码,只要加锁顺序不一致,或者走索引的路径不一致,就可能在不同时刻触发。所以排查死锁的第一步,不是改代码,而是先把现场捞出来,看清楚两个事务到底卡在哪个资源上。
1.2 传统排查手段的痛点:日志太干,现场太难抓
死锁发生的时候,最直观的信息来源是SHOW ENGINE INNODB STATUS,里面有一个LATEST DETECTED DEADLOCK段落,记录了最近一次死锁的两个事务、持有的锁、等待的锁。这个日志价值很大,但痛点也很明显。
第一个痛点是“覆盖”。SHOW ENGINE INNODB STATUS只保留最近一次死锁记录,新的死锁一发生,旧的就没了。线上一个死锁高发的系统,可能每隔几分钟就覆盖一次,等你看到日志的时候,凶手早就跑没影了。
第二个痛点是“静态”。死锁日志记录的是死锁发生瞬间的事务状态,但死锁发生的时间点已经过去了。日志里告诉你“事务A持有了SKU-1001的行锁,等待SKU-1002的行锁”,但不会告诉你“SKU-1001这个锁是哪个会话、哪条业务链路上加的、已经持有多久了”。这些信息必须在“死锁还没发生但正在等待”的时候,动态地抓取。
所以我说“两个小公举”是必备,就是因为它们能补上传统日志的这两个短板。data_locks和data_lock_waits是 performance_schema 里的实时锁视图,只要锁等待还在,你就能查到当前活生生的锁关系,而不是过期尸体。
1.3 两个小公举的定位:一个看静态锁,一个看等待关系
简单理解这两张表的分工:
data_locks:记录当前所有活跃的锁对象。每一行代表一把锁,包括表锁和行锁。你能看到锁的模式(X锁、S锁、间隙锁、插入意向锁)、锁的状态(已授予还是等待中)、锁在哪个索引上、锁的具体值是多少。data_lock_waits:记录锁之间的等待关系。每一行表示一个事务在等一把锁,而这把锁被另一个事务持有。这张表就是锁等待图的边,通过它能把“谁在等谁”直接关联起来。
在日常排查里,我通常先查data_lock_waits找到阻塞链,再按事务ID去data_locks看具体的锁细节。如果还有疑问,再回头翻SHOW ENGINE INNODB STATUS的死锁日志。这套组合拳,比单独看任何一个工具都完整。
2. 核心细节解析与实操要点
2.1 data_locks 的字段怎么看:别傻傻把表整张导出来
data_locks表的字段很多,实际关注的就那几个。很多人上来就SELECT * FROM performance_schema.data_locks,结果输出几百行,眼睛看花。正确姿势是聚焦关键列。
最重要的五个字段是:
ENGINE_TRANSACTION_ID:持有或等待锁的事务ID,这个是关联到information_schema.innodb_trx的钥匙。LOCK_TYPE:锁类型,TABLE表示表级意向锁,RECORD表示行级锁。LOCK_MODE:锁模式,常见的有X(排他锁)、S(共享锁)、X,GAP(排他间隙锁)、X,REC_NOT_GAP(排他行锁,非间隙部分)。注意X,GAP和X,REC_NOT_GAP的差别,前者锁的是间隙不锁记录,后者锁的是记录不锁间隙,而在可重复读隔离级别下,范围更新往往会同时加X,REC_NOT_GAP和X,GAP,形成Next-Key Lock。LOCK_STATUS:GRANTED表示锁已拿到,WAITING表示在等锁。这是一个非常重要的过滤器,排查死锁的时候,可以直接把LOCK_STATUS='WAITING'的行先筛出来,等待锁的事务就是“受害者候选”。LOCK_DATA:被锁住的具体值。对于主键锁,这里就是主键值;对于辅助索引锁,这里是辅助索引的键值,有时会附带主键值。这个字段能直接告诉你,事务卡在哪个具体行上。
实际操作中,我建议加过滤条件:
SELECT ENGINE_TRANSACTION_ID, OBJECT_NAME AS tbl, INDEX_NAME AS idx, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA FROM performance_schema.data_locks WHERE LOCK_STATUS = 'WAITING' OR ENGINE_TRANSACTION_ID IN (SELECT REQUESTING_TRANSACTION_ID FROM performance_schema.data_lock_waits);先看WAITING的行,找到受害事务,再看同表里ENGINE_TRANSACTION_ID是其他事务且LOCK_STATUS='GRANTED'的行,就能快速定位是谁持有的锁把它堵住了。
2.2 data_lock_waits 怎么用:一条SQL画出等待图
data_lock_waits的表结构更简单,核心就四个字段:
REQUESTING_TRANSACTION_ID:正在等待锁的事务ID。REQUESTING_ENGINE_LOCK_ID:它等的那把锁的ID。BLOCKING_TRANSACTION_ID:持有这把锁的事务ID。BLOCKING_ENGINE_LOCK_ID:持有锁的锁对象ID。
说白了,这张表就是一张有向图:BLOCKING_TRANSACTION_ID指向REQUESTING_TRANSACTION_ID。只要你把多行数据连起来看,就能画出完整的等待链。比如T1 -> T2 -> T3 -> T1,这就是一条典型的死锁环。
实际使用的时候,我一般不会单独查这张表,而是直接联查innodb_trx拿到事务的执行状态和SQL:
SELECT r.trx_id AS waiting_trx, r.trx_mysql_thread_id AS waiting_thread, r.trx_query AS waiting_query, b.trx_id AS blocking_trx, b.trx_mysql_thread_id AS blocking_thread, b.trx_query AS blocking_query, rw.LOCK_TYPE, rw.LOCK_MODE, rw.LOCK_DATA FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks rw ON w.REQUESTING_ENGINE_LOCK_ID = rw.ENGINE_LOCK_ID JOIN performance_schema.data_locks bw ON w.BLOCKING_ENGINE_LOCK_ID = bw.ENGINE_LOCK_ID JOIN information_schema.innodb_trx r ON r.trx_id = w.REQUESTING_TRANSACTION_ID JOIN information_schema.innodb_trx b ON b.trx_id = w.BLOCKING_TRANSACTION_ID;这条SQL查出来的每一行,都清晰告诉你:哪个事务在等、等的是哪行数据、被哪个事务堵住了、双方各自执行到了什么SQL。相比直接看死锁日志,这个结果要直观得多。如果有sys.innodb_lock_waits视图可以直接用,底层封装的也是这两个表。
2.3 SHOW ENGINE INNODB STATUS 的日志怎么读:抓住关键段落
虽然“两个小公举”能抓实时锁,但死锁日志依然是分析死锁原因的第一手证据。因为日志里不仅记录了锁的持有和等待,还记录了事务的执行历史和执行状态。
打开SHOW ENGINE INNODB STATUS后,直接找LATEST DETECTED DEADLOCK段落。重点看三个子段落:
第一个是每个事务的头部信息。类似:
*** (1) TRANSACTION: TRANSACTION 261376, ACTIVE 12 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 2 row lock(s) MySQL thread id 33, OS thread handle 140..., query id 548 ...TRANSACTION 261376是事务ID,ACTIVE 12 sec表示这个事务已经活跃了12秒,starting index read表示它当前正在做索引读取,LOCK WAIT 2 lock struct(s)表示它已经持有了2个锁结构,目前正在等待另外的锁。这些信息能帮你还原事务在时间轴上的状态。
第二个是HOLDS THE LOCK(S)和WAITING FOR THIS LOCK TO BE GRANTED段落。这里会直接给出持锁和等待锁的索引名、锁类型、锁值。比如:
*** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 12 page no 4 n bits 80 index PRIMARY of table `demo`.`stock_item` ... *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 12 page no 5 n bits 80 index PRIMARY of table `demo`.`stock_item`能明确落到具体表、具体索引、具体行。最后日志底部会写*** WE ROLL BACK TRANSACTION (2),告诉你这起死锁里哪一方被回滚了。
有一个细节必须提醒:死锁日志的详尽程度受参数影响。默认情况下日志里可能只有加锁范围说明,不打印每条记录锁的具体信息。建议在排障期间打开:
SET GLOBAL innodb_status_output_locks = ON;打开之后,SHOW ENGINE INNODB STATUS会输出每个事务持有的行锁明细,排查阶段非常有用。生产环境恢复后记得关掉,因为它会让错误日志显著膨胀。
3. 实操过程与核心环节实现
3.1 构造一个可复现的演示场景
纸上谈兵没用,直接搭一个最小复现环境。假设有一张库存表,结构如下:
CREATE TABLE `stock_item` ( `item_id` int NOT NULL, `sku_code` varchar(20) NOT NULL, `stock_qty` int NOT NULL, PRIMARY KEY (`item_id`), UNIQUE KEY `uk_sku` (`sku_code`) ) ENGINE=InnoDB; INSERT INTO stock_item VALUES (1, 'SKU-1001', 100), (2, 'SKU-1002', 100);模拟死锁的标准剧本就是“事务A先锁SKU-1001,再要锁SKU-1002;事务B反着来,先锁SKU-1002,再要锁SKU-1001”。两个事务各持有一把对方需要的锁,死锁一触即发。
这里特别说明一下为什么会死锁。两个事务都是更新同一张表的两个SKU,但加锁顺序相反。事务A持有SKU-1001的排他记录锁,事务B持有SKU-1002的排他记录锁。事务A继续执行更新SKU-1002时,必须等事务B释放锁;事务B继续更新SKU-1001时,必须等事务A释放锁。二者互相等待,环形成了,InnoDB立刻介入。
3.2 分步操作:先抓锁等待,再触发死锁
操作之前,先把日志开关打开。这一步很多人忽略,建议养成习惯:
SET GLOBAL innodb_status_output_locks = ON; SET GLOBAL innodb_print_all_deadlocks = ON;第一个参数让死锁日志显示行锁明细,第二个参数让所有死锁都写入MySQL错误日志,而不仅仅是覆盖内存里最近一次的死锁记录。排障阶段这两个开关价值极高。
然后开两个终端。终端A:
BEGIN; UPDATE stock_item SET stock_qty = stock_qty - 1 WHERE sku_code = 'SKU-1001';终端B:
BEGIN; UPDATE stock_item SET stock_qty = stock_qty - 1 WHERE sku_code = 'SKU-1002';注意执行完这两条,两个事务各自锁住一行,互不干扰,此时还没有等待关系。接着,终端A继续执行:
UPDATE stock_item SET stock_qty = stock_qty - 1 WHERE sku_code = 'SKU-1002';这一步会被阻塞,因为SKU-1002的行锁在终端B手里。先不要往下操作,马上在第三个终端执行查询,这时候data_lock_waits里就有数据了:
SELECT * FROM performance_schema.data_lock_waits\G你能看到类似这样的结果:REQUESTING_TRANSACTION_ID是终端A的事务,BLOCKING_TRANSACTION_ID是终端B的事务,等待的锁对象指向SKU-1002的记录锁。这是标准的“锁等待”,还不是死锁,因为等待关系是单向的。
接着让终端B执行最后一步,把循环补上:
UPDATE stock_item SET stock_qty = stock_qty - 1 WHERE sku_code = 'SKU-1001';这条语句会产生锁等待,但死锁检测器不会给它等待的机会。InnoDB检测到环存在后,立即回滚代价较小的一方。你会在终端B(或终端A)立刻收到类似Deadlock found when trying to get lock; try restarting transaction的报错。
3.3 实操输出分析:日志和两张表怎么对照
死锁发生后,马上在第三个终端执行:
SHOW ENGINE INNODB STATUS\G找到LATEST DETECTED DEADLOCK段落。日志里会列出两个事务:
事务1(假设被回滚)执行了更新SKU-1001的语句,持有SKU-1001的锁,在等待SKU-1002的锁;事务2持有SKU-1002的锁,在等SKU-1001的锁。日志结尾明确写着WE ROLL BACK TRANSACTION (1)。
这里有一个非常关键的实操点:死锁发生时,两个事务中只有一个会被回滚,另一个正常执行。所以当你只看到业务侧报了一条死锁错误时,不要以为死锁只影响了一个事务,而是要看日志确认另一方是谁,以及它的执行时长和SQL来源。
但是注意,死锁一发生,实时状态的data_lock_waits就查不到数据了,因为环已经断开。如果想用“两个小公举”来抓死锁瞬间的锁等待,必须在死锁发生前的双向等待窗口期去查。最有效的办法是写一个高频轮询脚本,每秒一次查data_lock_waits,把每次的查询结果落盘。这个办法在压测阶段最好用,能完整记录死锁车道上的每一组等待关系,比事后翻日志更直观。
如果只是看当前阻塞,还可以用sys模式封装好的视图:
SELECT * FROM sys.innodb_lock_waits\G这个视图直接输出wait_started(等待开始时间)、wait_age(已等待时长)、waiting_pid、blocking_pid、waiting_query、blocking_query。对于线上快速定位堵住业务的会话,比写长SQL更省事,是最快的一条路径。
3.4 动态追踪:事务活跃状态与当前SQL定位
有时候查到了持锁事务ID,但innodb_trx里trx_query字段是空的。这并不代表事务没有在做事,只是刚好处于“语句执行完但事务没提交”的空闲态。这个状态特别有迷惑性,很多人以为没有SQL在执行就不会持锁,但实际上事务只要没提交,持有的锁不会释放。
要定位持锁事务到底做了什么,可以用:
SELECT t.THREAD_ID, t.PROCESSLIST_ID, t.PROCESSLIST_USER, t.PROCESSLIST_DB, t.PROCESSLIST_TIME, t.PROCESSLIST_INFO FROM performance_schema.threads t WHERE t.PROCESSLIST_ID = 持锁线程ID;如果这个持锁会话已经空闲,PROCESSLIST_INFO也会显示为空。这时候就只能从业务侧查它的事务开启时间,或者查连接来源、最近执行过的语句记录(events_statements_history)。把事务ID、线程ID、业务代码日志放在一起对,通常能找到持锁源头。
4. 常见问题与排查技巧实录
4.1 死锁日志与实时锁视图对不上
这个现象很常见,先解释一下原理。SHOW ENGINE INNODB STATUS的LATEST DETECTED DEADLOCK记录是持久的,死锁发生后一直存在,直到下一次死锁才被覆盖。而data_lock_waits是实时的,死锁发生后锁等待关系立刻消失。所以如果你先看到死锁日志,再回头去查data_lock_waits,大概率查不到东西。
遇到这种情况不要慌,按这个顺序排查:先看LATEST DETECTED DEADLOCK日志确认死锁语句,再在代码里找到对应的事务逻辑,用“加锁顺序”的视角重新走一遍。如果怀疑当前还有锁等待但我行我素,这时候才用data_lock_waits抓实时等待。这两个工具不是互相替代,是时间维度上的互补。
4.2 线上死锁频繁但日志太少,怎么破
默认情况下,MySQL只在内存里保留最近一次死锁的信息,而错误日志里默认也不会打印每次死锁的详细信息。碰到死锁几分钟一次的系统,最省事的办法就是开innodb_print_all_deadlocks。
SET GLOBAL innodb_print_all_deadlocks = ON;打开之后,每一次死锁检测到,都会往MySQL错误日志(error log)里写一份完整的事务和锁信息。这个参数生产环境建议在排障期间开启,解决完问题后再关闭。因为死锁频率高的时候,错误日志增长非常快,磁盘会吃紧。
4.3 锁等待定位出来了,要不要马上KILL
很多同学看到sys.innodb_lock_waits里输出的blocking_pid,第一反应就是KILL。这个操作要谨慎。KILL一个持锁会话的代价是:它的事务会被回滚,已经执行的所有修改全部撤销。如果它是一个跑了很长时间的大事务,回滚本身可能耗费几秒甚至几十秒,期间资源开销也不小。
我的习惯是,先确认这个持锁会话的来源和业务场景。如果它是测试脚本、手工查询、或者是已经超时的僵死会话,直接KILL没问题。如果它是核心业务链路上的一个重要事务,更好的做法是先联系对应的业务负责人,确认为什么事务这么久不提交,从代码层面解决,而不是一刀切。
4.4 常见死锁场景速查表
这里整理一张速查表,方便日常对照:
| 场景特征 | 典型表现 | 排查重点 |
|---|---|---|
| 多个事务更新多条记录,顺序相反 | 日志中两个事务各持一行锁,互相等待对方的锁 | 统一业务加锁顺序,按主键排序更新 |
| 范围查询/更新导致间隙锁冲突 | 日志中出现X,GAP、S,GAP锁模式,场景常见于非唯一索引范围条件 | 检查SQL是否走了非唯一索引范围扫描,必要时用唯一索引或降低隔离级别 |
| 唯一键冲突加锁 | 插入语句因重复唯一键触发S锁等待,多事务并发插入同键值导致死锁 | 关注LOCK_MODE中S锁与X锁组合,考虑业务幂等或预先查询 |
| 外键检查加锁 | 子表插入时父表对应行加S锁,多子表并发插入同一父记录 | 检查是否真的需要外键约束,考虑应用层保证引用完整性 |
| 大事务 + 辅助索引更新 | 一个事务更新了大量行,持锁数量大,后续事务等待时间长,概率性死锁 | 分批处理大事务,缩短单事务执行时间 |
4.5 经验总结:怎么从代码层面根绝死锁
工具只是帮你看清现场,真正解决问题还是要改业务逻辑。根据我个人经验,大部分死锁都可以通过三招解决。
第一招是固定加锁顺序。多个事务要更新同一批资源时,规定必须按同一个顺序操作。比如先按主键从小到大排序,再执行更新。这条规则在大多数业务场景下非常简单有效,成本最低。
第二招是缩短事务时间。事务里不要做耗时操作,比如远程接口调用、大批量查询、多轮交互。锁持有时间越短,碰到死锁的概率越低。
第三招是降低锁粒度。尽量走唯一索引或主键定位到少量行,避免大范围扫描。范围更新产生的间隙锁是死锁的温床,能用唯一索引定位的,就不要用非唯一索引范围条件。
最后再分享一个我自己的习惯:新功能上线前,强制做一轮并发压测,把innodb_print_all_deadlocks打开,盯data_lock_waits的输出。压测阶段抓死锁,成本最低。等上了生产再救火,既伤脑筋又伤业务。这一讲把两个小公举的用法讲透了,后续如果再遇到死锁,至少不会一脸懵。