🤖
AI审核中

MySQL元数据锁与Online DDL排障实战

Java 19分钟 108浏览 0评论

设想这样一次发布。

业务只需要给文章表增加一个摘要字段。没有修改主键,没有调整复杂索引,甚至已经明确指定了 ALGORITHM=INSTANT

按预期,这应该是一次很轻的数据库变更。

但执行之后,DDL 一直没有返回。紧接着,文章详情接口开始超时,后台列表打不开,原本正常的其他接口也陆续报错。

打开监控,CPU 不高,磁盘也没有明显打满。

问题到底出在哪里?

这类故障有一种容易被忽略的解释:数据库并不是忙着修改数据,而是在等待修改表结构的资格。更麻烦的是,等待中的 DDL,还可能挡住后续查询。 MySQL 官方文档明确描述了这种由元数据锁引发的阻塞链。

本文以 MySQL 8.4、InnoDB 为讨论环境,从可复现的实验入手,分析阻塞如何产生、怎样定位,以及如何把数据库变更变成一个有边界的发布过程。

一、查询结束了,不代表它占用的锁已经释放

理解这个问题,需要先认识一种与行锁不同的锁:元数据锁,Metadata Lock,简称 MDL

行锁关注的是记录之间的并发访问,而 MDL 负责协调数据库对象的使用与结构变更。例如,一个事务正在使用某张表时,另一个会话不能随意改变这张表的定义。MySQL 会对事务访问的表获取相应的元数据锁,并将相关锁保留到事务结束。

下面这段 SQL 就足以成为问题的起点:

START TRANSACTION;

SELECT id, title
FROM article
WHERE id = 1;

查询可能已经返回了结果,但事务还没有提交或者回滚。

此时,连接可以处于空闲状态,却仍然持有这个事务对表获取的 MDL。因此,不能把“没有正在执行的 SQL”理解成“没有影响其他会话的事务”。在自动提交模式下,单条语句构成的事务通常随语句结束而结束;显式事务则不能这样判断。

可以把两者的区别理解为:

查询结束,说明这次读取完成了;事务结束,才说明这段事务对表结构稳定性的依赖结束了。

对于 Java 应用,这意味着排查时不能只问“哪条 SQL 很慢”,还要问:

哪段业务把数据库事务保持得太久了?

二、Online、INPLACE、INSTANT,分别承诺了什么

“在线变更”最容易造成的误解,是把几种不同能力混成了一个结论:不会阻塞业务。

实际上,应该分别考察算法、并发访问能力,以及元数据锁等待。

1. 算法解决的是“怎样修改”,不是“完全不等待”

MySQL 的几种主要 DDL 算法可以这样理解:

算法 核心行为 不能由此推出的结论
COPY 创建表副本并复制数据,不允许并发写入 不能认为复制期间所有阶段都允许读取
INPLACE 避免服务器层的传统复制方式,但某些操作仍会重建表 不代表不扫描数据、不重建表或者没有资源开销
INSTANT 只修改数据字典中的元数据,不重写表数据 不代表不获取 MDL,也不代表没有等待

一个尤其值得注意的细节是:在 MySQL 8.4 中,使用 INPLACE 添加普通列会重建表。因此,“支持 INPLACE”和“这个变更很轻”不是同一回事。

2. LOCK=NONE 不是关闭锁机制

LOCK=NONE 用来要求变更支持并发查询和 DML;操作不支持这种并发程度时,语句会报错。它并没有取消修改表定义时的 MDL 协调。

还有一个容易直接写错的组合:

MySQL 8.4 中,ALGORITHM=INSTANT 只允许默认的 LOCK 选项,不能再搭配 LOCK=NONE 因此,使用 INSTANT 时,通常直接省略 LOCK 子句。

3. “持锁很短”不代表“等待很短”

对于在线 DDL,提交新表定义的阶段需要排他 MDL。真正拿到锁之后,这个阶段可能很短;但在拿到锁之前,DDL 可能一直等待尚未结束的事务。事务既可能早于 DDL 开始,也可能在在线变更执行期间出现。

