“就加一个字段而已,业务低峰期,十分钟搞定。”——这是我在变更单里写下的原话,也是我今年最大的 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)三类角色一目了然:
- Id=771002,Sleep 1243 秒:一个连接,什么也没在执行,但存在了 20 多分钟;
- Id=802115,ALTER,Waiting for table metadata lock:我;
- 一百多个 SELECT / UPDATE,也都是
Waiting for table metadata lock:被我“连累”的所有业务请求。
State 列里的 Waiting for table metadata lock 就是这类问题的标志性关键字。看到它,别去查 CPU、慢 SQL 了——CPU 一定很闲,因为大家都在排队等门。
二、排查路线图:谁挡住了谁?
先把机制画出来,理解了机制,命令只是顺手的事:
用文字再说一遍。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 + 优先级 + 长事务,三个条件缺一不可
把因果链串起来:
- 某个服务里有一段代码,
@Transactional方法里先做了一次SELECT,然后调用了一个很慢的外部接口(外部系统卡住,等了 20 多分钟才超时——线程池里还有大量线程堵在这里);事务一直没结束,MDL 读锁一直握着; - 我执行 ALTER,请求 EXCLUSIVE,与那把读锁不兼容,进入等待;
- 写锁优先,后来的所有读写都被排在 ALTER 后面;
- 应用的连接池被这些“等待中”的连接占满,接口超时,告警响成一片。
一个常被误解的点:ALTER 语句本身如果是 Online DDL,真正干活的阶段是不阻塞读写的。MySQL 8.0 里“加列”默认使用 ALGORITHM=INSTANT(8.0.12+,只改元数据,几乎瞬间完成,不重建表,允许并发 DML);更早的版本是 INPLACE,需要重建表但也允许并发 DML。然而无论哪种,ALTER 的开头和结尾都需要短暂地拿一次 MDL 独占锁。所以问题不在于“ALTER 慢”,而在于“ALTER 拿不到锁”。这也是我在预发验证时没发现的原因:预发没有人开着长事务。
五、止血与修复
止血:让排队的人先动起来
止血有两个选择,杀 ALTER,或者杀长事务。我的顺序:
- 先杀 ALTER(最安全):
KILL 802115;。ALTER 一撤,排在它后面的请求瞬间放行,业务恢复。这是我优先选的动作,因为它不会丢任何业务数据; - 再定位并处理长事务:确认 771002 是“空闲且不再需要”的(比如上面
trx_query为 NULL、连接是 Sleep),再KILL 771002;。杀事务会回滚它的所有未提交修改,请先确认业务可接受或者让应用负责人确认; - 之后重新执行 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-run3)上游治理:不许有“睡着的事务”。这是根因,比工具重要:
- 事务里不许调用远程接口、发 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找睡着的长事务 → 先KILLDDL 保业务 → 下次lock_wait_timeout+ Online DDL / gh-ost。
后来我给自己的变更模板加了一行:“执行前确认:当前没有任何一个事务在睡觉。”产品问我为什么变更单上会有这句,我说,因为最危险的往往不是正在跑的,而是睡着了还抱着锁不放的那个——这个道理,好像不只适用于数据库。
参考资料
- MySQL 8.0:元数据锁(Metadata Locking)——MDL 持有到事务结束、写锁优先、事务阻塞 ALTER 的官方示例。
- MySQL 8.0:performance_schema.metadata_locks 表——列含义、LOCK_STATUS 取值、启用 instrument 的方法。
- MySQL 8.0:sys.schema_table_lock_waits 视图——谁在等、谁在挡,以及现成的 KILL 语句列。
- MySQL 8.0:Online DDL 操作支持——加列的 INSTANT / INPLACE 支持情况与限制。
- Pythian:How to deal with MetaData Lock——未提交事务阻塞 ALTER,继而 SELECT 也被堵的场景与应对。
- GitHub:gh-ost 项目主页——基于 binlog、无触发器的在线表结构迁移原理与用法。
一起聊聊