数据库与缓存排查 列表全部文章
数据库与缓存排查

给大表加一个字段,整站查询全卡死:一个“睡着”的事务与 Waiting for table metadata lock

线上执行 ALTER TABLE 加字段,结果这张表所有读写全部排队。原因是一个没提交的长事务持有 MDL 读锁,ALTER 等写锁,后续请求全被堵在队列。附 processlist、performance_schema、sys 视图定位与 kill、Online DDL、gh-ost、lock_wait_timeout 的做法。

更多

“就加一个字段而已,业务低峰期,十分钟搞定。”——这是我在变更单里写下的原话,也是我今年最大的 flag。

晚上九点半,我在预发验证过,执行计划、回滚方案都有。生产库执行 ALTER TABLE t_order ADD COLUMN remark varchar(200) ... 之后,终端光标一直闪,没有任何输出。五秒后,钉钉开始震动:订单查询超时、下单超时、后台报表超时。不是那张表慢,而是那张表所有请求都不动了。我盯着自己那条一动不动的 ALTER,突然理解了什么叫“社死”。

一、现象:ALTER 没执行完,整张表读写全体排队

第一件事:别 Ctrl+C,先看现场。(后面会解释为什么,其实 Ctrl+C 往往是对的,但要先知道谁是罪魁祸首,否则下一次还是会撞上。)另开一个连接:

mysql> SHOW FULL PROCESSLIST;
+--------+------+-----------------+----------+---------+------+---------------------------------+---------------------------------------------------+
| Id     | User | Host            | db       | Command | Time | State                           | Info                                              |
+--------+------+-----------------+----------+---------+------+---------------------------------+---------------------------------------------------+
| 771002 | app  | 10.0.3.30:39871 | order_db | Sleep   | 1243 |                                 | NULL                                              |
| 802115 | dba  | 10.0.1.5:52210  | order_db | Query   |   48 | Waiting for table metadata lock | ALTER TABLE t_order ADD COLUMN remark varchar(200)|
| 802131 | app  | 10.0.3.21:44012 | order_db | Query   |   47 | Waiting for table metadata lock | SELECT * FROM t_order WHERE id = 9912345          |
| 802140 | app  | 10.0.3.22:38820 | order_db | Query   |   46 | Waiting for table metadata lock | UPDATE t_order SET status = 2 WHERE id = 9912001  |
| 802152 | app  | 10.0.3.23:61233 | order_db | Query   |   45 | Waiting for table metadata lock | SELECT count(*) FROM t_order WHERE user_id = 8812 |
...
| 802977 | app  | 10.0.3.21:44890 | order_db | Query   |    3 | Waiting for table metadata lock | SELECT * FROM t_order WHERE id = 9913777          |
+--------+------+-----------------+----------+---------+------+---------------------------------+---------------------------------------------------+
137 rows in set (0.01 sec)

三类角色一目了然:

  1. Id=771002,Sleep 1243 秒:一个连接,什么也没在执行,但存在了 20 多分钟;
  2. Id=802115,ALTER,Waiting for table metadata lock:我;
  3. 一百多个 SELECT / UPDATE,也都是 Waiting for table metadata lock:被我“连累”的所有业务请求。

State 列里的 Waiting for table metadata lock 就是这类问题的标志性关键字。看到它,别去查 CPU、慢 SQL 了——CPU 一定很闲,因为大家都在排队等门。

二、排查路线图:谁挡住了谁?

先把机制画出来,理解了机制,命令只是顺手的事:

会话 1:长事务 会话 2:ALTER 会话 3..N:业务查询 MDL 队列 BEGIN; SELECT ... t_order 持有 MDL SHARED_READ(到事务结束) ALTER TABLE ADD COLUMN 要 MDL EXCLUSIVE → 不兼容,等待 SELECT / UPDATE t_order 想要 SHARED_READ/WRITE → 排在 ALTER 后面 等待队列 1. ALTER(写锁,优先级高) 2. 新来的所有读写 …越积越多,连接池打满 整张表“停摆” 关键:没人阻塞“读”,是排在 ALTER 后面的读被阻塞了;罪魁是那个已经睡着、但没提交的事务

