适用场景

这篇文章适用于 MySQL 5.7、MySQL 8.0 或兼容 MySQL 协议的数据库中,执行 ALTER TABLECREATE INDEXDROP INDEXTRUNCATE TABLE 等 DDL 时长时间不返回,同时业务侧出现接口变慢、连接数升高、写入卡住等问题的场景。

元数据锁的英文是 Metadata Lock,通常简称 MDL。它不是表里的行锁,也不是 InnoDB 事务锁,而是 MySQL 为了保护表结构一致性引入的一类锁。只要 SQL 访问了某张表,MySQL 就会在语句或事务期间持有对应的元数据锁;DDL 要修改表结构时,必须等已有访问释放锁,自己拿到更高等级的元数据锁后才能继续执行。

线上最容易出问题的地方在于:一个看似普通的长事务或长查询,可能阻塞 DDL;而被阻塞的 DDL 又可能挡住后续新进入的查询和写入,最终把数据库连接池、应用线程池一起拖满。

现象描述

一次典型故障可能有这些表现:

  1. 运维或开发执行 ALTER TABLE orders ADD INDEX idx_user_created(user_id, created_at); 后命令行一直卡住。
  2. 应用接口开始间歇性超时,慢 SQL 数量上升。
  3. 数据库连接数快速升高,Threads_runningThreads_connected 明显超过平时。
  4. SHOW PROCESSLIST 里出现大量 Waiting for table metadata lock
  5. 回滚发布或重启应用不一定立即恢复,因为阻塞源可能还在数据库内部。

需要注意,DDL 卡住并不代表 MySQL 正在慢慢建索引。很多时候它还没有真正进入拷贝数据、重建索引阶段,而是在等待拿表结构锁。

可能原因

常见原因可以按优先级这样排:

  1. 有未提交事务访问过目标表,例如手工开启事务后执行过 SELECT * FROM orders WHERE id = 1,但忘记 COMMIT
  2. 有长查询正在扫描目标表,DDL 等待它结束。
  3. 有备份、数据导出、报表任务长时间访问目标表。
  4. 应用连接池中存在异常连接,事务没有及时关闭。
  5. DDL 已经在等待 MDL,后续普通 SQL 又排在 DDL 后面,形成请求堆积。
  6. 在业务高峰直接执行大表 DDL,没有设置超时、没有灰度、没有预检查。

其中最容易误判的是第一种:事务里即使只是查询,只要事务没有结束,也可能继续持有目标表的元数据锁。开发环境不明显,线上连接池复用和事务传播一复杂,就会变成隐蔽故障。

快速判断是不是 MDL 问题

先看进程列表:

SHOW FULL PROCESSLIST;

重点看这些字段:

  • Id:连接线程 ID,后续 KILL 需要用它。
  • User:连接用户,帮助判断来自应用、运维账号还是定时任务。
  • Host:来源地址,帮助定位具体机器或服务。
  • db:当前数据库。
  • Command:常见有 QuerySleepBinlog Dump 等。
  • Time:当前状态持续秒数。
  • State:如果是 Waiting for table metadata lock,基本可以确认有 MDL 等待。
  • Info:正在执行的 SQL。

示例:

Id      User    Host            db      Command Time  State                              Info
12031   app     10.0.1.21:53012 shop    Sleep   1840  NULL                               NULL
12088   dba     10.0.2.10:42110 shop    Query   620   Waiting for table metadata lock    ALTER TABLE orders ADD INDEX idx_user_created(user_id, created_at)
12102   app     10.0.1.22:53144 shop    Query   580   Waiting for table metadata lock    SELECT * FROM orders WHERE id = 10001
12119   app     10.0.1.23:53208 shop    Query   570   Waiting for table metadata lock    UPDATE orders SET status = 2 WHERE id = 10002

这个例子里,真正要找的不是等待的 ALTER TABLE,而是更早访问过 orders 且没有释放锁的连接。很多时候它可能显示为 Sleep,因为 SQL 已经执行完了,但事务没有提交。

使用 performance_schema 定位阻塞源