因此,评估一次变更时,应该把总耗时拆开看:

等待进入变更阶段的时间,与实际完成变更的时间,是两回事。

只在空闲测试库验证“加字段很快”,并没有验证它在生产事务并发下是否安全。

三、用三个会话,复现一条阻塞链

下面使用独立实验库制造阻塞。只在隔离测试实例中执行,不要在生产业务表上进行这个实验。

1. 初始化实验表

先在一个连接中执行:

CREATE DATABASE ddl_mdl_lab;

CREATE TABLE ddl_mdl_lab.article_demo (
    id BIGINT NOT NULL PRIMARY KEY,
    title VARCHAR(100) NOT NULL
) ENGINE = InnoDB;

INSERT INTO ddl_mdl_lab.article_demo (id, title)
VALUES (1, 'MDL demo');

2. 会话 A:读取数据,但保持事务未结束

SELECT CONNECTION_ID() AS session_a_id;

START TRANSACTION;

SELECT id, title
FROM ddl_mdl_lab.article_demo
WHERE id = 1;

先不要执行 COMMITROLLBACK

这里没有使用 FOR UPDATE,实验关注的也不是记录之间的行锁冲突,而是尚未结束的事务对表持有的 MDL。

3. 会话 B:尝试瞬时增加字段

SELECT CONNECTION_ID() AS session_b_id;

-- 120 秒仅用于给实验留出观察时间,不是生产建议值。
SET SESSION lock_wait_timeout = 120;

ALTER TABLE ddl_mdl_lab.article_demo
    ADD COLUMN note VARCHAR(100) NULL,
    ALGORITHM = INSTANT;

在会话 A 尚未结束事务的条件下,这条 DDL 会等待所需的元数据锁。

4. 会话 C:执行一条普通查询

确认会话 B 已进入等待后,再执行:

SET SESSION lock_wait_timeout = 60;

SELECT id, title
FROM ddl_mdl_lab.article_demo
WHERE id = 1;

按照这个实验顺序,C 的查询也会进入等待:A 持有共享 MDL,B 等待排他 MDL,后续查询 C 被等待中的 DDL 挡住。这个链条与 MySQL 官方文档给出的在线 DDL 阻塞示例一致。

graph LR
    A["事务 A 持有共享 MDL"] -->|阻塞| B["DDL B 等待排他 MDL"]
    B -->|阻塞| C["后续查询 C 排队"]

这里最值得记住的不是某一种锁名称,而是:

一个尚未拿到目标排他锁的 DDL,也可能成为后续请求的阻塞点。

观察结束后,回到会话 A 执行:

ROLLBACK;

只要 B、C 尚未超时,解除这个阻塞源后,它们便可以继续推进。若已经超时,则检查最终表结构,再重新安排实验,不能根据客户端表现猜测 DDL 是否成功。

四、线上排查:先找等待链,再看事务年龄

出现接口超时时,不要立即认定需要加索引,也不要先扩大连接池。

对于疑似 MDL 问题,建议按“当前等待状态—锁持有关系—事务来源”的顺序收集证据。

1. 先确认是否在等待元数据锁

在另一个诊断连接中执行:

SHOW FULL PROCESSLIST;

重点关注 State 中的 Waiting for table metadata lock。它比“查询执行了很久”更具体,直接指出会话正在等待表级元数据锁。

不过,等待最久的连接不一定就是最应该处理的连接。它可能只是后续受害者。

2. 用 sys 视图快速关联连接

sys.schema_table_lock_waits 提供了等待连接、阻塞连接、锁类型以及等待语句等信息,可以作为定位入口。下面查询聚焦于实验表,线上使用时替换库名和表名。

SELECT
    object_schema,
    object_name,
    waiting_pid,
    waiting_query_secs,
    waiting_lock_type,
    blocking_pid,
    blocking_lock_type,
    waiting_query
FROM sys.schema_table_lock_waits
WHERE object_schema = 'ddl_mdl_lab'
  AND object_name = 'article_demo'
