上周三下午两点多,业务方在群里说下单接口全卡住了。我正在改配置,手机就一直震。等我登上生产库一看,进程列表里杵着一条 UPDATE 语句,已经跑了快 40 秒,后面排着两百多个 waiting 状态的连接。应用层的超时是 3 秒,所以前端看到的不是「慢」,而是一大片 504。
最终定位下来不是机器性能问题,也不是那条 SQL 写得烂(它本身只更新两行),而是 InnoDB 的间隙锁在作祟。那条 UPDATE 的 WHERE 条件走了一个没覆盖到的索引,于是 InnoDB 把整张订单表按扫描顺序一行行加了锁,前面那个事务还抱着锁不放,后面 300 个写请求就这么干等着。
如果你手上也有 MySQL 实例要跑订单、库存这类写密集的业务,下面两家机器可以直接对比着看:狗云DOGYUN(高性价比云服务器,机型齐全,适合单独拉一台做锁等待复现和压测)、RackNerd(海外 VPS 与大存储机型,磁盘余量足,binlog 和慢日志撑得住)。机器别配太高,关键是要能随时复现锁问题内存和 IOPS 差一点无所谓,能跑起来能看 SHOW PROCESSLIST 就行。

一、间隙锁锁的不是行,是行与行之间那段空隙
先纠正一个我以前也搞混的点。MySQL 的行锁并不只锁「这条记录」,InnoDB 走索引扫描的时候锁的是「索引记录 + 记录之间的间隙」,这组合叫 next-key lock(临键锁)。所谓间隙锁,可以理解成给两行之间那段本来没有数据的空隙上了一把锁,别的语句想往这个空隙里插数据,就只能等。
为什么要有这么麻烦的机制?举个例子:表里 id 是主键,现在有 id=10 和 id=20 两条记录,id=15 这个值并不存在。如果事务 A 执行 `UPDATE t SET ... WHERE id=15`,在可重复读隔离级别下,它必须锁住 id=15 这个位置,否则事务 B 插一条 id=15 的记录进来,幻读就成立了。A 的本意是「把 id=15 改成别的」,B 却凭空多出一条 id=15,两边都提交,数据就自相矛盾。
所以间隙锁是防幻读的代价。理解了这一点,很多反直觉的现象就说得通了:明明我只 update 一条不存在的记录,却把一大片写入全挡住了;明明两条语句改的是完全不同的 id,却会互相等待。
而间隙锁能不能生效、锁住多大范围,取决于两件事:隔离级别和有没有走索引。读已提交(RC)级别下基本不产生间隙锁(除了外键检查、唯一键冲突这几处),可重复读(RR)才是重灾区。而一旦 WHERE 条件没走上索引,扫描就会退化成全表扫描,引擎会把扫过的每一行连同间隙都锁上一张几十万行的表,直接变成「全表锁」的等价物。

二、我那条 UPDATE 是怎么把整张表锁住的
还原一下当时的情况。订单表大概 180 万行,主键 id,另外有一个 status 字段,业务上经常按 status 过滤。线上那条 SQL 是这样的:
UPDATE orders SET paid_at = NOW() WHERE order_no = 'A2026xxxxxx' AND status = 2;
问题出在 order_no 上没有索引(当时这张表只有主键 id 和一个 status 索引)。引擎只能全表扫描找 order_no,等价于按 id 顺序把 180 万行全过一遍,每一行都加上 next-key 锁,直到找到那一行才停下。可它没停下来因为那条 UPDATE 前面还带了一个先 SELECT 再判断的业务逻辑(那段逻辑写在应用层的存储过程里),整个事务开了 30 多秒还没提交。
结果就是:前面 120 万行被锁住,后面的写请求全部排队。那 40 秒里我把 kill 掉那条 UPDATE 都失败了两次,因为它的行数太大,kill 需要回滚。最终是靠重启应用把长事务踢掉才恢复的。
这次事故真正让我记住的教训是:间隙锁的范围是「扫描到哪里,就锁到哪里」。它跟「你逻辑上只想改几行」没有任何关系,只跟引擎实际扫过的索引区间有关。所以任何一条 SQL 上线前,我都会先确认它的执行计划走的是哪个索引。