如果启用了 performance_schema,优先从 metadata_locks 查。MySQL 8.0 通常更好用,MySQL 5.7 需要确认相关 instrument 是否开启。

查看目标表上的元数据锁:

SELECT
  ml.OBJECT_SCHEMA,
  ml.OBJECT_NAME,
  ml.LOCK_TYPE,
  ml.LOCK_DURATION,
  ml.LOCK_STATUS,
  ml.OWNER_THREAD_ID,
  t.PROCESSLIST_ID,
  t.PROCESSLIST_USER,
  t.PROCESSLIST_HOST,
  t.PROCESSLIST_DB,
  t.PROCESSLIST_COMMAND,
  t.PROCESSLIST_TIME,
  t.PROCESSLIST_STATE,
  t.PROCESSLIST_INFO
FROM performance_schema.metadata_locks ml
JOIN performance_schema.threads t
  ON ml.OWNER_THREAD_ID = t.THREAD_ID
WHERE ml.OBJECT_SCHEMA = 'shop'
  AND ml.OBJECT_NAME = 'orders'
ORDER BY
  FIELD(ml.LOCK_STATUS, 'PENDING', 'GRANTED'),
  t.PROCESSLIST_TIME DESC;

字段解释:

  • LOCK_STATUS='PENDING':正在等待锁,通常能看到 DDL。
  • LOCK_STATUS='GRANTED':已经拿到锁,阻塞源大概率在这里面。
  • LOCK_TYPE:锁类型,DDL 常见等待的是更强的排他类元数据锁。
  • PROCESSLIST_ID:可以和 SHOW PROCESSLIST 里的 Id 对上。
  • PROCESSLIST_TIME:状态持续时间,越大越值得关注。
  • PROCESSLIST_INFO:当前 SQL,Sleep 连接可能为空,需要继续查事务。

如果发现 PENDING 的 DDL 和多个 GRANTED 的会话同时存在,就要进一步找哪个会话有未提交事务。

定位未提交事务

查看 InnoDB 事务:

SELECT
  trx.trx_id,
  trx.trx_mysql_thread_id,
  trx.trx_started,
  TIMESTAMPDIFF(SECOND, trx.trx_started, NOW()) AS trx_seconds,
  trx.trx_state,
  trx.trx_tables_locked,
  trx.trx_rows_locked,
  trx.trx_query
FROM information_schema.innodb_trx trx
ORDER BY trx.trx_started;

重点看:

  • trx_mysql_thread_id:对应 SHOW PROCESSLISTId
  • trx_started:事务开始时间。
  • trx_seconds:事务已持续秒数,可以自己计算,也可以在 SQL 里算。
  • trx_query:事务当前正在执行的 SQL;如果为空,不代表没问题,可能是事务空闲未提交。

把事务和连接信息关联起来:

SELECT
  trx.trx_mysql_thread_id AS process_id,
  TIMESTAMPDIFF(SECOND, trx.trx_started, NOW()) AS trx_seconds,
  trx.trx_started,
  p.USER,
  p.HOST,
  p.DB,
  p.COMMAND,
  p.TIME,
  p.STATE,
  LEFT(p.INFO, 200) AS current_sql,
  LEFT(trx.trx_query, 200) AS trx_sql
FROM information_schema.innodb_trx trx
JOIN information_schema.PROCESSLIST p
  ON trx.trx_mysql_thread_id = p.ID
ORDER BY trx.trx_started;

如果看到某个应用账号连接已经 Sleep 很久,但事务持续时间也很久,它就是高优先级怀疑对象。

MySQL 5.7 开启 metadata_locks 观察能力

部分 MySQL 5.7 环境默认没有开启 metadata lock instrument,查询 performance_schema.metadata_locks 可能看不到关键数据。可以先检查:

SELECT *
FROM performance_schema.setup_instruments
WHERE NAME = 'wait/lock/metadata/sql/mdl';

如果 ENABLEDTIMED 不是 YES,可以临时开启:

UPDATE performance_schema.setup_instruments
SET ENABLED = 'YES', TIMED = 'YES'
WHERE NAME = 'wait/lock/metadata/sql/mdl';

