凌晨两点告警群炸了:订单接口集体超时,日志里刷屏Lock wait timeout exceeded; try restarting transaction。登上去一看,SHOW PROCESSLIST里一堆 query 在排队,却看不出谁在挡谁。
这个场景几乎每个 DBA 和后端都撞过。InnoDB 的锁信息不在慢日志里、也不在业务日志里,得去 performance_schema 里翻。今天把线上排查锁等待和死锁的 6 招整理成一份可以直接照抄的清单,前 3 招负责"找到人",后 3 招负责"别再犯"。
先分清两种报错,入口不一样
报错 | 含义 | 排查入口 |
1205 Lock wait timeout | 等行锁超过innodb_lock_wait_timeout(默认 50 秒),只回滚这一条 SQL | 查锁等待视图,找 blocking_pid |
1213 Deadlock found | 双向循环等待被 InnoDB 主动检测出来,回滚了代价较小的一个事务 | 看SHOW ENGINE INNODB STATUS的 LATEST DETECTED DEADLOCK 段 |
很多人把这两种当成一回事去处理,结果翻半天日志找不到死锁记录——1205 根本不产生死锁记录,它只是"等太久了"。
第 1 招:一眼看谁在等谁
这是最常用的一招。有人还在网上抄 5.7 时代的information_schema.INNODB_LOCK_WAITS,注意 **MySQL 8.0 已经把这两张表删掉了**(5.7 就标了弃用),照搬会报 unknown table。8.0 直接用sys.innodb_lock_waits:
SELECT waiting_pid, waiting_query,
blocking_pid, blocking_query,
wait_age_secs,
sql_kill_blocking_connection
FROM sys.innodb_lock_waits\G
-waiting_pid:被卡的连接
-blocking_pid:挡住别人的元凶
-wait_age_secs:已经等了多少秒
- 最后一列直接把KILL xxx语句替你拼好了
一个常见的困惑:**blocking_query显示 NULL 很正常**。元凶事务可能正开着但闲着——早先执行完一条 SQL 就没再发语句,等着业务下一步。这时去看blocking_trx_started,就知道它把事务开了多久。
第 2 招:从事务反查连接和长事务
SHOW PROCESSLIST只能看到当前语句,看不到"这个连接把事务开了多久"。长事务才是锁等待的温床,去information_schema.innodb_trx里按开始时间倒着排:
SELECT trx_id, trx_mysql_thread_id,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS alive_secs,
trx_rows_locked, trx_rows_modified,
trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
trx_rows_locked看锁了多少行,alive_secs看事务活了多久。线上常见画像:一个连接在事务里查完数据,转头去调第三方接口,接口超时 30 秒,期间它手里的行锁一个都不放,后面所有相关更新全部排队。
要把事务精确关联到 performance_schema 里的会话,用performance_schema.threads的PROCESSLIST_ID(就是SHOW PROCESSLIST里的 Id,也就是KILL命令用的那个 id)。注意别拿内部THREAD_ID去 kill,那是另一个体系。
第 3 招:看懂 LOCK_MODE,别被意向锁吓到
锁的明细在performance_schema.data_locks里。很多新人第一次查到LOCK_MODE = IX、LOCK_TYPE = TABLE就慌了,以为表被锁死。
其实要按LOCK_TYPE分开看:
LOCK_TYPE | LOCK_MODE | 含义 | 会不会挡人 |
TABLE | IS / IX | 意向锁,表示"有事务打算在这个表上加行锁" | 只和表级锁(LOCK TABLES)冲突,不挡行操作 |
RECORD | X | Next-Key 锁:锁记录 + 记录前面的间隙(RR 默认) | 挡本记录的写,也挡往间隙里插入 |
RECORD | X,REC_NOT_GAP | 纯记录锁,只锁这一行 | 挡本记录的写 |
RECORD | X,GAP | 纯间隙锁,只锁区间不锁记录 | 挡往间隙里插入,不挡已存在行的读写 |
判断谁真正挡了谁,别靠猜,直接看performance_schema.data_lock_waits的等待关系;最近一次死锁的完整细节(两个事务各持哪把锁、各在等哪把锁、最后回滚了谁)在SHOW ENGINE INNODB STATUS\G的 LATEST DETECTED DEADLOCK 段里。
锁类型对照
第 4 招:最高频的成因——没走索引的 UPDATE
线上"整张表被锁住"的案件,十有八九是这条:
-- status 没索引
UPDATE orders SET status = 2 WHERE status = 1;
执行计划走全表扫描,InnoDB 会给**扫到的每一行**都加锁;在 REPEATABLE-READ 下还伴随间隙锁,效果上等于把整张表锁了。看起来像"锁升级",其实没有锁升级这回事,就是锁的范围跟着扫描范围走。
习惯在建表和改锁问题之前先跑一遍:
EXPLAIN UPDATE orders SET status = 2 WHERE status = 1;
type = ALL、扫描行数接近全表,就是它了。补上索引,锁范围立刻从全表缩到命中的那几行。这是性价比最高的一个修复,没有之一。
顺带两个容易踩的坑:
1. **唯一索引上不存在的行也会被间隙锁挡住**。RR 下SELECT ... FOR UPDATE一条不存在的记录,锁的是它前后的间隙,别人往这个区间 INSERT 会被卡住。
2. **INSERT ... ON DUPLICATE KEY UPDATE和REPLACE INTO并发跑是死锁高发区**,撞的是唯一索引的间隙锁和 S 锁,典型表现就是偶发 1213,单并发复现不了。
第 5 招:从写法上减少锁冲突
找到了人、也修好了索引,还要管住代码里的事务写法。四条纪律按优先级排:
1. **多行更新固定顺序**。所有事务都按主键从小到大更新,循环等待天然不存在。这也是消灭死锁最根本的一条。
2. **大批量改小批量**。UPDATE ... LIMIT 1000配合循环,每批一个事务,锁持有时间从分钟级降到毫秒级。
3. **事务里不做慢操作**。远程调用、发消息、算大数,都挪到事务外。
4. **热点行考虑 READ-COMMITTED**。RC 下间隙锁基本关闭(外键和唯一键冲突检查除外),锁范围明显收窄;改之前和业务确认一致性语义。
-- 分批更新模板:固定顺序 + 小批量
UPDATE orders SET status = 2
WHERE status = 1
ORDER BY id
LIMIT 1000;
-- 影响行数为 0 时结束循环
MySQL 8.0 还给了两个开关:FOR UPDATE NOWAIT(遇到锁直接报错不排队)和FOR UPDATE SKIP LOCKED(跳过被锁的行)。做任务表抢任务的场景,SKIP LOCKED比自己造排队逻辑干净得多。
反例与推荐写法
第 6 招:参数与止血的正确姿势
三个参数值得提前配好:
参数 | 默认 | 作用与提醒 |
innodb_lock_wait_timeout | 50 | 行锁等待超时秒数,会话级可改;**只对行锁等待生效,不影响死锁** |
innodb_deadlock_detect | ON | 超高并发打同一热点行时,检测本身会烧 CPU,官方允许关掉改靠超时兜底;普通业务别关 |
innodb_print_all_deadlocks | OFF | 开启后每次死锁都写 error log,线上排查靠它攒证据 |
真要止血时,KILL的姿势有个大坑:
KILL QUERY 12345; -- 只杀当前正在跑的语句,事务还开着,锁一个不释放
KILL CONNECTION 12345; -- 断开连接并回滚事务,锁才真正放掉(KILL 12345 等价)
所以优先 kill 阻塞链条最上游的元凶(blocking_pid),别去杀等待者——杀掉一个等待者,它身后排队的照旧堵着。另外元凶如果是改了很多行的大事务,回滚本身要跑一阵子,锁释放有延迟,杀完用第 1 招确认sys.innodb_lock_waits清空了再收工。
常见故障速查表
现象 | 最可能的原因 | 第一步动作 |
报 1205 Lock wait timeout | 行锁等太久 | 查sys.innodb_lock_waits找 blocking_pid |
报 1213 Deadlock found | 加锁顺序不一致等循环等待 | 看 error log / LATEST DETECTED DEADLOCK |
大量 query 集体卡住 | 单个长事务持锁不放 | 查innodb_trx按trx_started排序 |
blocking_query为 NULL | 元凶事务开着但空闲 | 看blocking_trx_started和trx_rows_locked |
像"锁了全表" | 更新条件没走索引 | EXPLAIN验证,补索引 |
偶发 1213,难复现 | 唯一键冲突写法(ON DUPLICATE KEY/REPLACE) | 固定加锁顺序,或改普通INSERT |
写在最后
锁问题的排查链路其实很固定:**报错码定性 → 锁等待视图找元凶 → 事务表看持有时间 → LOCK_MODE 看锁粒度 → 索引和写法修根因 → 参数兜底**。前两招背熟,90% 的锁告警能在 5 分钟内定责。
真正值钱的是第 4、5 招的预防:建表时就让更新条件有索引可用,写代码时按顺序、小批量、事务里别干慢活。死锁和锁等待不是玄学,是写法问题的延迟爆发。
排查速查
这套排查路径在云贝教育的 MySQL 实战课里是拿真实故障库练的,命令怎么敲、结果怎么看,对着练一遍比自己摸半天强。