用文字再说一遍。MySQL 官方文档“Metadata Locking”一节的要点:

  • 访问表的语句(哪怕只是 SELECT)在事务里会拿到元数据锁(MDL),而且一直持有到事务结束,目的是保证事务期间表结构不被别人改掉(否则会破坏事务的可串行化和 binlog 的一致性);
  • ALTER TABLE 需要 MDL 的独占(EXCLUSIVE)锁,与任何读锁不兼容,所以它必须等所有在这张表上有未结束事务的会话;官方文档的例子:会话 1 开启事务并读了表 t,会话 2 的 ALTER TABLE t 会一直阻塞,直到会话 1 提交;
  • 官方文档同时写明:写锁请求的优先级高于读锁请求。所以 ALTER 一旦进入等待队列,后面新来的 SELECT 也要排在它之后。这才是“一个 ALTER 拖垮整表”的关键:它本身在等,但它的等待把后续所有人都堵住了。

这也解释了为什么那个 Sleep 连接“没在干活”却能闯祸:它在一个 BEGIN 之后执行了一次查询,然后应用忘了 COMMIT(连接池借出去、抛异常、没回滚,或者代码里手动开事务但分支里漏了提交),连接躺在池里,MDL 读锁一直握着。

三、动手:找到那个“睡着的”事务

第 1 步:processlist 初筛

上面的输出里 Sleep 1243 已经非常可疑。但是 processlist 只知道“它睡着了”,不知道“它睡着之前干了什么、持有哪张表的锁”。所以我们需要更精确的证据。

第 2 步:performance_schema.metadata_locks

performance_schema.metadata_locks 表展示已授予和正在等待的元数据锁(官方文档:包含已授予的锁、被请求但未授予的锁、被死锁检测杀掉的和超时的请求)。它依赖 wait/lock/metadata/sql/mdl 这个 instrument,先确认它开着:

mysql> SELECT NAME, ENABLED, TIMED FROM performance_schema.setup_instruments WHERE NAME = 'wait/lock/metadata/sql/mdl';
+----------------------------+---------+-------+
| NAME                       | ENABLED | TIMED |
+----------------------------+---------+-------+
| wait/lock/metadata/sql/mdl | YES     | YES   |
+----------------------------+---------+-------+

-- 如果是 NO(5.7 默认没开),运行时打开:
mysql> UPDATE performance_schema.setup_instruments SET ENABLED='YES', TIMED='YES' WHERE NAME='wait/lock/metadata/sql/mdl';
-- 或者写进 my.cnf:performance-schema-instrument='wait/lock/metadata/sql/mdl=ON'

注意一个现实问题:instrument 只对开启之后产生的锁记账。如果事故发生时它是关的,这条路就没有历史证据,只能靠 processlist + information_schema.innodb_trx。所以我现在的规范是新实例上线就打开。

mysql> SELECT OBJECT_SCHEMA, OBJECT_NAME, LOCK_TYPE, LOCK_DURATION, LOCK_STATUS, OWNER_THREAD_ID
    -> FROM performance_schema.metadata_locks
    -> WHERE OBJECT_NAME = 't_order';
+---------------+-------------+-------------------+---------------+-------------+-----------------+
| OBJECT_SCHEMA | OBJECT_NAME | LOCK_TYPE         | LOCK_DURATION | LOCK_STATUS | OWNER_THREAD_ID |
+---------------+-------------+-------------------+---------------+-------------+-----------------+
| order_db      | t_order     | SHARED_READ       | TRANSACTION   | GRANTED     |          771123 |
| order_db      | t_order     | SHARED_UPGRADABLE | TRANSACTION   | GRANTED     |          802247 |
| order_db      | t_order     | EXCLUSIVE         | TRANSACTION   | PENDING     |          802247 |
| order_db      | t_order     | SHARED_READ       | TRANSACTION   | PENDING     |          802263 |
| order_db      | t_order     | SHARED_WRITE      | TRANSACTION   | PENDING     |          802271 |
...
+---------------+-------------+-------------------+---------------+-------------+-----------------+