这类开关只影响观察能力,不会自动解决锁等待。生产环境长期策略要结合版本、性能基线和公司规范决定。

一个完整定位示例

假设发布时要给 shop.orders 加索引:

ALTER TABLE orders ADD INDEX idx_user_created(user_id, created_at);

执行 10 分钟后还没返回,应用开始告警。第一步看进程:

SHOW FULL PROCESSLIST;

发现:

22011 app 10.0.1.15:48122 shop Sleep 2260 NULL NULL
22105 dba 10.0.2.10:53920 shop Query  612 Waiting for table metadata lock ALTER TABLE orders ADD INDEX idx_user_created(user_id, created_at)
22130 app 10.0.1.16:49001 shop Query  590 Waiting for table metadata lock SELECT * FROM orders WHERE order_no = 'A20260711001'

第二步查事务:

SELECT
  trx_mysql_thread_id,
  trx_started,
  TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_seconds,
  trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;

发现:

trx_mysql_thread_id  trx_started          trx_seconds  trx_query
22011                2026-07-11 09:10:12  2280         NULL

第三步联系业务日志和连接来源。22011 来自应用机器 10.0.1.15,事务空闲但未提交,说明可能是某段代码开启事务后异常分支没有提交或回滚。

第四步确认是否可以处理。先和业务确认该连接是否可以中断,必要时保留现场信息:

SHOW ENGINE INNODB STATUS\G

再解除阻塞:

KILL 22011;

如果 DDL 本身已经引发大量业务 SQL 排队,也要评估是否先杀 DDL:

KILL 22105;

这里没有固定答案。若 DDL 还没拿到锁,杀掉 DDL 通常能让后续业务 SQL 不再排在它后面;若业务允许短暂维护,并且已经确认阻塞源可以结束,也可以先释放阻塞源让 DDL 继续。线上优先级一般是先恢复业务,再安排变更。

修复方案

立即止血

按下面顺序处理比较稳妥:

  1. 确认等待 DDL 的表名和 SQL。
  2. 找到最早持有目标表 MDL 的会话或长事务。
  3. 判断该会话来自应用、脚本、报表还是人工操作。
  4. 如果业务影响已经扩大,优先停止继续排队的 DDL。
  5. 如果确认阻塞源可以中断,执行 KILL process_id
  6. 观察 SHOW PROCESSLISTWaiting for table metadata lock 是否消失。
  7. 观察应用错误率、接口耗时和数据库连接数是否回落。

不要一上来批量 KILL 所有等待连接。等待连接多数是受害者,真正要处理的是阻塞源和排队在前面的 DDL。

应用侧修复

如果阻塞源来自应用,需要重点检查:

  1. 是否所有事务分支都有 commitrollback
  2. 异常捕获后是否吞掉异常但没有回滚。
  3. 是否在事务里调用外部 HTTP、RPC、消息队列或慢文件操作。
  4. 是否把只读查询包进了不必要的大事务。
  5. 连接池是否开启了连接归还前的事务状态清理。

以 Python 伪代码为例,危险写法是:

conn.begin()
order = query_order(conn, order_id)
call_remote_service(order)
update_order(conn, order_id)
conn.commit()

问题在于远程调用放在事务里,一旦远程服务慢,事务就会变长。更稳妥的方式是缩短事务边界:

order = query_order_without_transaction(order_id)
remote_result = call_remote_service(order)

with transaction() as conn:
    update_order(conn, order_id, remote_result)

核心原则是:事务只包住必须保持一致性的数据库读写,不要把网络等待、人工确认、文件处理放进事务。

DDL 侧修复

执行 DDL 前建议先设置等待超时,避免无限卡住:

SET SESSION lock_wait_timeout = 10;
ALTER TABLE orders ADD INDEX idx_user_created(user_id, created_at);

说明:

  • lock_wait_timeout 对 metadata lock 等待有效。
  • 设置为 10 秒不是通用标准,要按业务窗口调整。
  • 超时失败比无限等待更容易被自动化发布系统感知,也更容易回滚流程。