三、线上抓现行:三段 SQL 定位到具体那条语句
数据库一慢,第一反应是看 CPU 和 IO,但锁等待的场景下 CPU 通常很闲。这时候我一般先跑这三段。
第一段,看谁在等:
SELECT * FROM information_schema.innodb_trx ORDER BY trx_started;
重点看两列:trx_started(事务开始时间)和 trx_state。trx_state 为 LOCK WAIT 的就是被堵住的;trx_mysql_thread_id 是连接 ID,记下来可以直接 KILL。而 trx_started 距今很久但还在 RUNNING 的那条,多半就是罪魁祸首的长事务。
第二段,看谁在挡:
SELECT * FROM performance_schema.data_lock_waits;
MySQL 8.0 才有这张视图,它直接把「等待方」和「阻塞方」连起来了,REQUESTING_ENGINE_LOCK_ID 和 BLOCKING_ENGINE_LOCK_ID 一对比就清楚了。7.0 环境没有的话,就退回到老办法:
SHOW ENGINE INNODB STATUS\G
看输出里 LATEST DETECTED DEADLOCK 那段(这是死锁),以及 TRANSACTIONS 段里的 lock wait 那几行。个人感觉这个输出比 5.7 的 innodb_lock_waits 更好读,5.7 是另一张 information_schema.innodb_lock_waits。
第三段,看锁的范围:
SELECT * FROM performance_schema.data_locks WHERE OBJECT_NAME = 'orders';
能直接看到锁住的索引名、记录模式(RECORD / GAP / NEXT_KEY / INSERT_INTENTION)。这就是判断「是不是间隙锁」的铁证LOCK_MODE 里写着 GAP 或 NEXT_KEY 的,就不是我靠猜的了。
三次查询跑完,基本能得出结论:哪条 SQL、哪个索引、锁了多大区间、持锁多久。如果当时没有开 performance_schema,也可以先用 `SHOW FULL PROCESSLIST` 看 State 字段里的具体值(大概率是「Waiting for table metadata lock」以外的「updating」或「Waiting for record lock」)。

四、死锁和锁等待不是一回事,处理方式也不同
顺便把容易混的两件事分清楚。锁等待是「我在等你放」,死锁是「我在等你,你也在等我」,互相等就成环了。InnoDB 有死锁检测,会主动挑一个牺牲者回滚,报 1213 错误;锁等待则不会自解,只能等到锁释放或者超时(默认 innodb_lock_wait_timeout 是 50 秒,报 1205)。
线上碰到 1213 的处理思路是找两个事务的 SQL,看它们的加锁顺序是否相反最典型的就是两个事务都先更 A 表再更 B 表,但一个是 A→B 另一个是 B→A。解法也很直接:给所有涉及多表的更新定一个全局顺序,比如按表名字母序,全系统照着来。
碰到 1205 长时间锁等待,除了等和 kill 之外,我更倾向于事后修 SQL,因为 kill 大事务的回滚成本可能比让它跑完还高。有一次 kill 了一个跑了 20 分钟的批量任务,binlog 回滚了 40 万条,那台机器整整假死了一个小时。所以我的原则是:先用 kill 止血保证业务可用,事后一定要把这个长事务拆掉。
五、我后来改的五件事
一、补索引,把扫描范围收窄。那次事故的直接修复就是给 order_no 加了唯一索引,加完之后执行计划从 ALL 变成 ref,锁的范围从 120 万行缩到 1 行。
二、给批量更新分段。不管有没有索引,改 10 万行这种事我都会拆成 5000 行一批,每批之间 sleep 几十毫秒再提交。间隙锁最怕的就是一个超长事务。
三、把事务边界缩到最短。应用层那些「先查再判断再更新」的逻辑,尽量改成单条 SQL 用原子操作表达,实在改不动就把事务挪到存储过程里,别让业务代码在事务中间去做 RPC 调用。我见过一段代码在事务里调了 HTTP 接口,那 800 毫秒的外部延迟全被算进事务持有时间了。
四、监控上加两个指标。一是长事务数量与最长事务时长(超过 5 秒的单独告警),二是锁等待次数。现在我们的 Grafana 上专门有一块「InnoDB 锁」面板,看 LOCK MODE 分布,一出现大量 GAP 就有人去查。
五、把隔离级别当作可选参数,而不是教条。不是所有业务都需要 RR。像纯报表统计、跑一次就结束的批处理,用 RC 能少很多锁竞争,也少很多间隙锁带来的莫名其妙等待。我一般先问一句「这个业务能接受幻读吗」,能接受就直接上 RC。

六、给新接手的人的一份速查清单
最后把这次事故沉淀成一张我自己贴在工位的清单:
一、新表上线前,WHERE 条件涉及的每个字段都确认有索引,尤其是「看起来会走主键但实际带了别的条件」的组合。
二、`EXPLAIN` 看 type,出了 ALL 和 index 就停下来想。
三、线上出现大面积超时,先看 SHOW PROCESSLIST 里有没有长时间 running 的更新语句,别一上来就查 CPU。
四、排查锁问题优先看 information_schema.innodb_trx 和 performance_schema.data_lock_waits,别急着重启。
五、批量任务一律分批提交,每批之间留间隔。
六、事务里不放 RPC、不放文件 IO、不放人机交互。
这次事故从发现到恢复,大概四十分钟。真正让我后怕的不是那 40 秒的卡顿,而是我发现这条 SQL 在测试环境跑了半年都没出事测试库只有两千行,扫全表也是毫秒级。所以我现在的规矩是:任何写操作上线前,先在生产同量级数据上跑一次 EXPLAIN,小数据量下的执行计划参考价值有限。


喜欢
顶
难过
囧
围观
无聊