读法:

  • SHARED_READ ... TRANSACTION ... GRANTED:某个线程(771123)已经拿到读锁,持续到事务结束——它就是“被告一号”;
  • ALTER 所在线程有两行:SHARED_UPGRADABLE GRANTED(先占个能升级的位置,允许别人继续读写)和 EXCLUSIVE PENDING(等着升级到独占);
  • 下面所有 PENDING 的 SHARED_READ / SHARED_WRITE 都是被排队的普通业务请求。

注意 OWNER_THREAD_ID 是 performance_schema 里的线程 ID,不是 processlist 的 Id。要对应,需要查 performance_schema.threads 的 PROCESSLIST_ID。

第 3 步:sys.schema_table_lock_waits,一步到位

手工对应线程 ID 太烦,MySQL 自带的 sys 库把这件事包成了视图 sys.schema_table_lock_waits(官方文档:显示哪些会话因元数据锁而阻塞,被谁阻塞,并提供现成的 KILL 语句):

mysql> SELECT object_schema, object_name, waiting_pid, waiting_query, waiting_query_secs,
    ->        blocking_pid, blocking_lock_type, sql_kill_blocking_connection
    -> FROM sys.schema_table_lock_waits\G
*************************** 1. row ***************************
                 object_schema: order_db
                   object_name: t_order
                   waiting_pid: 802115
                 waiting_query: ALTER TABLE t_order ADD COLUMN remark varchar(200)
            waiting_query_secs: 48
                  blocking_pid: 771002
            blocking_lock_type: SHARED_READ
sql_kill_blocking_connection: KILL 771002
*************************** 2. row ***************************
                   waiting_pid: 802131
                 waiting_query: SELECT * FROM t_order WHERE id = 9912345
                  blocking_pid: 771002
            blocking_lock_type: SHARED_READ
...

又是 771002——和 processlist 里那个 Sleep 1243 秒的连接对上了。视图还贴心地给出 sql_kill_blocking_connection(KILL 771002)和 sql_kill_blocking_query。注意:这个视图列出的是“谁阻塞了谁”,但它把 ALTER 之后排队的读写也当作等待方,所以每一行的 blocking_pid 未必都是同一个,你只需要关注“最上游”的那个(通常是持有 GRANTED 读锁且最老的)。

第 4 步:确认这个事务到底干了什么

mysql> SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query
    -> FROM information_schema.innodb_trx ORDER BY trx_started\G
*************************** 1. row ***************************
                trx_id: 421918776123456
             trx_state: RUNNING
           trx_started: 2026-09-29 21:09:03
   trx_mysql_thread_id: 771002
             trx_query: NULL
1 row in set (0.00 sec)

trx_started 是二十多分钟前,trx_query 是 NULL(当前没有在执行的语句),trx_mysql_thread_id = 771002。事务开了,没提交,连接空闲。这类“事务开着但连接在睡”是 MDL 事故最典型的形态。还可以看这个连接最后执行了什么:

mysql> SELECT * FROM performance_schema.events_statements_current WHERE THREAD_ID = (
    ->   SELECT THREAD_ID FROM performance_schema.threads WHERE PROCESSLIST_ID = 771002)\G
-- 关注 SQL_TEXT,例如:SELECT * FROM t_order WHERE id = 9911200
-- 结合应用日志,找这个来源 IP(10.0.3.30)的服务,看是哪段代码开了事务不提交。

四、根因:MDL + 优先级 + 长事务,三个条件缺一不可