对于大表 DDL,还要评估在线变更工具,例如 pt-online-schema-changegh-ost。即使用在线工具,也不能完全忽略 MDL:它们在创建触发器、切换表名等阶段仍然需要短暂元数据锁。只是在设计上把长时间阻塞窗口压缩到更小。

预防措施

发布前检查长事务

DDL 前先查长事务:

SELECT
  trx_mysql_thread_id,
  trx_started,
  TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_seconds,
  trx_state,
  LEFT(trx_query, 200) AS trx_query
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60
ORDER BY trx_started;

如果目标库已经有持续几分钟的事务,先不要执行 DDL。尤其是高峰期,有长事务时强行变更很容易触发连锁等待。

发布前检查目标表活跃 SQL

SELECT
  ID,
  USER,
  HOST,
  DB,
  COMMAND,
  TIME,
  STATE,
  LEFT(INFO, 200) AS SQL_TEXT
FROM information_schema.PROCESSLIST
WHERE DB = 'shop'
  AND INFO LIKE '%orders%'
ORDER BY TIME DESC;

这个查询不一定完整,因为 SQL 文本可能不含库名或表名,也可能被截断,但它能快速发现显眼的报表、导出和慢查询。

给变更系统加保护

如果 DDL 由发布平台或自动化脚本执行,建议内置这些保护:

  1. 执行前检查目标库长事务。
  2. 会话级设置 lock_wait_timeout
  3. DDL 超时失败后自动停止,不继续重试轰炸数据库。
  4. 失败后输出 SHOW PROCESSLIST 和长事务摘要。
  5. 大表默认要求低峰窗口或在线变更工具。

监控告警

可以关注这些指标:

  • Threads_connected:连接数是否异常升高。
  • Threads_running:正在运行线程是否异常升高。
  • 慢查询数量和 P95、P99 延迟。
  • information_schema.innodb_trx 中长事务数量。
  • performance_schema.metadata_locksPENDING 状态数量。
  • 应用连接池等待时间和获取连接超时次数。

如果监控系统支持 SQL exporter,可以采集长事务数量:

SELECT COUNT(*) AS long_trx_count
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 300;

也可以采集 MDL 等待数量:

SELECT COUNT(*) AS pending_mdl_count
FROM performance_schema.metadata_locks
WHERE LOCK_STATUS = 'PENDING';

这两个指标不能替代业务告警,但能在 DDL 风险扩大前提供早期信号。

常见误区

误区一:Sleep 连接一定无害

Sleep 只表示当前没有正在执行 SQL,不代表没有事务。一个空闲未提交事务可能比正在运行的查询更危险,因为它很容易被忽略。

误区二:只有写操作才会阻塞 DDL

普通查询也会持有元数据锁。事务未结束时,查询访问过的表可能继续影响 DDL。

误区三:Online DDL 就不会阻塞

MySQL 的 online DDL 降低了对读写的影响,但不等于完全无锁。开始和结束阶段仍可能需要元数据锁,遇到长事务一样会等待。

误区四:杀掉所有等待 SQL 就能恢复

排队等待的 SQL 多数不是根因。根因通常是更早的长事务、长查询或排队在前面的 DDL。批量杀等待 SQL 可能短暂降低连接数,但阻塞链还会继续产生。

总结

MySQL 元数据锁问题的排查关键不是盯着卡住的 DDL 本身,而是找到谁让 DDL 拿不到锁,以及 DDL 是否又挡住了后续业务 SQL。

实战中可以按这个顺序处理:

  1. SHOW FULL PROCESSLIST 确认是否存在 Waiting for table metadata lock
  2. performance_schema.metadata_locks 查看目标表上的 PENDINGGRANTED 锁。
  3. information_schema.innodb_trx 找长事务和空闲未提交事务。
  4. 先恢复业务,再决定继续 DDL、取消 DDL 或改到低峰执行。
  5. 从应用事务边界、DDL 超时、发布前检查和监控告警四个方向做预防。

对线上数据库来说,DDL 不是简单的一条 SQL,而是一次变更动作。只要把长事务检查和锁等待超时纳入发布流程,大部分 MDL 引发的连锁故障都可以提前拦住。