一次"数据库没死但网站不动"的故障
先说清楚这篇文章要解决的问题,避免和站点上已有的几篇混淆。站上已经写过 SHOW ENGINE INNODB STATUS 读死锁(那是"两个事务互相等,MySQL 主动回滚一个"),也写过 MDL 元数据锁阻塞(那是"一条 SELECT 把 ALTER TABLE 挡住了,连带把整个表的所有请求都挡住")。这两篇讲的是两种特定的锁现象。
这篇文章讲的是最普遍、也最容易被误判的那一类:普通的行锁等待,因为持锁事务迟迟不提交,导致后来的一堆请求在队列里排队,最终表现为"网站转圈但数据库看起来没什么异常"。
这个故障的迷惑性在于:MySQL 进程活着,CPU 不高,连接数正常(没有爆),慢查询日志里那几条语句单独执行都只要几毫秒。你去检查每一个组件,都找不到问题。但用户那边,加入购物车、提交订单、甚至只是打开一个页面,全都在等几秒甚至几十秒。
难点在哪儿?在于"慢"这件事没有发生在这条 SQL 自己身上,而是发生在它等待别人的那段时间里。而你如果只看这条 SQL 的执行计划、只看它的索引,永远找不到答案。你得去看"谁在持锁、持了多久、队列里排了多远"。这篇文章就是讲怎么把这三件事读出来。所有命令都在 MySQL 8.0 上验证过。
先分清锁等待和死锁,两者处理方式完全不同
很多人一遇到锁问题就去看死锁日志,这是个常见的误区。锁等待和死锁是两种不同的状态,只有前者需要你人工干预,后者 MySQL 自己会处理(回滚代价小的那个事务)。
死锁:A 等 B 的锁,B 等 A 的锁,形成环。MySQL 的死锁检测器会立刻发现这个环,然后回滚其中一个事务,另一个得以继续。特征是——事务会收到 Deadlock found when trying to get lock 错误,问题自动消解。你的任务是去读死锁日志,改掉"两个事务以不同顺序访问同一批数据"的代码逻辑。
锁等待:A 持有锁,B 在等 A 释放。这不是环,只是单向的排队。MySQL 不会主动干预,只会让 B 一直等,直到超过 innodb_lock_wait_timeout(默认 50 秒)才给 B 报错。特征是——事务会挂起数十秒,用户那边就是长时间转圈。
关键区别在于:死锁自己会好,锁等待不会好。如果 A 的事务因为某个原因(比如代码里忘了 commit、或者 A 在等一个外部 HTTP 接口)一直不结束,B 就得一直等下去,而 B 后面的 C、D、E 又都在等 B 释放它已经拿到的锁——一条链就形成了。这才是"网站全站不动"的真正原因。
最核心的一个视图:innodb_trx
要判断是不是锁等待,第一条命令永远是这句:
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS elapsed_sec,
trx_mysql_thread_id, trx_rows_locked, trx_rows_modified,
trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started ASC;innodb_trx 是 InnoDB 公开的"当前活跃事务"视图,它不显示已提交或已回滚的事务,只显示正在跑的。几个字段要会读:
trx_state:事务状态。看到LOCK WAIT就是"正在等锁",这是最直接的信号。看到RUNNING却长时间存在,说明它在执行,可能是在等外部资源。trx_started加上elapsed_sec:事务已经跑了多久。一个事务存活了几十秒甚至几分钟,几乎一定有问题——正常的 OLTP 事务应该是毫秒级的。这是最重要的判读指标,比看trx_state还重要,因为持有锁的那个事务状态很可能是RUNNING(它没在等任何人),你只有通过"它活了多久"才能发现它是元凶。trx_rows_locked:这个事务锁住了多少行。一个UPDATE语句本意改一行,如果这个数字是几十万,说明它走了全表扫描,把整张表都锁了——这是缺少索引导致的锁范围爆炸。trx_query:事务当前正在执行的语句。注意这个字段只显示"当前"语句,不显示历史上执行过的所有语句,所以它可能是NULL(比如事务已经执行完语句但还没提交)。看到 NULL 不要以为没信息,NULL + 长时间存活,恰恰说明"业务代码执行完了业务逻辑,但卡在了 commit 之前"——典型的应用层 bug,比如两个数据库连接的提交顺序错了,或者中间夹着一次远程调用。
用这个视图做判读,最经典的形态是这样的:一条记录 trx_state=RUNNING、存活 180 秒、trx_query 是 NULL;下面跟着七八条 trx_state=LOCK WAIT、存活几十秒、trx_query 是同一个 UPDATE users SET ...。这个画面已经把结论写在脸上了:一个不知为何不肯提交的事务占着锁,一群更新请求排着队。
第二步:把"谁挡住了谁"连起来
innodb_trx 告诉你有哪些事务在等,但不告诉你是被谁挡的。要看锁的依赖关系,得用性能模式里的数据锁视图:
SELECT
w.REQUESTING_ENGINE_TRANSACTION_ID AS waiter_trx,
w.REQUESTING_THREAD_ID AS waiter_thread,
r.BLOCKING_ENGINE_TRANSACTION_ID AS blocker_trx,
r.BLOCKING_THREAD_ID AS blocker_thread,
w.OBJECT_SCHEMA, w.OBJECT_NAME,
w.LOCK_TYPE, w.LOCK_MODE,
r.LOCK_TYPE AS blocker_lock_type
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks r
ON r.ENGINE_LOCK_ID = w.BLOCKING_ENGINE_LOCK_ID;这一条查询是整个排查里最值钱的一条,它直接把"等待者"和"阻塞者"配对输出。几个字段的含义:waiter_trx 是排队的人,blocker_trx 是插队插最前面的人;OBJECT_NAME 告诉你锁在哪张表上;LOCK_TYPE 为 RECORD 表示行锁,为 TABLE 表示表级意向锁(通常伴随行锁一起出现)。
有了 waiter_thread 和 blocker_thread,你就可以去查这些连接是谁、在干什么:
SELECT PROCESSLIST_ID, PROCESSLIST_USER, PROCESSLIST_HOST,
PROCESSLIST_DB, PROCESSLIST_TIME, PROCESSLIST_STATE, PROCESSLIST_INFO
FROM performance_schema.threads
WHERE PROCESSLIST_ID IN (12345, 12346, 12347);把 PROCESSLIST_HOST 和 PROCESSLIST_INFO 结合起来看,你就能回答"是哪个应用服务器上的什么操作引发了这次锁等待"——比如 HOST 显示是内网的一台应用机,INFO 显示是那条更新语句。这一步完成后,根因定位就从"数据库有问题"收敛到了"某个具体业务操作的某个具体写法有问题",这才能改。
顺带说一个 MySQL 版本差异,这个坑很容易踩:MySQL 5.7 及更早版本没有 data_lock_waits 这个视图,那时要用 information_schema.innodb_locks 配合 innodb_lock_waits。innodb_locks 在 8.0 里已经被移除。如果你的服务器还在 5.7,别照抄上面的 SQL,会报表不存在——先去 SELECT VERSION(); 确认版本再动手。
为什么锁等待会滚雪球一样扩大
理解这条传播链,才能理解为什么"一个慢事务"能搞垮"整站"。假设有一张 users 表,业务里有一个操作会锁住某个用户的行。
- 事务 A 拿到了 user_id=100 这一行的排他锁(
X锁),然后开始做别的事情——比如调一个第三方接口校验实名信息。 - 事务 B 也要改 user_id=100,发现锁被占了,进入
LOCK WAIT。关键在于:InnoDB 的行锁是排队获取的,B 虽然还没拿到锁,但它已经排在了这个等待队列里。 - 事务 C 也要改 user_id=100,它排在 B 后面。
- 如果 B 这条语句除了 user_id=100,还顺手改了另一个条件命中的行(比如
UPDATE users SET ... WHERE type=1),那 B 在等待期间,它已经持有的其他行的锁也会被当成等待链的一环,把等待扩散到别的行、甚至别的表上。
这就是滚雪球:等待本身会被当成"某种持有"继续阻塞后来者。所以一个卡住的事务,往往不是导致一条请求变慢,而是让一波请求集体变慢。理解了这一点,你就明白了为什么"赶紧把那个事务 kill 掉"能立刻恢复全站,而不是"等它自己跑完"。
怎么在故障现场快速恢复
故障正在发生的时候,第一优先级是恢复服务,根因分析放到后面。恢复动作只有一个——杀掉持有锁的那个事务。但杀谁、怎么杀,有讲究。
确定要杀的线程 ID 之后(从上一步的 blocker_thread 拿),执行:
KILL 12345;这个命令会终止该连接。注意几个细节:
不要批量 kill 所有 LOCK WAIT 的连接。那些都是无辜的排队者,杀掉它们只是把排队的人赶走,持锁的人还在,问题没解决,而且会让用户看到一堆报错。只 kill 那个 blocker。blocker 被终止后,它持有的锁立刻释放,排队的事务会依次拿到锁继续执行,用户侧表现为"卡住的请求突然都成功了"。
kill 之后要确认事务真的回滚了。KILL 是异步的,InnoDB 需要回滚这个事务已经做的修改。如果这个事务改了几百万行(trx_rows_modified 很大),回滚本身也要花很长时间,期间资源仍然被占用。杀完立刻再查一次 innodb_trx,确认那个 trx_id 消失了。如果还在,说明正在回滚,只能等——这时候要有心理准备,回滚可能比正常执KILL更慢。
先看清楚再杀,别养成条件反射。有一个真实的反例:有人看到 LOCK WAIT 就 kill,结果杀的是那条正在做大批量数据修正的离线任务——那个任务本身是计划内的、必须完成的,杀完之后业务数据只改了半截,反而制造了一个数据修复问题。所以判断标准不是"它在等待",而是"它是不是因为代码缺陷或者外部依赖卡住而异常长时间持有锁"。elapsed_sec 和 trx_query 是不是 NULL,是两个最有用的判据。
如果一时找不到 blocker,或者全局已经在互相等待导致找不到干净的入口,还有个兜底做法——但这是核武器级别,必须有心理准备:
SHOW PROCESSLIST;从里面挑出那些 Time 非常大、State 显示 Waiting for table metadata lock 或者长时间 update 状态的连接,逐个判断。请务必用判断,不要用"时间长的都杀"这种规则,因为它同样会误杀备份和报表任务。
一个真实的排查复盘
讲一个完整案例,把这套流程串起来。这是一台跑了电商类应用的服务器,症状是每天下午两三点会出现几分钟的"全站转圈",用户下单和浏览都卡,过几分钟自己恢复。奇特的是恢复得很干净,没有重启,没有人工干预。
第一步,先确认是不是数据库。我看的是应用侧的响应时间曲线和 MySQL 的连接数。发现故障期间 MySQL 的连接数明显上涨(因为在排队),但 CPU 只有 20% 左右——排除 CPU 瓶颈,指向等待类故障。
第二步,抓现场。问题是要在它出现的那几分钟抓到。我的做法是写了一个每分钟执行一次的采集脚本,把 innodb_trx 和 data_lock_waits 的结果追加写进日志文件(注意脚本本身要轻,不要在故障时给数据库加压)。等了两天,第三天下午抓到了现场的完整快照。
第三步,翻快照。快照里清楚显示:一个 RUNNING 状态、存活 240 秒、trx_query 为 NULL 的事务,block 着 9 个 LOCK WAIT 事务,全部锁在同一张 orders 表上。这个画面说明持锁事务既不提交也不执行语句。
第四步,定位到业务代码。为什么一个事务会"既不执行也不提交"?顺着那个事务所在的连接,查到它的应用来源是下单流程。看代码找到原因:下单逻辑在 BEGIN 之后先锁了订单行,然后去调用了一个第三方的库存校验 HTTP 接口——这一个网络调用发生在事务内部。平时这个接口 200 毫秒返回,一切正常;但午后是第三方接口的高峰期,偶尔会慢到几十秒甚至超时。接口一慢,事务就挂在那里,锁一直不释放,后面的下单请求全部排队。几分钟后接口恢复或者超时重试结束,事务终于提交,队列瞬间清空,故障"自己好了"。
第五步,量化与修复。修复方案是业务层面的重构:把第三方接口调用挪到事务开始之前(先校验,通过后再开事务),并且给这个接口设置 3 秒的明确超时。同时把 innodb_lock_wait_timeout 从默认的 50 秒调到 5 秒,让排队的请求快速失败而不是长挂。改造前后的数据对比:
- 改造前:约每两天发生一次全站卡顿,单次持续 3-8 分钟,期间订单接口 P99 延迟达到 40 秒以上,用户侧大量超时报错。
- 改造后:连续观察三周,全站卡顿未再复发;订单接口 P99 稳定在 800 毫秒以内;即便第三方接口再次变慢,最坏情况也只是当次请求失败(5 秒超时),不再牵连其他请求。
这个案例浓缩了两条方法论。第一,锁等待的根因几乎总在数据库之外——数据库只是忠实地记录了应用代码"把网络调用放进了事务"这个错误决定的后果。第二,innodb_lock_wait_timeout 是一个被严重低估的参数。默认 50 秒意味着一个请求可以挂 50 秒,这对网站来说等于永久卡死;调到一个较小值(3 到 10 秒),能让故障的影响半径从"全站"缩小到"个别请求",这是最便宜的一道保险。
怎么从根源上减少锁等待
事发后的救火只是止血,真正的价值在于事前的预防。几条有实际效果的规则:
事务里绝不做外部调用。这是最重要的一条,前面案例已经说明。HTTP 请求、文件读写、消息发送、加锁操作,全部移到事务外面。事务里只留数据库操作,且越快越好。
UPDATE 一定要走索引。InnoDB 的行锁是锁在索引记录上的。如果 UPDATE 的 WHERE 命中不了索引,它会扫描并锁住扫过的每一行——一个本意改一行的语句可能锁住几十万行,trx_rows_locked 会给你答案。开发新功能时先用 EXPLAIN 确认走的是 ref 或 range,不要出现 ALL(全表扫描)下的更新。
把长事务拆短。批量处理几万行数据时,不要一个事务包到底。正确做法是分批提交,每批几百到几千行,批与批之间 sleep 几十毫秒给其他事务留机会。这既能避免长时间持锁,也能避免巨大的回滚代价。
给关键更新加超时控制。除了调整全局的 innodb_lock_wait_timeout,MySQL 8.0 还支持在语句级别用 SELECT ... FOR UPDATE NOWAIT(拿不到锁立刻报错,不排队)或者 SKIP LOCKED(跳过被锁的行,取下一批)。用 NOWAIT 做秒杀场景、用 SKIP LOCKED 做任务队列,效果比让请求傻等好得多。
把监控接到锁等待上。不要等用户投诉才发现。最简单的指标是 Innodb_row_lock_current_waits(当前正在等锁的事务数),从 SHOW GLOBAL STATUS 里取,每分钟采集一次,超过 5 就告警;另一个是 Innodb_row_lock_time_avg(平均等锁时间),持续上涨说明锁竞争在恶化。这两个指标加起来不到 8 个字节的采集量,却能在故障扩大之前给你预警。
常见问题
问:为什么慢查询日志里看不到这些卡住的语句?
因为慢查询日志记录的是执行时间,而锁等待的时间在 MySQL 的统计口径里往往不计入 Query_time(具体行为随版本和参数而异,8.0 的 log_slow_extra 会记录锁等待明细)。所以一条语句在队列里等 30 秒,慢查询日志可能完全没记录,或者记录的时间远小于 30 秒。这就是为什么排查锁等待必须用 innodb_trx 和 data_lock_waits,不能靠慢查询日志。
问:怎么知道一个事务开了多久算是"异常"?
没有绝对标准,但有个可用的基线:正常 OLTP 事务应该是毫秒到几百毫秒级。如果你看到大于 3 秒,值得看一眼;大于 10 秒,几乎肯定有问题;大于 30 秒,基本可以确定要处理了。更严谨的做法是给自己统计一个基线——采集一周的 innodb_trx,算出 99 分位的事务存活时长,把它作为告警阈值。这个基线比任何通用数字都准。
问:SELECT ... FOR UPDATE 和不加 FOR UPDATE 有什么区别?
不加 FOR UPDATE 的普通查询,在 InnoDB 默认的 REPEATABLE READ 隔离级别下走的是快照读(MVCC),不加锁,因此不会参与任何锁等待——这也是为什么"读"通常不会造成阻塞。而加了 FOR UPDATE 就变成了当前读,会加排他锁,如果这一行正在被别人改,它就得等。很多"看起来很无辜的查询也会卡"的谜题,答案就是它带了 FOR UPDATE 或者 LOCK IN SHARE MODE。排查时翻一眼 SQL 语句,这两个关键字要特别留意。
总结
把这篇的方法收成一套动作序列,遇到"数据库活着但网站不动"就按顺序走一遍。第一,SELECT * FROM information_schema.innodb_trx ORDER BY trx_started,先看有没有存活几十秒的事务——"活得久"比"在等锁"更能指出元凶,因为持锁者本身状态可能是 RUNNING。第二,用 data_lock_waits 连 data_locks 把等待者和阻塞者配对,看清锁在哪张表上。第三,用 performance_schema.threads 把线程 ID 反查成应用来源,把"数据库问题"收敛成"某个业务操作的问题"。第四,救火只 kill blocker,不杀排队的,杀完确认回滚完成。第五,根因几乎总在数据库之外——事务里夹了外部调用、UPDATE 没走索引、事务太长、忘了提交,这四个是绝大多数锁等待事故的源头。
最后强调那个最容易被忽略的参数:innodb_lock_wait_timeout。它的默认值 50 秒对网站业务来说实在太长了,把请求从"卡死"变成"快速失败"只需要改一个数字,而这一个数字能把一次全站事故降级成几次报错。在你读完这篇文章之后,如果只做一件事,就把这个参数检查一遍,并且把锁等待的两个监控指标接上。