一、先认清一个事实:MDL 不是死锁,但比死锁更常见
很多站长第一次遇到「数据库突然全站卡住」时,第一反应是去查 SHOW ENGINE INNODB STATUS 看死锁,结果什么都没找到。这时候八成是元数据锁(Metadata Lock,简称 MDL)在作怪。
MDL 和 InnoDB 行锁是两套完全独立的机制。行锁锁的是数据行,由存储引擎层实现;MDL 锁的是表结构定义,由 MySQL Server 层实现,跟用什么存储引擎没关系。它的存在目的很简单:防止「有人在读表的时候,另一个人把表结构改了」。
正因为它保护的是表结构这个极容易被忽略的层面,所以它的加锁时机非常隐蔽——绝大多数普通 SELECT 语句也会自动加一把 MDL 读锁,而且这把锁会一直持有到事务提交或回滚为止。这就是 MDL 阻塞事故的根源。
举个真实场景:你的站点后台有个「文章浏览量统计」功能,代码里开了事务,先 SELECT 一下文章表,然后执行一堆逻辑,最后才 COMMIT。如果这段逻辑中间因为某个外部接口超时卡了 30 秒,那这 30 秒内文章表上就挂着一把 MDL 读锁。此时你恰好想给文章表加个索引执行 ALTER TABLE——恭喜,ALTER TABLE 会排在后面等,而它一旦开始等待,后面所有的读写请求也全部被它挡住,形成一条经典的「队列式雪崩」。
二、先用三个命令确认「是不是 MDL」
排查顺序非常重要,不要一上来就 KILL 连接,先把现场固定下来。以下三个查询按顺序执行。
2.1 看当前是否有阻塞关系
SELECT
waiting_pid AS 被阻塞线程,
waiting_query AS 被阻塞语句,
blocking_pid AS 阻塞方线程,
blocking_query AS 阻塞方语句,
wait_age AS 已等待时长
FROM sys.innodb_lock_waits;注意:这个视图基于 InnoDB 锁等待构建,MDL 等待不一定出现在这里。如果它返回空,但你确信表被卡住了,继续往下走。
2.2 直接查 performance_schema 里的 MDL 表(关键一步)
SELECT
t.THREAD_ID,
t.PROCESSLIST_ID,
t.PROCESSLIST_USER,
t.PROCESSLIST_TIME,
t.PROCESSLIST_STATE,
t.PROCESSLIST_INFO,
l.OBJECT_TYPE,
l.OBJECT_SCHEMA,
l.OBJECT_NAME,
l.LOCK_TYPE,
l.LOCK_DURATION,
l.LOCK_STATUS
FROM performance_schema.metadata_locks l
JOIN performance_schema.threads t
ON l.OWNER_THREAD_ID = t.THREAD_ID
WHERE l.OBJECT_SCHEMA NOT IN ('performance_schema','information_schema','mysql')
ORDER BY t.PROCESSLIST_TIME DESC;这张表是排查 MDL 的核心工具,它能直接告诉你:谁(THREAD_ID / PROCESSLIST_ID)在什么对象(OBJECT_SCHEMA.OBJECT_NAME)上持有什么类型的锁(LOCK_TYPE),以及这个锁的状态是 GRANTED(已授予)还是 PENDING(等待中)。
读法很直接:找到 LOCK_STATUS = 'PENDING' 的行,看它是哪个 OBJECT_NAME、哪个 LOCK_TYPE;然后回到同一张表里找同一个 OBJECT_NAME 上 LOCK_STATUS = 'GRANTED' 并且 LOCK_DURATION = 'TRANSACTION' 的那个线程,它就是罪魁祸首。
LOCK_DURATION 的取值有三种,含义必须搞清楚:
STATEMENT:语句结束就释放,通常是LOCK_TYPE = 'SHARED_READ'或'SHARED_WRITE'的普通 DML。TRANSACTION:事务提交才释放,这是最危险的一类,也是绝大多数事故的成因。EXPLICIT:显式加锁,日常极少见到。
2.3 用 information_schema 兜底(老版本 MySQL 5.7 用这个)
SELECT * FROM information_schema.innodb_trx\G
SELECT * FROM information_schema.processlist WHERE Time > 30 ORDER BY Time DESC;innodb_trx 里重点看 trx_started 和 trx_mysql_thread_id。只要看到某个事务的 trx_started 是很久以前,而这个线程的 STATE 是 Sleep,基本可以锁定它——一个开着事务却在睡觉的连接,就是 MDL 事故的标准画像。
这里有个经典陷阱:SHOW PROCESSLIST 里这类连接显示的 Info 往往是 NULL,因为它当前没有在执行的语句。如果你只看 Info 列,会完全看不到异常,必须结合 Time 列一起看。Time 表示线程处于当前状态已持续了多少秒,一条 Sleep 状态且 Time 高达几千的记录,就是在明晃晃地告诉你「我的事务没提交」。
三、一个可以手动复现的最小实验
不亲眼看到一次,很难建立起直觉。下面这个实验在三分钟内就能复现,建议在测试库上做一遍。
开三个终端会话,按顺序执行:
-- 会话 A:开启事务,查一次表,然后什么都不做
START TRANSACTION;
SELECT COUNT(*) FROM typecho_contents;
-- 注意:不要 COMMIT,就这样挂着
-- 会话 B:尝试改表结构,会立刻卡住
ALTER TABLE typecho_contents ADD INDEX idx_created (created);
-- 光标停在这里,等待中
-- 会话 C:一个完全无关的普通查询,也会被卡住
SELECT * FROM typecho_contents WHERE cid = 1;
-- 同样卡住,不会返回此时在第四个会话里执行上面 2.2 节的 metadata_locks 查询,你会看到:会话 B 的 ALTER TABLE 处于 PENDING,而会话 A 的 SELECT 是 GRANTED 且 LOCK_DURATION = 'TRANSACTION'。
注意会话 C 也被卡住了——这是最容易被误解的一点。 很多人以为只有 DDL 会排队,其实一旦 DDL 开始等待,它为了保证拿到锁之后语义正确,会先把后面进来的所有请求一起挡住。所以线上表现为「全站卡死」,而不是「只有加索引卡住」。在会话 A 执行 COMMIT 后,会话 B 立刻拿到锁执行完,会话 C 也随之返回。
四、线上应急处置:怎么安全地解掉阻塞
确认了阻塞方线程后,处置要分两步,顺序不能反。
4.1 先尝试等待,而不是立刻 KILL
如果阻塞方是一个正常业务事务、只是执行慢,优先等它自己提交。贸然 KILL 一个正在写数据的事务会触发回滚,大事务的回滚可能比正向执行还慢,反而把磁盘拖垮。只有当确认阻塞方是「开着事务在睡觉」的空闲连接时,才果断动手。
4.2 精确 KILL,不要随手 KILL 一堆
-- 先看一眼要杀掉谁
SELECT PROCESSLIST_ID, PROCESSLIST_USER, PROCESSLIST_TIME, PROCESSLIST_STATE
FROM performance_schema.threads
WHERE PROCESSLIST_ID = 12345;
-- 确认后精确杀掉
KILL 12345;
-- 如果连接属于某个连接池、杀掉会被立刻重连并重新发起,可以连根拔起
KILL QUERY 12345; -- 只终止当前语句,连接保留KILL QUERY 和 KILL CONNECTION 的区别要记牢:KILL QUERY 只终止正在执行的语句,连接和事务上下文保留(事务通常会回滚当前语句);KILL(等价于 KILL CONNECTION)直接断开连接,事务整体回滚。处置 MDL 阻塞时,通常需要 KILL CONNECTION,因为问题出在「事务没提交」而不是「语句在跑」,杀语句解决不了。
4.3 大批量阻塞时的批量定位脚本
SELECT
t.PROCESSLIST_ID AS pid,
t.PROCESSLIST_USER AS usr,
t.PROCESSLIST_TIME AS secs,
t.PROCESSLIST_STATE AS state,
l.OBJECT_NAME AS tbl,
l.LOCK_TYPE,
l.LOCK_STATUS
FROM performance_schema.metadata_locks l
JOIN performance_schema.threads t ON l.OWNER_THREAD_ID = t.THREAD_ID
WHERE l.OBJECT_SCHEMA = DATABASE()
ORDER BY l.LOCK_STATUS DESC, t.PROCESSLIST_TIME DESC;把 LOCK_STATUS 为 GRANTED 且 PROCESSLIST_TIME 最大的那条找出来,就是根源。批量场景下往往是「一个源头,堵住一片」,不要试图一个个去杀 PENDING 的线程,那是治标。
五、根治:五条工程化防线
应急只是止血,真正要解决得从代码和流程上防。以下五条按性价比排序。
5.1 缩短事务边界,绝不在事务里做外部调用
这是最重要的一条。下面这段代码是典型的反面教材:
// ❌ 错误示范:事务里做 HTTP 请求和文件操作
$db->beginTransaction();
$row = $db->query("SELECT * FROM contents WHERE cid = ?");
$remote = file_get_contents('https://api.example.com/check?id=' . $row['cid']);
$db->exec("UPDATE contents SET checked = 1 WHERE cid = ?");
$db->commit(); // 前面卡了 30 秒,这里才释放 MDL
正确做法是把外部调用挪到事务外面,或者先用无事务的普通查询取数据,远程调用完成后再开一个尽可能短的事务去写:
// ✅ 正确:事务只包裹纯粹的数据库写入
$row = $db->query("SELECT * FROM contents WHERE cid = ?"); // 无显式事务,语句级锁立即释放
$remote = file_get_contents('https://api.example.com/check?id=' . $row['cid']);
$db->beginTransaction();
$db->exec("UPDATE contents SET checked = 1 WHERE cid = ?");
$db->commit();5.2 给事务设一个「超时闹钟」
MySQL 5.7.11 之后提供了两个参数,能有效防止「忘记提交」无限期挂住:
SET GLOBAL wait_timeout = 300; -- 空闲连接最长存活秒数
SET GLOBAL innodb_lock_wait_timeout = 10; -- 行锁等待超时秒数
SET GLOBAL max_execution_time = 30000; -- 单位毫秒,只对只读 SELECT 生效注意 wait_timeout 只能处理「完全空闲」的连接,一个拿着 MDL 却在 Sleep 的连接确实会被它清掉,所以对这个场景有效。但它不能处理「事务开着、连接不空闲」的情况,也有应用层自动重连导致超时反复触发的问题,所以它只是兜底,不是根治。
innodb_lock_wait_timeout 管的是 InnoDB 行锁,对 MDL 无效——很多人误以为调小它就能防 MDL 阻塞,这是个常见误解。MDL 等待没有独立的超时参数(lock_wait_timeout 管的是元数据锁在存储引擎层面的表现,且主要是 DDL 之间的等待),所以MDL 阻塞本质上无法靠参数自动化解,只能靠事务纪律。
5.3 所有 UPDATE/DELETE 都套上事务提交保护
在框架层加统一钩子,保证任何异常路径都会 ROLLBACK。以 PHP PDO 为例:
try {
$pdo->beginTransaction();
// ... 业务逻辑
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}同时把 SET autocommit = 1 作为连接初始化的一部分写进连接配置,避免某些 ORM 或驱动默认关掉自动提交后忘了处理。
5.4 DDL 一律走低峰期,并且加超时自保
ALTER TABLE 在 MySQL 5.6+ 虽然大多支持 Online DDL,但「获取 MDL 排他锁」这一步始终需要短暂独占,如果拿不到就会无限等待并引发雪崩。给 DDL 加上明确的等待上限:
SET SESSION lock_wait_timeout = 5;
ALTER TABLE typecho_contents ADD INDEX idx_created (created);这样如果 5 秒内拿不到锁,DDL 自己失败退出,不会拖垮全站。把它写进发布脚本的固定前置语句,是一条极低成本的保险。
对于千万级大表,更稳的做法是用 pt-online-schema-change 或 gh-ost,它们通过「建影子表 + 触发器/二进制日志同步 + 原子改名」的方式绕开长时间独占:
pt-online-schema-change \
--alter "ADD INDEX idx_created (created)" \
--host=127.0.0.1 --user=root --ask-pass \
D=zz1984,t=typecho_contents \
--critical-load Threads_running=80 \
--max-load Threads_running=50 \
--execute--max-load 是安全阀:当 Threads_running 超过阈值时工具会暂停复制,避免把线上压垮。这条参数比 --critical-load 更常用。
5.5 加监控:把「长事务」当成一级告警
MDL 事故的共同特征是「事务开太久」。所以只要监控长事务,就能提前发现问题。写一个每分钟跑的巡检脚本:
#!/bin/bash
# /root/mdl_watch.sh —— 长事务监控,超过 60 秒即告警
THRESHOLD=60
RESULT=$(mysql -uroot -p"$DB_PW" -N -B -e "
SELECT trx_mysql_thread_id, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS secs, trx_state
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > $THRESHOLD
")
if [ -n "$RESULT" ]; then
echo "[$(date '+%F %T')] 发现长事务:" | tee -a /var/log/mdl_watch.log
echo "$RESULT" | tee -a /var/log/mdl_watch.log
fi再配合前面 metadata_locks 的查询,做一个「PENDING 锁数量超过 N 就告警」的规则,基本就能在用户投诉之前发现异常。
六、四个最容易踩的误区
- 误区一:以为读不锁表。 普通
SELECT确实不锁数据行,但它会加 MDL 读锁,且在显式事务里会持有到事务结束。MyISAM 的「读不阻塞写」经验完全不适用。 - 误区二:以为
innodb_lock_wait_timeout能救 MDL。 如前所述它只管行锁。调小它反而会让正常业务报Lock wait timeout exceeded,得不偿失。 - 误区三:用
SHOW PROCESSLIST的 Info 列判断。 拿 MDL 的「睡眠事务」Info 是 NULL,必须看Time和State。 - 误区四:在连接池环境里直接
KILL。 应用会立即重连并重发同样的 SQL,可能形成「杀了又堵」的循环。这种情况要先摘掉应用层的失败重试(暂停发布、摘流),再 KILL。
七、小结:一张排查决策清单
把前面的内容压缩成可执行清单,遇到「数据库突然全卡」时按顺序走:
- 查
sys.innodb_lock_waits——有结果就是行锁问题,按死锁/锁等待处理。 - 结果为空但确实卡住,查
performance_schema.metadata_locks——找LOCK_STATUS='PENDING'的对象。 - 在同一对象上找
GRANTED且LOCK_DURATION='TRANSACTION'的线程,它就是要处置的目标。 - 看它是「正在执行的大事务」还是「Sleep 状态的空闲事务」:前者等、后者杀。
- 处置用
KILL CONNECTION,不是KILL QUERY。 - 恢复后立刻回查代码里的
beginTransaction,把事务里的一切网络/文件操作挪出去。 - 把
lock_wait_timeout写进 DDL 发布脚本,把长事务监控加到巡检里。
MDL 这类问题的特点是「平时完全无感,一旦发作就是全站级故障」。它不需要多高深的知识,但需要你对「事务边界」这件事始终保持敬畏。作为个人站长,你未必天天遇到它,但只要能记住第 2 步那个 metadata_locks 查询,就已经超过大多数人了。