ORDER BY waiting_query_secs DESC;

不要一看到 blocking_pid 就直接终止连接。先确认对应语句、业务身份以及锁类型,特别是存在多个等待者时,要继续检查原始锁记录。

3. 用 metadata_locks 检查原始状态

performance_schema.metadata_locks 同时记录已授予和待授予的元数据锁。通过 OWNER_THREAD_ID 关联线程信息,可以把锁记录还原到具体连接。

SELECT
    t.PROCESSLIST_ID AS connection_id,
    t.PROCESSLIST_USER AS db_user,
    t.PROCESSLIST_HOST AS client_host,
    t.PROCESSLIST_COMMAND AS command,
    m.LOCK_TYPE,
    m.LOCK_DURATION,
    m.LOCK_STATUS,
    t.PROCESSLIST_INFO AS current_sql
FROM performance_schema.metadata_locks AS m
LEFT JOIN performance_schema.threads AS t
    ON t.THREAD_ID = m.OWNER_THREAD_ID
WHERE m.OBJECT_TYPE = 'TABLE'
  AND m.OBJECT_SCHEMA = 'ddl_mdl_lab'
  AND m.OBJECT_NAME = 'article_demo'
ORDER BY t.PROCESSLIST_ID, m.LOCK_STATUS;

GRANTED 表示已经获得锁,PENDING 表示正在等待。LOCK_DURATION 则表示生命周期类别,例如 TRANSACTION不是已经等待了多少秒

如果没有查到记录,还要检查采集是否开启,以及诊断账号是否拥有足够权限。MDL 对应的采集项为:

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

MySQL 8.4 默认启用这个采集项,但实际环境可能修改过配置。空结果不能脱离配置与权限直接解释成“没有锁”。

4. 检查未结束事务,而不是只查运行中的 SQL

INNODB_TRX 提供事务开始时间、关联连接、当前语句及修改行数等信息。诊断账号需要相应的 PROCESS 权限。

SELECT
    trx.trx_mysql_thread_id AS connection_id,
    TIMESTAMPDIFF(
        SECOND,
        trx.trx_started,
        NOW()
    ) AS trx_age_seconds,
    trx.trx_state,
    trx.trx_rows_modified,
    t.PROCESSLIST_USER AS db_user,
    t.PROCESSLIST_HOST AS client_host,
    t.PROCESSLIST_COMMAND AS command,
    trx.trx_query AS current_sql
FROM information_schema.innodb_trx AS trx
LEFT JOIN performance_schema.threads AS t
    ON t.PROCESSLIST_ID = trx.trx_mysql_thread_id
ORDER BY trx.trx_started
LIMIT 20;

这里有两个判断边界。

其一,事务年龄大,不代表它一定阻塞了目标表,仍然需要与 MDL 记录交叉验证。

其二,trx_query 是事务当前执行的语句,不是完整事务历史。不能因为这一列为空,就认为连接没有执行过相关查询。

真正要找的,是这几项证据能否对得上:

目标表的锁、持锁连接、未结束事务,以及应用中的业务调用。

五、为什么一张表被堵,其他接口也会超时

到数据库这一层,问题看起来只影响一张表。

但从应用架构推演,影响范围可能更大:访问这张表的请求占着数据库连接等待,如果它们逐渐占满共享连接池,其他原本不访问该表的请求,也可能拿不到连接。

HikariCP 的行为是:连接池达到 maximumPoolSize 且没有空闲连接时,新的 getConnection() 调用会等待,直到获取到连接或者达到 connectionTimeout。(GitHub)

graph LR
    A["部分 SQL 等待 MDL"] --> B["数据库连接持续被占用"]
    B --> C["共享连接池耗尽"]
    C --> D["其他接口获取连接超时"]

这是由共享连接池结构推导出的故障扩散路径,不意味着每次 MDL 等待都会造成全站故障。

但它解释了一个重要现象:

最先报错的组件,不一定是问题的起点。

应用日志里出现连接池超时,只能说明请求没有及时拿到连接。根因可能仍然在数据库的锁等待链上。

