
周三下午三点,一条把字段长度从 100 改到 200 的变更,在发布平台上卡了整整四十分钟,最后被人工终止。业务侧的慢查询监控一片安静,CPU、IO、连接数全在正常区间,唯独这条 DDL 一动不动。打开会话列表,执行的语句前面挂着一行不起眼的等待状态,大意是在等表的元数据锁。这种场面,只要做过线上库变更的人,大概都见过至少一次。这里说的 MDL,是元数据锁(Metadata Lock)的缩写,不是内存描述符那一套东西。它管的对象很特殊——不是某一行数据,也不是整张表的数据页,而是表的定义本身:字段结构、索引定义、字符集、注释、触发器绑定关系这些。任何一条语句要访问某张表,都得先拿到这张表定义的读锁,确认自己看到的表结构在执行期间不会变;而任何要改表结构的语句,必须拿到写锁。加锁的动作发生在语句解析之后、真正读写数据之前,由数据库内核自动完成,业务代码里完全看不见。这篇就把 MDL 的加锁逻辑掰开讲清楚:它锁什么、分几档、什么情况下会排队、排队了怎么查、怎么在工程上把风险摁下去。适合已经能写 SQL、但对线上变更为什么会被卡住说不清楚的后端和运维同学看,也适合正在设计发布流程的技术负责人参考。1. MDL 锁的不是行也不是表,而是表定义本身很多人第一次遇到 MDL 阻塞,第一反应是是不是有行锁没释放,然后去翻事务和锁等待视图,翻了半天发现什么都没有。这是因为 MDL 和行锁根本不在同一个体系里:行锁是存储引擎层的东西,锁的是索引记录;MDL 是更上层的服务层概念,锁的是表对象在数据字典里的那份定义。两层各管各的,行锁视图里自然看不到 MDL 的影子。1.1 一条普通 SELECT 触发的加锁链路一条再普通不过的查询,从连上数据库到返回结果,中间要经过好几个环节。语句进来先做词法语法解析,然后打开涉及的表——这个打开表的动作,内核会去表定义缓存里找这张表的描述信息,找不到就从数据字典里读出来放进缓存。就在打开表的这一刻,MDL 被加上了:先给全局的锁空间加一个意向锁,再给所在的库加一个意向锁,最后给这张表加一个共享类型的 MDL。为什么要这么设计?核心原因是表定义缓存在会话之间是共享的。假设有两个会话,一个正在按旧结构全表扫描,另一个把字段删了,缓存里的表定义被换掉,第一个会话手里拿着的字段偏移量就全错了,读出来的数据会直接串行错位。这种事一旦发生,不是报错那么简单,是脏数据。所以内核必须保证:只要还有人在用这份定义,定义就不能被改。加锁链路上还有一点值得注意:MDL 请求是按顺序排队的,而且一旦某个请求进入等待队列,后续同表的请求原则上不能再插队。这条规则是很多明明没有长事务,DDL 却一直等不到的根源,后面第 3 节会展开讲。1.2 元数据锁与表锁、行锁的边界在哪把三种锁放在一起对比,边界就清楚了。行锁由存储引擎实现,粒度最细,只在事务内生效,提交或回滚就释放;表锁是显式加的那种,通常用于批量导入导出场景,需要手动加手动放;MDL 则是内核自动施加、跟随语句和事务生命周期走的隐性锁,业务侧既看不见也控制不了它的加锁时机。维度行锁表锁MDL实现层存储引擎服务层服务层加锁方式自动手动自动锁对象索引记录整表数据表定义释放时机事务结束手动释放语句结束或事务结束常见症状死锁、锁等待超时显式阻塞DDL 卡住、后续查询排队表格里最后一行最关键。行锁出问题,通常会有明确的报错;MDL 出问题,表现往往是什么都不报,就是不动,这种静默阻塞对排查极不友好。我在实际工作中养成了一个习惯:只要看到 DDL 执行时间异常,第一件事不是看执行计划,而是先确认有没有会话在长时间持有这张表的 MDL。2. 从全局到表:MDL 的层级结构与模式谱系MDL 不是一把锁,是一套分层的锁体系。理解这个层级,才能明白为什么有时候你只操作一张表,却会影响到别的表的变更。层级设计的思路和文件系统的权限继承有点像:上层加意向锁,下层加实锁,意向锁的用途是快速判断这个范围里有没有人正在动东西。2.1 四层粒度与常见锁类型的对应关系常见的实现里,MDL 至少有四层粒度:全局层、库层、表层、以及更细的对象层(比如某些实现里的存储过程、函数对象)。全局层和库层一般只加意向锁,真正的共享/排他判断发生在表层。意向锁的作用是让上层的冲突检测变成 O(1)——想给某个库加排他锁,只要看库上有没有意向锁就够了,不用遍历库里所有表。表层的 MDL 类型比较丰富,不同语句对应不同档位。下面这张表是我根据自己的理解整理的常见对应关系,不同版本可能有细微差异,但大方向一致:MDL 类型典型触发语句是否阻塞 DDL共享读普通 SELECT是共享写INSERT / UPDATE / DELETE是共享高优先级带特定语义的查询、部分元数据查询是共享可升级需要后续升级为写锁的语句是共享不可写用于在线变更中间态是排他ALTER TABLE / DROP TABLE / RENAME排他,互斥一切可以看到,除了排他锁,其余都是共享大类。共享的含义是彼此之间可以并行,但和排他锁互斥。这解释了一个很常见的现象:一张表上跑着十条查询,它们之间完全不影响;但只要来一条 ALTER,就得等这十条全部跑完,而且新来的查询还会被挡在后面。2.2 兼容矩阵决定了谁能并行、谁必须排队兼容矩阵是理解 MDL 行为最直接的工具。矩阵的行是已持有的锁,列是正在请求的锁,交叉格子表示能否立即获得。下面是我常用的简化版矩阵,只列最常打交道的四类:已持有 \ 请求共享读共享写共享可升级排他共享读兼容兼容兼容冲突共享写兼容冲突冲突冲突共享可升级兼容冲突冲突冲突排他冲突冲突冲突冲突从矩阵能读出两个关键结论。第一,共享读几乎和所有东西兼容,所以普通查询不会互相阻塞,这是性能的基础。第二,共享写之间是冲突的——同一个表上两个会话同时做增删改,在 MDL 层面就要排队,虽然实际并发由行锁决定,但 MDL 这一层先做了一次粗筛。这也意味着,如果一个事务长时间持有共享写 MDL,同表上其他写事务在 MDL 层就已经被挡了,还没走到行锁那一步。很多人把 MDL 理解成只有 DDL 才会碰到的锁,这是误解。任何一个写事务都会持有共享写 MDL,一直持到事务结束。所以一个挂了半小时没提交的写事务,不光占着行锁和回滚段,还占着一份 MDL。2.3 事务边界才是 MDL 的真正生命周期MDL 的释放时机分两种情况,这个点特别容易搞混。对于自动提交的单条语句,语句执行完、事务提交,MDL 就释放;对于显式开启的事务,MDL 会一直持有到事务提交或回滚为止。注意,是事务结束,不是语句结束。这就解释了为什么我就随手开个事务看一眼数据,然后忘了提交会酿成大祸。那个会话可能只是执行了一条 SELECT,拿的是共享读 MDL,看起来人畜无害,但只要事务不结束,这份 MDL 就不放。此时如果有个 ALTER 在等排他锁,它会一直等;更糟的是,后面所有同表的新查询也会被排在 ALTER 之后——因为它们不能插队。于是原本只是一条查询卡住,最后演变成整张表的访问全部挂起。注意:排查 MDL 阻塞时,不要只看谁在等,更要看谁在持有。持有者往往是一条看起来完全正常的语句,甚至是一条执行时间只有几毫秒的 SELECT,只不过它所在的事务忘了结束。3. 复现一次 DDL 被堵死:完整排查链路光讲原理不够,我把一次真实的排查过程完整走一遍。这次的情况是:一条加索引的变更卡了接近半小时,期间业务侧陆续报了几个接口超时。整个过程我用的是先定位等待,再找持有者,最后判断能不能动的三步法,顺序不能乱。3.1 现场症状:进程列表里那行不起眼的等待状态第一步永远是看会话列表。执行查看进程的命令后,能看到一条 ALTER 语句,状态列写着在等表的元数据锁;同时在同一张表上,还能看到若干条 SELECT 和 UPDATE,状态列也是类似的等待信息。这就说明问题不在 DDL 本身,而是它被前面的什么东西挡住了,而且它后面又挡了一批人。这里有个细节值得说:等待状态的文字描述在不同版本里措辞不完全一样,有的写等待表元数据锁,有的会带上具体的锁类型。但只要是同一张表、同一类等待,基本可以锁定是 MDL 问题。这时候不要急着终止会话,先确认三件事:卡了多久、涉及哪些表、有没有正在跑的长事务。这三条信息决定了后面的处置方式。我见过有人一看到卡住就直接终止 DDL,结果 DDL 是有回滚代价的——大表加索引中途被终止,可能要回滚很久,期间锁还在。所以在动手之前,先把现场摸清楚。3.2 用系统视图把等待链一层层拆开第二步是把等待关系画出来。新一些的版本里,性能 schema 中有一张专门记录元数据锁的表,能看到当前每个会话持有哪些 MDL、在等哪些 MDL、锁的类型是什么、作用在哪个对象上。配合另一张记录锁等待关系的视图,可以直接看到谁在等谁。下面是我常用的查询思路,分三步:先查所有和元数据相关的锁记录,过滤出目标表,看清楚持锁和等锁的会话分别是谁、锁类型是什么;再查等待关系视图,确认等待链的方向,是不是 A 等 B、B 等 C 这种多级等待;最后拿会话 ID 去会话列表里对照,看这些会话对应的 SQL 是什么、事务开了多久、连接来自哪个应用。这三步做完,基本能还原出完整因果链。举个真实例子:最后查出来的持有者是某个应用连接池里的一条闲置连接,事务开在两小时前,期间只执行过一条统计查询。这条连接早就该归还了,但因为代码里手动开了事务又没在异常分支里提交,一直挂着。如果版本比较老,没有这些视图怎么办?退一步的办法是看会话列表里的时间字段和状态字段,结合业务侧的应用日志做交叉比对。再不行,还有通用日志可以临时打开看最近执行的语句,但这个对性能有影响,只能在低峰期短时间开启。3.3 排查中最容易犯的三个误判这十来年我在这件事上栽过跟头,总结出三个高频误判。第一个误判是把持有者当成元凶。看到一条查询持锁很久,就认定是它的问题。但很多时候它只是无辜的受害者——事务开得久了点,本身没做错什么。真正的问题可能在应用侧的连接管理,或者某次异常没走到提交分支。第二个误判是只查当前会话,不查历史。有些阻塞是间歇性的,你去查的时候已经恢复了,什么都看不到。这种情况需要在事前就打开相关的监控采集,把锁等待的样本留下来。临时查只能抓到正在发生的,抓不到已经过去的。第三个误判是忽略间接依赖。有一次 DDL 卡了四十分钟,查了半天发现持有者是一条对另一张表的查询。原因是那条查询里有个跨表关联,它在执行过程中同时持有两张表的 MDL,而它自己又被另一个东西挡着。这种链路得顺着等待关系一路往上追,不能只看第一层。4. 那些不写在文档里的阻塞源头文档里通常会说长事务会阻塞 DDL,但实际生产环境里的阻塞源头五花八门,有些甚至很难和事务两个字联系起来。我把这些年遇到的整理成几类,按出现频率从高到低排。4.1 长事务、未提交会话与连接池的闲置连接这是绝对的大头。长事务的来源主要有三种:一是批量任务,一次处理几十万行,事务开了很久;二是应用代码里手动控制事务,异常分支漏了提交或回滚;三是连接池里的连接被借出后,持有事务却没有归还。第三种最隐蔽。连接池通常有最大空闲时间配置,但那个配置管的是连接空闲多久被回收,管不了连接上有未提交事务。一个连接如果一直有心跳保活,空闲时间永远重置,事务就一直挂着。我遇到过最夸张的一次,一条连接挂了三天,期间所有针对那张表的 DDL 全部失败,而监控上完全看不出来异常,因为连接数是正常的。处置思路上,除了找出具体连接处理掉,更根本的是在应用侧加约束:事务的开启和关闭必须成对,用 try-finally 这类结构保证异常时也走提交或回滚;连接池配置里加上事务超时相关的检查,长时间不提交的连接要么被强制回收,要么至少告警。4.2 备份、导出与显式锁表操作备份和导出这类操作,如果用的是会持有一致性视图的方式,就可能在很长一段时间里持着共享读 MDL。原本共享读和 DDL 是冲突的,所以只要备份没跑完,DDL 就得等。大表备份跑两三个小时很常见,这期间所有涉及该表的变更都得排队。显式锁表也是类似道理。有些运维脚本为了防止数据变化会先锁表再操作,如果脚本异常退出没走到解锁那一步,锁就一直挂着。虽然会话断开时锁会自动释放,但如果会话没断、脚本卡在某一步,就会形成持续阻塞。这类问题的防范办法是流程层面的:备份和变更排期错开,备份任务设置明确的超时上限,锁表操作全部走带超时保护的工具,不允许裸写锁表语句。4.3 只读事务与从库回放带来的意外持锁只读事务在某些实现里也会持有 MDL,而且持有时间可能比想象的长。特别是那种开启一致性快照读的场景,为了让整个事务看到同一个时间点的数据,事务期间不能有表结构变化,自然就得持着 MDL 不放。从库的情况更绕。主库执行的 DDL 会在从库回放,回放过程同样需要加排他 MDL。如果从库上有长查询在跑,回放就会被挡住,进而造成主从延迟;延迟又会反过来影响主库上依赖复制状态的变更流程。我见过因为一条从库长查询导致整个变更流程卡住的案例,排查时一直在主库上找原因,找了一下午才发现问题在从库。提示:排查 MDL 阻塞时,主库和从库都要看。尤其是变更流程里带有等待从库同步这类步骤的,从库上的阻塞会以很隐蔽的方式传导回来。5. 把 MDL 风险压下去的工程做法原理清楚了,排查也会了,但真正有价值的还是让它尽量别发生。这一节讲的是我目前在用的几套做法,有些是流程约束,有些是参数配置,都是踩过坑之后沉淀下来的。5.1 DDL 发布流程里必须落地的几条硬规则第一条,变更前必须确认目标表上没有超过阈值的长事务。这个阈值我给的是 10 秒,超过就拦住不让发。实现方式很简单,发布脚本执行前先查一遍会话列表,筛出目标表上开了很久的事务,有就直接失败并打印出来。第二条,DDL 必须带锁等待超时设置。绝大多数数据库都支持在执行 DDL 前设置一个锁等待超时,超时后自动放弃而不是无限等。这个设置极其重要:没有它,一条 DDL 可能挂几个小时,期间把整张表的访问都堵死;有了它,最坏情况是失败重试,影响面可控。第三条,大表变更必须走在线变更工具,而不是直接执行原生 DDL。在线变更工具的基本思路是建影子表、分批拷数据、增量同步、最后短暂切换,把持锁时间压到毫秒级。代价是耗时长、占用额外空间,但换来的是业务无感。第四条,变更要有明确的回退方案,且回退方案本身也要评估 MDL 影响。有些变更的正向操作很快,回退操作却要重建索引,耗时更长、风险更大。这种情况必须提前想清楚,而不是等出事了再说。5.2 超时参数与重试策略怎么设超时参数分两个层面。锁等待超时决定一条语句最多等多久,设置得太短会导致正常的高频变更频繁失败,太长则失去保护意义。我的经验值是按表的访问频率分档:核心高频表设 5 到 10 秒,普通表设 30 秒,离线表可以不设或者设很长。重试策略要和超时配合。失败的 DDL 不能立刻无脑重试,得先退避一段时间,让可能存在的短事务自然结束。我的做法是重试三次,间隔分别是 30 秒、2 分钟、5 分钟,三次都失败就转人工。重试期间要有告警出去,不能默默重试到天亮。还有一个容易被忽略的参数是元数据锁的检测周期。部分实现里有专门的后台机制周期性检查是否有会话长时间持有 MDL,超过阈值就告警甚至终止。这个功能要慎用,因为终止会话本身可能带来更大问题。我的建议是开启告警但不开自动终止,让人来判断。5.3 监控指标与告警阈值监控上我最关注的三个指标:一是当前 MDL 等待的会话数,超过 5 个就要看一眼;二是单表上 MDL 等待的最长时长,超过 30 秒必须介入;三是长时间未提交的事务数量,这个指标和 DDL 本身无关,但它是所有 MDL 事故的根因。第三个指标的采集方式是从会话信息里筛选出事务开启时间早于当前时间若干秒的会话,按应用维度聚合。这个指标做出来之后,我们这边 MDL 相关的线上事故下降了八成以上,因为绝大多数问题在变成事故之前就被这个指标提前发现了。告警要区分级别。等待数超阈值一般是提醒级别,发到值班群就行;长事务超过 10 分钟且落在核心表上,应该是电话级别,因为这时候很可能已经有 DDL 在排队了。6. 几个我实际踩过的坑和对应的土办法前面讲的都是成体系的东西,最后分享几个不成体系但很实用的经验,都是真金白银换来的。6.1 一次误杀会话引发的连锁等待有次半夜处理一个 DDL 卡住的问题,看到一条会话持锁时间最长,没多想就终止了。结果终止之后 DDL 还是没跑起来,反而又多了一堆等待。原因是那条会话正在执行一个大事务的回滚,终止触发了回滚流程,回滚期间锁不但没释放,还额外占用了大量 IO 和回滚段,持续了快二十分钟。从那之后我给自己定了规矩:终止会话之前,先看这个会话已经执行了多久、涉及多少行数据。如果事务很大,终止的代价可能比等着更大。更好的做法是先看能不能从应用侧让那个连接主动提交或断开,实在不行再考虑终止,并且要预估回滚时间。判断回滚时间可以用一个粗略的估算:看这个事务涉及的数据量和当前的 IO 能力,大致估个数量级。虽然不准,但至少能判断是几秒钟还是几十分钟,这个判断直接影响处置决策。6.2 大表变更遇到复制延迟时的取舍还有一次,一个几千万行的表要加索引。在线变更工具跑到最后切换阶段,需要短暂加排他锁做表名切换。但当时从库延迟一直在涨,切换脚本配置了等待从库追上再切的逻辑,结果等了两小时还没切。这种时候的取舍很现实:继续等,业务侧的延迟指标会一直红着;不等,直接切,可能造成短时间的主从不一致,需要在事后补偿。我最后选的是暂停业务写入几十秒,让从库追平,然后再切。这个决策的前提是提前和业务方沟通好了窗口,而不是临场拍脑袋。所以大表变更这件事,技术只是一半,另一半是排期和沟通。我现在做大表变更,一定会提前准备三样东西:完整的执行时间预估、明确的暂停窗口、以及万一失败的回退演练记录。少一样都不发。另外提一个很实用的小技巧:在做任何涉及 MDL 的操作前,先在一个同等规模的测试库上完整跑一遍,把手里的所有超时、重试、监控配置都真跑一遍。测试环境跑通了,心里才有底。我见过太多测试环境数据量小所以秒过、生产环境卡住的情况了。