把因果链串起来:

  1. 某个服务里有一段代码,@Transactional 方法里先做了一次 SELECT,然后调用了一个很慢的外部接口(外部系统卡住,等了 20 多分钟才超时——线程池里还有大量线程堵在这里);事务一直没结束,MDL 读锁一直握着;
  2. 我执行 ALTER,请求 EXCLUSIVE,与那把读锁不兼容,进入等待;
  3. 写锁优先,后来的所有读写都被排在 ALTER 后面;
  4. 应用的连接池被这些“等待中”的连接占满,接口超时,告警响成一片。

一个常被误解的点:ALTER 语句本身如果是 Online DDL,真正干活的阶段是不阻塞读写的。MySQL 8.0 里“加列”默认使用 ALGORITHM=INSTANT(8.0.12+,只改元数据,几乎瞬间完成,不重建表,允许并发 DML);更早的版本是 INPLACE,需要重建表但也允许并发 DML。然而无论哪种,ALTER 的开头和结尾都需要短暂地拿一次 MDL 独占锁。所以问题不在于“ALTER 慢”,而在于“ALTER 拿不到锁”。这也是我在预发验证时没发现的原因:预发没有人开着长事务。

五、止血与修复

止血:让排队的人先动起来

止血有两个选择,杀 ALTER,或者杀长事务。我的顺序:

  1. 先杀 ALTER(最安全):KILL 802115;。ALTER 一撤,排在它后面的请求瞬间放行,业务恢复。这是我优先选的动作,因为它不会丢任何业务数据;
  2. 再定位并处理长事务:确认 771002 是“空闲且不再需要”的(比如上面 trx_query 为 NULL、连接是 Sleep),再 KILL 771002;。杀事务会回滚它的所有未提交修改,请先确认业务可接受或者让应用负责人确认;
  3. 之后重新执行 ALTER,这次带超时保护(见下)。
-- 先杀“我自己”的 DDL,业务立即恢复
mysql> KILL 802115;
-- 确认长事务无害后再杀
mysql> KILL 771002;

-- 重新执行前,先查一遍是不是干净了
mysql> SELECT trx_mysql_thread_id, trx_started FROM information_schema.innodb_trx
    -> WHERE trx_started < NOW() - INTERVAL 60 SECOND;
Empty set (0.00 sec)

根治:让 DDL 学会“礼貌地敲门”

1)设置 lock_wait_timeout,拿不到锁就撤,不要傻等。它控制“等元数据锁的最长时间”,默认值很大(官方默认是一年,31536000 秒)。在 DDL 会话里把它设小:

mysql> SET SESSION lock_wait_timeout = 5;   -- 5 秒拿不到 MDL 就放弃
mysql> ALTER TABLE t_order ADD COLUMN remark varchar(200) NOT NULL DEFAULT '', ALGORITHM=INSTANT;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction   -- 撞上长事务时会这样,安全失败,业务无感
-- 稍后重试;或者先去把长事务清掉

同时显式写 ALGORITHM=INSTANT(或 INPLACE, LOCK=NONE):如果引擎做不到,语句会直接报错而不是悄悄退化成会锁表的拷贝方式。这是一层非常实用的保险。

2)大表改结构,用 Online DDL 或者外部工具。官方 Online DDL 文档给出各操作的支持矩阵:加列在 8.0.12+ 默认 INSTANT;很多操作 INPLACE 并允许并发 DML;也有些操作只能 COPY(会阻塞写)。实在不适合原生 DDL 的(比如改字符集、改列类型这类需要重建的大表),可以用:

  • gh-ost(GitHub 出品):不使用触发器,而是读 binlog,把变更异步应用到影子表(ghost table),最后原子切换。官方 README 列出的优势包括可真正暂停、可动态调参、可先在从库测试、切换时机可控(如 --postpone-cut-over-flag-file);
  • pt-online-schema-change(Percona Toolkit):创建新表、用触发器同步增量、拷贝数据、最后 RENAME 切换。适用面广,但触发器会给主库额外负担,并且仍需要在 RENAME 一刻拿 MDL——所以它们也可能被长事务卡在最后一步,需要配合超时参数使用(如 --lock-wait-timeout);