因此,扩容连接池不应该成为这类故障的第一反应。增加连接数量不能释放 MDL,在阻塞未解除时,还可能允许更多请求进入同一条等待队列。

六、故障处理中,先决定保护什么

定位完成后,处理动作应该围绕一个目标展开:优先恢复业务,还是必须完成当前变更?

在常规在线发布中,我更倾向于把“业务继续提供服务”作为优先目标。

1. 对正在排队的 DDL,优先评估取消变更

如果确认当前 DDL 正是扩大阻塞范围的排队节点,而业务允许延后发布,可以先暂停迁移任务的自动重试,再取消该 DDL 的当前语句。

MySQL 的 KILL QUERY 只终止指定连接正在执行的语句,保留连接本身;KILL CONNECTION 则会终止连接。操作前必须重新核对连接 ID 和当前 SQL,避免处理错对象。

不能把“发出了取消命令”当成“故障已经解除”。

取消之后,要重新检查等待中的锁是否消失、业务查询是否恢复、连接池是否回落,以及 DDL 的最终状态。已经执行了大量工作的变更,还需要考虑取消后的清理成本。

2. 对持锁事务,先弄清业务语义

如果阻塞源是遗留的空闲事务,应优先让事务所属程序正常提交或回滚。

确实需要终止连接时,必须知道它是否包含未提交写入,以及回滚可能造成的影响。尤其不能批量终止所有 Sleep 连接:空闲连接并不都持有事务,持有事务也不等于都阻塞了本次 DDL。

还要注意,取消某个连接的当前查询,并不等于结束它已经打开的事务。MDL 的释放条件仍然要回到事务生命周期上判断。

处理阻塞不是比谁执行 KILL 更快,而是找到最小影响的解除方式。

七、给 DDL 设置等待预算,而不是无限排队

生产发布不应该默认接受“等到能够变更为止”。

MySQL 的 lock_wait_timeout 控制获取元数据锁的等待超时。MySQL 8.4 文档中的默认值为 31536000 秒,也就是一年,显然不能直接把这个默认行为当成发布窗口的等待策略。

对于前面的实验表,可以用独立迁移连接演示一个较短的等待预算:

-- 示例值需要结合业务容忍度调整。
-- 使用独立迁移连接,执行结束后关闭该连接。
SET SESSION lock_wait_timeout = 3;

SELECT
    CONNECTION_ID() AS migration_connection_id,
    @@SESSION.lock_wait_timeout AS mdl_wait_timeout_seconds;

ALTER TABLE ddl_mdl_lab.article_demo
    ADD COLUMN summary TEXT NULL,
    ALGORITHM = INSTANT;

这里的思路是:

宁可本次变更因竞争失败,也不要让它长时间占据等待队列。

但必须准确理解这个参数:它对每一次元数据锁获取尝试分别生效。一条语句可能获取多个锁,因此它既不是整条 DDL 的总耗时上限,也不能保证变更一定在三秒内返回。

另外,几个名称相近的超时不能混用:

参数 主要约束对象
lock_wait_timeout MySQL 元数据锁获取等待
innodb_lock_wait_timeout InnoDB 行锁等待
HikariCP connectionTimeout 应用向连接池获取连接的等待

应用和发布任务还应有各自的总体时间预算,但那是另一层控制,不能认为设置了其中一个参数,其他等待也自动有了边界。

同时,要显式指定允许的算法。指定 ALGORITHM=INSTANT 后,如果表结构或操作不支持,MySQL 会报错,而不是自动改走其他算法。(dev.mysql.com)

不要在捕获错误后直接删掉算法限制重试。

算法不支持、锁竞争超时、权限不足以及连接中断,代表不同问题。把它们统一变成“换一种方式再来一次”,是在把已知风险重新变成未知风险。

八、Java 侧的治理重点,是缩短事务而不是只缩短 SQL

假设一个业务流程按以下顺序执行:

先打开事务,读取文章,再调用外部 AI 接口生成摘要,最后保存结果并提交。

