外观
元数据锁踩坑实录:DDL 卡住到恢复的排查链路
问题背景:一条加索引语句为什么拖垮业务
凌晨变更窗口,我们给订单明细表执行在线加索引:
sql
ALTER TABLE orders_order_item
ADD INDEX idx_shop_sku_created (shop_id, sku_id, created_at);正常情况下,MySQL 8.0 会使用 Instant 或 Inplace 算法,业务影响较小。但这条 DDL 在第一台从库上执行了十几分钟仍未返回,应用侧随后出现大量请求超时:新查询停在 Waiting for table metadata lock,写入线程被拖慢,连接池迅速打满。
根因不是 DDL 本身扫描数据慢,而是它没有拿到表的 元数据锁(Metadata Lock,简称 MDL)。MySQL 访问表时会持有 MDL,用于保护表结构在语句执行期间不被修改。只要有一个未提交事务还占着该表的 MDL,DDL 就必须等待;DDL 排队后,后续访问同一张表的新事务也会被堵住,形成雪崩式 DDL 阻塞。
下面用可复现的 SQL 示例,完整演示持锁会话定位、等待链分析、安全 kill 与 变更窗口 选择。
方案对比:排查 MDL 应该看哪套视图
常见排查方式有三类:
| 方案 | 能看到什么 | 局限 | 适用场景 |
|---|---|---|---|
SHOW PROCESSLIST | 当前会话状态、SQL、等待秒数 | 看不到锁对象和完整等待关系 | 快速判断是否出现 MDL 堆积 |
information_schema.innodb_trx | InnoDB 事务开始时间、锁等待 | 对只读事务、自动提交外的空闲事务支持有限 | 找长事务 |
performance_schema.metadata_locks | MDL 类型、对象、持有/等待状态 | 需要开启对应 instrumentation | 精确定位元数据锁 |
sys.schema_table_lock_waits | 表锁等待者与阻塞者 | 依赖 P_S 配置 | 快速生成 kill 建议 |
生产环境优先使用 performance_schema 与 sys 联动:先确认 DDL 在等哪张表,再找持有 MDL 的会话,而不是看到 Waiting for table metadata lock 就盲目批量 kill。
实操步骤:从复现到定位持锁会话
1. 准备测试表并开启 MDL 监控
以下示例可在 MySQL 8.0 测试库直接运行:
sql
-- 建表:模拟订单明细表
CREATE TABLE orders_order_item (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
shop_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
order_no VARCHAR(32) NOT NULL,
quantity INT NOT NULL DEFAULT 1,
amount DECIMAL(12,2) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 写入少量测试数据
INSERT INTO orders_order_item
(shop_id, sku_id, order_no, quantity, amount)
VALUES
(1001, 2001, 'NO202401', 1, 99.00),
(1001, 2002, 'NO202402', 2, 58.50),
(1002, 2001, 'NO202403', 1, 99.00);
-- 开启等待事件采集,便于观察 MDL 等待
UPDATE performance_schema.setup_instruments
SET ENABLED = 'YES', TIMED = 'YES'
WHERE NAME = 'wait/lock/metadata/sql/mdl';
UPDATE performance_schema.setup_consumers
SET ENABLED = 'YES'
WHERE NAME IN ('events_waits_current', 'events_statements_current');2. 会话 A:制造一个未提交事务
在第一个连接中显式开启事务,执行查询但不提交:
sql
-- 会话 A
BEGIN;
SELECT id, order_no, amount
FROM orders_order_item
WHERE shop_id = 1001;
-- 故意不执行 COMMIT,也不执行 ROLLBACK很多人以为只读 SELECT 不会造成锁问题。确实,它不会产生常见的 InnoDB 行锁,但事务期间会持有该表的共享 MDL。此时如果连接被应用池放回,服务端仍认为事务活跃,就会成为 DDL 的阻塞源。
3. 会话 B:执行 DDL,观察阻塞
第二个连接执行加索引:
sql
-- 会话 B:会被会话 A 的 MDL 阻塞
ALTER TABLE orders_order_item
ADD INDEX idx_shop_sku_created (shop_id, sku_id, created_at),
ALGORITHM=INPLACE,
LOCK=NONE;第三个连接再执行简单查询,也会被堵住:
sql
-- 会话 C:DDL 排队后,新访问也可能等待
SELECT COUNT(*) FROM orders_order_item;使用下面语句快速确认现场:
sql
SELECT
id,
user,
host,
db,
command,
time,
state,
LEFT(info, 200) AS sql_text
FROM information_schema.PROCESSLIST
WHERE info IS NOT NULL
OR state LIKE '%metadata lock%'
ORDER BY time DESC;典型状态如下:
text
会话 B:Waiting for table metadata lock,SQL 为 ALTER TABLE ...
会话 C:Waiting for table metadata lock,SQL 为 SELECT COUNT(*) ...
会话 A:Sleep,time 很长,info 为 NULL注意,真正的阻塞者常常显示为 Sleep,不能只根据当前 info 判断。
4. 查询 MDL 持有与等待关系
执行以下 SQL 找出谁在等、谁在持有:
sql
SELECT
ml.OBJECT_SCHEMA AS db_name,
ml.OBJECT_NAME AS table_name,
ml.LOCK_TYPE,
ml.LOCK_DURATION,
ml.LOCK_STATUS,
ml.OWNER_THREAD_ID,
pt.PROCESSLIST_ID AS conn_id,
pt.PROCESSLIST_USER AS user_name,
pt.PROCESSLIST_HOST AS host_name,
pt.PROCESSLIST_TIME AS active_seconds,
LEFT(pt.PROCESSLIST_INFO, 120) AS current_sql
FROM performance_schema.metadata_locks ml
JOIN performance_schema.threads pt
ON ml.OWNER_THREAD_ID = pt.THREAD_ID
WHERE ml.OBJECT_SCHEMA = DATABASE()
AND ml.OBJECT_NAME = 'orders_order_item'
ORDER BY
FIELD(ml.LOCK_STATUS, 'PENDING', 'GRANTED'),
pt.PROCESSLIST_TIME DESC;结果中通常会看到:
- 会话 A:
LOCK_STATUS = GRANTED,LOCK_TYPE = SHARED_READ,但当前 SQL 可能为NULL; - 会话 B:
LOCK_STATUS = PENDING,需要排他 MDL; - 会话 C:同样处于
PENDING,被前面的 DDL 队列挡住。
如果实例安装了 sys 库,可以直接查表锁等待:
sql
SELECT
object_schema,
object_name,
waiting_pid,
waiting_lock_type,
waiting_query,
blocking_pid,
blocking_lock_type,
blocking_query,
sql_kill_blocking_connection AS kill_sql
FROM sys.schema_table_lock_waits;5. 结合事务开始时间判断是否可以终止
不要只看连接空闲时间,还要看事务持续时间:
sql
SELECT
trx.thread_id,
th.PROCESSLIST_ID AS conn_id,
th.PROCESSLIST_USER AS user_name,
th.PROCESSLIST_HOST AS host_name,
trx.trx_started,
TIMESTAMPDIFF(SECOND, trx.trx_started, NOW()) AS trx_seconds,
trx.trx_state,
LEFT(th.PROCESSLIST_INFO, 200) AS current_sql
FROM information_schema.innodb_trx trx
JOIN performance_schema.threads th
ON trx.thread_id = th.THREAD_ID
ORDER BY trx.trx_started ASC;确认会话 A 是测试连接或无主连接后,再终止:
sql
-- 将 52 替换为实际 blocking_pid
KILL 52;kill 后,会话 A 回滚,MDL 释放,会话 B 的 DDL 继续执行,会话 C 恢复。测试完成后清理索引:
sql
ALTER TABLE orders_order_item
DROP INDEX idx_shop_sku_created;变更窗口:不要只按业务低峰设置时间
DDL 变更窗口应同时满足四个条件:业务写入低峰、长事务少、主从复制有余量、出现阻塞时可快速回退。建议变更前执行以下检查:
sql
-- 1. 查看运行超过 60 秒的事务
SELECT
trx_id,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS seconds_old,
trx_state,
trx_mysql_thread_id AS pid,
trx_query
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60
ORDER BY trx_started ASC;
-- 2. 查看目标表是否已有 MDL 等待
SELECT
OBJECT_SCHEMA,
OBJECT_NAME,
LOCK_TYPE,
LOCK_STATUS,
OWNER_THREAD_ID
FROM performance_schema.metadata_locks
WHERE OBJECT_SCHEMA = '你的库名'
AND OBJECT_NAME = 'orders_order_item';
-- 3. 查看复制状态,重点关注 Seconds_Behind_Source 或 Seconds_Behind_Master
SHOW REPLICA STATUS\G对于核心表,建议把 DDL 放在可灰度的从库或使用变更平台执行,并设置 lock_wait_timeout,避免 DDL 无限等待:
sql
-- 只控制 MDL 等待时间,不控制整个 DDL 执行时间
SET SESSION lock_wait_timeout = 10;
ALTER TABLE orders_order_item
ADD INDEX idx_shop_sku_created (shop_id, sku_id, created_at),
ALGORITHM=INPLACE,
LOCK=NONE;如果 10 秒内拿不到 MDL,DDL 会报错退出,而不是长时间堵住后续访问。变更平台可以重试,但重试间隔要留出清理长事务的时间。
避坑总结:至少记住这五条
不要忽略
Sleep状态的长连接。 常见现象是PROCESSLIST中 DDL 显示Waiting for table metadata lock,但看不到明显正在执行的 SQL。原因是应用开启事务后查询结束却没有提交或回滚,连接进入Sleep,仍持有 MDL。修正方法是通过metadata_locks关联threads找GRANTED会话,而不是 kill 正在等待的 DDL。不要把
lock_wait_timeout当成 DDL 总超时。 有同学设置后以为整个加索引最多执行 10 秒,实际上它只控制元数据锁等待。DDL 拿到 MDL 后,数据重建、日志应用仍可能持续很久。大表变更仍需评估磁盘、临时空间、复制延迟和业务峰谷。不要在高峰期直接对主库执行不可逆的大表 DDL。 即使
ALGORITHM=INPLACE, LOCK=NONE,准备阶段和提交阶段仍需要 MDL;一旦被长事务卡住,后续查询会排队。应先清理长事务,再在变更窗口执行,并准备KILL阻塞会话、暂停任务、延迟切流等预案。kill 会话前要确认回滚代价。 kill 一个大事务不是瞬间消失,InnoDB 可能需要回滚大量修改。若误杀批处理或支付任务,还可能造成业务数据处理中断。应先看
trx_started、来源主机、账号和最近执行的 SQL,必要时联系应用负责人确认。主库完成不等于变更结束,必须检查主从延迟。 DDL 在从库通常串行应用,大索引可能造成复制日志积压,表现为
Seconds_Behind_Source持续增大、只读查询读到旧执行位点。修正方法是在低峰执行、控制同批次 DDL 数量,变更后持续观察复制延迟,延迟未恢复前不要继续下一张核心表。
一次元数据锁事故的本质,往往不是“MySQL 加索引太慢”,而是表结构变更与事务生命周期发生了冲突。把 MDL 排查 SQL 提前准备好,把变更窗口从“日历上的低峰”升级为“锁、事务、复制都满足条件的窗口”,才能让 DDL 从风险操作变成可控操作。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。