# gh-ost 示例(先 --dry-run 演练,确认无误再换成 --execute)
$ gh-ost \
    --host=10.0.2.11 --port=3306 --user=ghost --ask-pass \
    --database=order_db --table=t_order \
    --alter="ADD COLUMN remark varchar(200) NOT NULL DEFAULT ''" \
    --chunk-size=1000 --max-load=Threads_running=25 --critical-load=Threads_running=1000 \
    --postpone-cut-over-flag-file=/tmp/ghost.postpone.flag \
    --exact-rowcount --verbose --dry-run

3)上游治理:不许有“睡着的事务”。这是根因,比工具重要:

  • 事务里不许调用远程接口、发 MQ、做长耗时计算,先算完再开事务,尽快提交;
  • 连接池设置:Hikari 的 maxLifetime、idleTimeout 之外,MySQL 侧用 wait_timeout/interactive_timeout 兜底(空闲连接超时会被断开,未提交事务随之回滚,MDL 也就释放了);
  • 用 pt-kill 或监控脚本,对 innodb_trx 中运行超过 N 秒且 trx_query IS NULL 的事务告警(不建议默认自动 kill,先告警);
  • 把 DDL 放进变更流程:执行前先跑一遍上面的“长事务检查”SQL。

六、复盘:为什么“只是加个字段”也能出事故

做法为什么
DDL 前先查 information_schema.innodb_trx 有无长事务ALTER 能不能拿到 MDL 独占锁,取决于这张表上有没有未结束的事务,而不是 ALTER 本身快慢
DDL 会话设置 lock_wait_timeout(如 3–5 秒)拿不到锁就安全失败,避免在队列头部堵住整表;默认值可以等一年,等于没有超时
显式指定 ALGORITHM 与 LOCK引擎做不到就报错,不会悄悄降级成会阻塞写的方式
出事先杀 DDL,再杀长事务杀 DDL 立即让排队请求放行且不丢数据;杀事务会回滚未提交内容,需要确认
新实例默认开启 wait/lock/metadata/sql/mdl事故当下才开,没有历史证据;sys.schema_table_lock_waits 依赖它
大表变更走 gh-ost / pt-osc,并选择低峰、可暂停、可控切换减少对业务的冲击和复制延迟,但切换那一刻仍需要 MDL,别以为万事大吉
禁止事务内做远程调用长事务是元数据锁事故、死锁、复制延迟的共同源头

一句话备忘:ALTER 卡住、整表排队 → SHOW PROCESSLIST 找 Waiting for table metadata lock → sys.schema_table_lock_waits / innodb_trx 找睡着的长事务 → 先 KILL DDL 保业务 → 下次 lock_wait_timeout + Online DDL / gh-ost。

后来我给自己的变更模板加了一行:“执行前确认:当前没有任何一个事务在睡觉。”产品问我为什么变更单上会有这句,我说,因为最危险的往往不是正在跑的,而是睡着了还抱着锁不放的那个——这个道理,好像不只适用于数据库。

参考资料

— 感谢读到这里 —发现错误,或想补充?告诉我 →
TURN KNOWLEDGE INTO SOMETHING REAL

有想法,也想把它做出来?

从技术方案到上线交付,聊聊你的下一个项目。

聊聊需求

一起聊聊

搜索文章

输入关键词,按标题和摘要即时筛选。

浏览分类文章归档

阅读设置

字号
行宽
专注模式

专注模式会隐藏页头与侧栏,只留正文。设置保存在当前浏览器。

稍后阅读

保存在当前浏览器,留给下一次安静阅读。

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人