如果数据库事务在整个远程调用期间保持打开,那么即使读取 SQL 很快,对表的事务级 MDL 依赖仍可能持续到最终提交。这个判断来自前面已经确认的事务持锁规则。

对于允许拆分的业务,更合理的设计是:

graph LR
    A["短事务读取必要数据"] --> B["事务外执行远程调用"]
    B --> C["短事务校验并保存结果"]

不过,不能为了缩短事务而丢掉一致性。

读取之后到保存之前,文章内容可能已经改变。因此,保存结果时应重新校验版本号、内容摘要或者业务状态,确认生成结果仍然对应当前数据。校验失败时,要明确选择重新计算、放弃写入或者进入补偿流程。

这是一个业务设计取舍:

减少事务覆盖的时间,同时用明确的并发校验补上被拆开的时间窗口。

同样,批量任务也不宜无条件把所有数据包在一个超大事务里。可以按业务允许的原子性边界拆批,并为每一批设计检查点和失败恢复方式,而不是只把一次循环里的 SQL 优化得更快。

九、把数据库变更纳入真正的发布流程

1. 发布前检查只能降低风险,不能消除竞态

DDL 发出之前检查一次长事务,当然有价值。

但检查结束后仍可能出现新事务。因此,我建议把“发布前检查”和“执行时短等待预算”同时使用,不能让前者替代后者。

对于涉及重建表的变更,还应评估资源消耗与复制延迟。MySQL 官方文档指出,长时间在线 DDL 可能造成复制延迟,主库允许业务并发执行,并不意味着副本没有追赶压力。

2. 应用回滚,不应默认等于删除刚加的字段

对于新增可空字段这样的兼容性变更,可以优先采用分阶段发布:

先增加兼容字段,再发布使用它的应用,待回滚观察期结束之后,再考虑移除旧结构。

这样设计的目的,是让应用回滚时仍能使用已经扩展的数据库结构,而不是临时再执行一次反向 DDL。

MySQL 的 ALTER TABLE 会涉及隐式提交,不能把它当成普通 DML,期待用外层 ROLLBACK 撤销。原子 DDL 也不等于用户可以把多条结构变更任意组合成可回滚事务。

3. 在线变更工具也需要切换窗口

pt-online-schema-changegh-ost 可以提供更可控的数据迁移方式,但不能因此忽略最终切换阶段。

前者会通过表重命名完成切换,并需要额外处理外键等约束;后者也明确提供切换锁超时等控制项。工具的价值在于改善过程控制,而不是消除所有锁竞争。

选型时应该问的,不只是“能不能在线改”,而是:

能不能限制影响、推迟切换、识别失败,并在失败后确认数据库究竟处于什么状态。

4. 测试失败路径,而不只是成功路径

我建议至少把以下场景放进数据库变更演练:

演练场景 需要验证的结果
没有竞争事务 变更成功,业务读写符合预期
存在访问目标表的长事务 变更按预算失败,不持续放大阻塞
指定算法不被支持 发布中止,不偷偷放宽算法限制
客户端在执行期间断开 重新检查表结构和迁移记录,不盲目重试
新旧应用版本短暂共存 两个版本都能正确访问当前结构

这些不是性能测试结果,而是发布流程应该主动验证的行为边界。

十、结语:在线变更的关键,不是永远成功,而是失败可控

一次加字段引发的故障,往往不是某个孤立参数配置错误。

它可能是未结束事务、DDL 排队、后续查询等待,以及共享连接池耗尽共同作用的结果。

真正需要改变的,是对“在线”的理解:

Online DDL 提供并发能力,但不会替你定义业务能等待多久,也不会替你决定故障时应该牺牲哪一项操作。

生产环境中的数据库变更,应该有明确算法、有等待预算、有诊断入口,也有停止条件。完成之后,还要能验证表结构、应用兼容性与业务状态。

比“这条 DDL 平时只需要几毫秒”更重要的问题是:

当它没能立即完成时,系统会怎样?

能回答这个问题,数据库变更才真正进入了工程可控的范围。

0 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论