2136 字
约 7 分钟
13
S11-A27 · 元数据锁:为什么"只加一个字段"让全表查询卡住
2024-12-31
2026-11-12

凌晨两点执行一条 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. 自测

  1. MDL 在哪一层?为什么连 SELECT 都要拿它?
  2. 为什么一次 DDL 会把"只读查询"也堵死?队列为什么不允许插队?
  3. KILL 掉 DDL 之后为什么还可能卡?唯一"事前"的解法是什么?
  4. 用什么视图能直接看到"谁在阻塞谁"?
  5. gh-ost 解决的是"长持锁"还是"完全不拿锁"?为什么它仍有队列风险?
  6. 判断"这次 DDL 会不会出事"的判据是什么?

9. 一句话总结

MDL 的风险不是"锁多久",是"会不会形成队列":DDL 要排他 MDL,撞上一个长事务后就进入等待,而之后的所有读请求都排在它后面——所以卡住的是整张表,不是那条 DDL。三条纪律:先查 sys.schema_table_lock_waits 找根、给 DDL 设 5 秒 lock_wait_timeout 宁失败不排队、在线工具只压窗口不消除风险。判据:执行那一刻这张表上有没有长事务,而不是这条 SQL 预计几秒。

S11-A27 · 元数据锁:为什么"只加一个字段"让全表查询卡住
https://www.clxhxhhr.top/posts/6748/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。