凌晨两点执行一条 ALTER TABLE orders ADD COLUMN remark varchar(64)。按经验这是秒级操作,结果跑了 40 分钟,期间下单接口全超时——而那条 SQL 本身还没提交。运维第一反应是 KILL 掉 DDL,结果卡住的状态没有任何缓解。
原因不在 DDL 慢,在它拿到的那把锁不是行锁,是元数据锁(MDL)。而 MDL 的等待会排队:DDL 等一个正在跑的查询,之后所有访问这张表的请求都排在 DDL 后面——包括只是 SELECT 的。
1. 两张锁表:行锁与元数据锁
| 锁 | 层级 | 保护什么 | 谁持有 |
|---|---|---|---|
| 行锁 / 间隙锁 | InnoDB 引擎层 | 行数据 | DML 事务 |
| MDL | MySQL server 层 | 表结构定义 | 每条访问语句 |
| 意向锁 | 引擎层 | 表级与行级协调 | 事务开始时 |
第二行是关键:每一条语句访问表时都会拿 MDL,即使你只是查。SELECT 拿 SHARED_READ,ALTER 要 EXCLUSIVE——两者互斥,所以"改结构"与"读数据"天然冲突。
2. 一次 DDL 如何变成全表停摆
// 时间线(真实发生过的形状)
t=0:00 ALTER 开始,请求 EXCLUSIVE MDL
t=0:00 发现一个长事务 S 持有 SHARED_READ → ALTER 进入等待
t=0:01 新查询 Q1 请求 SHARED_READ —— 被排在 ALTER 后面(不插队)
t=0:01 Q2、Q3… 全部排在 Q1 之后
t=0:30 业务开始超时,连接池被打满
t=2:00 被人发现,KILL ALTER
t=2:00 队列里的 Q1..Qn 才开始依次执行(仍要排队)
// 结论:卡住的不是 DDL,是这张表的所有访问
第二行的"不插队"是整个事故的核心机制:MySQL 的 MDL 队列不允许后来的读请求越过正在等待的写请求。这个设计避免了"写饥饿",代价是一次 DDL 就能把读也堵死。
3. 为什么 KILL 掉 DDL 也要等
杀掉 DDL 只是取消它的 MDL 请求,而队列里已经排住的那批查询仍按顺序执行。如果它们本身不快(比如大表扫描),排完还是要几十秒。
| 动作 | 效果 | 为什么不够 |
|---|---|---|
KILL DDL 线程 |
停止继续申请 | 队列里已有请求仍排队 |
KILL 持锁的长事务 |
让 DDL 能拿到锁 | 可能回滚大量数据 |
| 重启从库 | 清空队列 | 只对从库有效,主库更糟 |
提前设 lock_wait_timeout |
DDL 快速失败 | 唯一不放大影响的做法 |
第四行是唯一"事前"的解法——它把"DDL 等待"变成"DDL 快速失败",而失败的代价远小于一次雪崩:重跑一条 DDL 只要几分钟,恢复一次全表停摆要赔上整段业务。:给 DDL 一个很短的 lock_wait_timeout(例如 5 秒),拿不到锁就失败,不让它形成队列。失败一次比卡 40 分钟好得多——重试成本远低于雪崩。
-- 事务级设置,只影响这一条 DDL
SET SESSION lock_wait_timeout = 5;
ALTER TABLE orders ADD COLUMN remark varchar(64), ALGORITHM=INPLACE, LOCK=NONE;
-- 拿不到 MDL 就在 5 秒内报错退出,不会形成长队列
4. 先看谁持有,再动手
SHOW PROCESSLIST 只能看到 Waiting for table metadata lock,看不到"谁持有"。真正的持有者在 performance_schema 里。
| 视图 | 能回答 |
|---|---|
performance_schema.metadata_locks |
谁持有、谁在等、什么类型 |
information_schema.innodb_trx |
哪个事务还在跑、跑了多久 |
SHOW ENGINE INNODB STATUS |
事务与锁等待详情 |
sys.schema_table_lock_waits |
直接给出阻塞链(推荐) |
第四行是 5.7+ 的现成视图,一条 SQL 就能看到"谁是根"。
-- 一条 SQL 找到根:谁在阻塞谁
SELECT waiting_pid, waiting_query, blocking_pid, blocking_query, wait_age
FROM sys.schema_table_lock_waits
ORDER BY wait_age DESC;
-- 典型输出:waiting=ALTER,blocking=一个跑了 3 小时的 SELECT(长事务)
-- 处置顺序:先评估能否 KILL 长事务;不能就放弃这次 DDL,换低峰再试
5. 在线 DDL 也救不了所有情况
ALGORITHM=INPLACE, LOCK=NONE 能避免长时间持有排他锁,但它仍然需要短暂的 EXCLUSIVE MDL(开始与结束各一次)。所以:
| 方案 | 持锁时长 | 仍需 MDL | 适用 |
|---|---|---|---|
| 原生 INPLACE | 极短(首尾各一次) | 是 | 大多数字段变更 |
| gh-ost / pt-osc | 极短 | 是(改名那一刻) | 大表、需可暂停 |
| 主从切换式变更 | 无(在从库做) | 从库上是 | 有备库且可切 |
| 停机变更 | 无 | 否 | 可接受停服 |
// 三种方案的"持锁窗口"对比(同一张 2000 万行表)
原生 INPLACE(不可用) 需拷贝整表,持锁 40+ 分钟
gh-ost 拷贝在影子表做,最后 RENAME 持锁 < 1 秒
pt-osc 类似,但触发器带来额外写入开销
// 共同点:最后那一秒仍然要 EXCLUSIVE,所以仍然要挑低峰
第二行常被误解成"完全无锁":它把拷贝放到影子表,但最后 RENAME 仍要拿 MDL。所以工具解决的是"长持锁",不是"不拿锁"——队列风险依然存在,只是窗口从 40 分钟缩到 1 秒。
// gh-ost 的关键参数:把"卡住的时间窗"压到最小
$ gh-ost --host=... --database=weblog --table=orders \
--alter="ADD COLUMN remark varchar(64)" \
--max-load='Threads_running=30' \ # 负载超阈值自动暂停
--critical-load='Threads_running=60' \ # 超了直接 abort
--cut-over-lock-timeout-seconds=3 # 改名阶段最多等 3 秒
// 三个阈值的作用:宁可这次不做,也不要制造队列
6. 排错:五种"DDL 卡住"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
DDL 一直 Waiting for table metadata lock |
有长事务持有 SHARED_READ | 查 schema_table_lock_waits |
| KILL 了 DDL 仍然卡 | 队列已形成 | 等队列排空或 kill 长事务 |
| DDL 一秒完成但业务抖动 | 首尾 EXCLUSIVE 撞上高峰 | 低峰执行 |
| 从库正常、主库卡 | 主库有未提交长事务 | 查 innodb_trx 长事务 |
| 反复重试都失败 | 有常驻连接持有 MDL | 找那个连接(常见是未提交的客户端) |
第五行很典型:某些客户端(或连接池的探活查询)开了事务却不提交,长期持有 MDL,于是 DDL 永远拿不到锁。这类"幽灵持有者"只能靠 metadata_locks 的 OWNER_THREAD_ID 反查连接。
7. 边界:什么情况该直接停机
| 场景 | 判断 | 做法 |
|---|---|---|
| 大表结构重构(改主键、换类型) | 在线工具也需长时间 | 停机窗口 |
| 无法 KILL 的长事务(关键批处理) | 队列一定会形成 | 排到批处理之后 |
| 主从延迟已经很高 | DDL 会加剧 | 先解决延迟 |
| 无备库、单点 | 风险不可控 | 备份 + 停机 |
判断口径一句话:MDL 的风险不在"锁多久",在"会不会形成队列"——所以判据是"执行那一刻,这张表上有没有正在跑的长事务",而不是"这条 DDL 预计几秒"。前者决定事故,后者只决定耗时。
8. 自测
- MDL 在哪一层?为什么连
SELECT都要拿它? - 为什么一次 DDL 会把"只读查询"也堵死?队列为什么不允许插队?
KILL掉 DDL 之后为什么还可能卡?唯一"事前"的解法是什么?- 用什么视图能直接看到"谁在阻塞谁"?
- gh-ost 解决的是"长持锁"还是"完全不拿锁"?为什么它仍有队列风险?
- 判断"这次 DDL 会不会出事"的判据是什么?
9. 一句话总结
MDL 的风险不是"锁多久",是"会不会形成队列":DDL 要排他 MDL,撞上一个长事务后就进入等待,而之后的所有读请求都排在它后面——所以卡住的是整张表,不是那条 DDL。三条纪律:先查 sys.schema_table_lock_waits 找根、给 DDL 设 5 秒 lock_wait_timeout 宁失败不排队、在线工具只压窗口不消除风险。判据:执行那一刻这张表上有没有长事务,而不是这条 SQL 预计几秒。