加一个允许为空的字段,语句敲下去,0.1 秒返回,监控没抖,业务没感知。改同一个表里某个 varchar(50) 的长度改成 varchar(100),看起来是"把限制放宽一点",结果整张表被拷了一遍,几千万行的数据跑了几个小时,磁盘在那段时间里被临时文件吃掉了一大截。这两个动作在日常沟通里都叫"在线 DDL",但它们在引擎内部走的是完全不同的两条路。
差别不在语义上。站在 SQL 层面看,两者都是一条 ALTER TABLE;站在存储引擎层面看,一个是改了一行元数据就收工,另一个是把数据重新组织了一遍。所以真正决定一次变更危不危险的,从来不是执行时长,而是它属于哪一类操作、要申请什么级别的锁、以及要额外吃掉多少磁盘和内存。把这三件事问清楚,比盯着进度条有用得多。
这件事之所以值得单独拿出来说,是因为踩坑的场景太固定:表已经跑到了几千万行,运营临时要加一个字段做活动标记,或者线上出现慢查询、DBA 急着补一个二级索引。变更本身看起来都很小。真正把库搞慢的,是"看起来小"这四个字带来的轻视——没有人会认为加一个字段需要走评审,但它恰恰可能触发一次全表拷贝。
把在线 DDL 的实现方式拆开,就三类。第一类是只改元数据,也就是 MySQL 8.0 里的 INSTANT 这类操作:语句执行完,数据文件一个字节没动,改的只是数据字典里关于这张表的描述信息,以及对已有行的读取时的补齐规则。它的耗时和表大小基本无关,几千万行和几百行的差别可以忽略。第二类是原地重建,INPLACE:数据要在表空间里重新组织,索引要重建,期间允许并发 DML 一定程度地进入,通常需要一份额外的临时空间。第三类是整表拷贝,COPY:引擎在内部新建一张影子表,把老数据一行一行搬过去,搬完再换名字、删掉老表。这一类最贵,也最容易出问题。
MySQL 里你可以用 ALGORITHM 子句显式指定走哪一类:ALGORITHM=INSTANT、ALGORITHM=INPLACE、ALGORITHM=COPY。写上去的好处不是"指定了我就能快",而是当引擎发现这个操作不支持你指定的算法时,它会直接报错拒绝,而不是悄悄降级成 COPY 去拷表。这一层防护在生产上非常值钱:比起"慢一点",更可怕的是你以为它是几秒钟的操作,实际上它在后台默默拷了三小时。也正因为这样,很多团队会把"禁止裸写 ALTER"写进规范——不带 ALGORITHM 和 LOCK 子句的变更语句,不给过审。这条规矩看着死板,实际挡掉的绝大多数都是那种"看着很小"的变更。
配套的还有一个 LOCK 子句:LOCK=NONE、LOCK=SHARED、LOCK=EXCLUSIVE。ALGORITHM 管的是"数据要不要搬",LOCK 管的是"搬的过程中别人能不能读写"。这两个维度是正交的,很多人把它们混为一谈,于是就有了"我明明写了在线,为什么还是卡住了"的困惑。
元数据级变更快,是因为它压根不碰数据。加一列可为 NULL 的列时,引擎在数据字典里记录下来:这张表现在有 N+1 列,第 N+1 列的默认值是 NULL。老的行记录在物理上仍然是 N 列,读取时引擎按规则补一个 NULL 出来。整个过程只涉及一次字典写入,所以它是"秒级"甚至"亚秒级"的,跟表多大没关系。
但它的条件相当琐碎,这也是为什么"加字段不锁表"这句话不能当成常识来用。INSTANT ADD COLUMN 通常要求:只能加在最后一列的位置,不能指定 FIRST 或 AFTER;不能是主键的一部分;表的行格式要支持这种延后补齐方式,REDUNDANT 这类老行格式往往不行;行内还可能因为之前做过一些操作而不再允许 instant;涉及压缩表、含全文索引的场景也常被排除在外。不同小版本之间的支持范围还在扩,具体到你手上那个版本能支持哪几种组合,得查对应版本的官方文档。
还有一类容易被忽略:删除二级索引在很多版本里也是元数据级的,因为索引本身就是可以随时重建的附属结构,标记失效就行。反过来,加二级索引就一定不是元数据级的——索引里要有真实数据,不做出来没法用。
一句经验总结:INSTANT 是"免费午餐",但它只对极少数特定的变更开放。判断方法很简单——看这条语句是否需要动到已有行的物理内容。需要,就不可能是元数据级。
这是最容易低估的部分。一次 INPLACE 重建或 COPY 拷贝,磁盘账单通常是"原表大小的一份"起步:新表或者新的表空间文件要写进去,数据量约等于原表(含索引)。如果你的表是 200GB,那么在执行期间需要额外准备出一个通常不小于这个量级的空间余量,具体多少取决于引擎是在线创建临时文件还是复用表空间,经验上按 1 到 1.5 倍去评估比较稳妥。
这还没完。数据写入会产生 redo 日志,innodb_online_alter_log 会缓存变更期间进入的 DML,这些都要算进去。更要命的是 binlog:如果 binlog 格式是 STATEMENT,一条 ALTER 就是一条语句;如果是 ROW 格式,整表拷贝会被记录成海量的行事件,体积可以和原表数据同量级。也就是说,主库上多了一份临时数据不说,binlog 目录也跟着涨,而这批 binlog 还要传到从库去回放。磁盘规划如果只算了"表多大",漏掉 binlog 这一份,是很典型的翻车方式。
内存同样不是免费的。排序建索引要用 sort buffer,字典缓存要装下新的表定义,临时文件的写入 Buffer 也会占用。把这些算清楚之后你会发现,在线 DDL 拼的其实是本地磁盘余量和 IO 能力——这也是为什么做这类操作时,机器的盘能不能让你自己决定大小和阵列方式很重要。一万网络深耕 IDC 19 年(成立于 2007 年),提供独立服务器租用,磁盘和内存可以按自己的节奏去配,具体配置以官网实时显示为准;你要跑的是几千万行的重建,那么"能不能临时换更大的盘、要不要给 redo 单独一块 SSD",比 CPU 主频重要得多。
聊到这里必须把 MDL(metadata lock,元数据锁)讲明白,因为它才是"加个字段把库搞慢了"这句话里最直接的凶手。
InnoDB 的行锁大家都知道,DDL 期间加行锁的情况反而不那么常见。真正形成堵塞的是 server 层的 MDL:一个查询进来会拿 MDL 读锁,一个 DDL 要拿 MDL 写锁(排他)。DDL 真正需要排他写的那段时间可能极短——INSTANT 可能只有毫秒级,INPLACE 重建的收尾阶段也不长——问题在于它在排队等待期间就已经把后面的查询全堵住了。MDL 的排队规则是写者优先的:一旦有一个排他写锁请求挂在后面,后续所有新的读请求都得排在它后面等着,即使 DDL 自己还没轮到。
于是就出现了那个让人抓狂的现象:DDL 卡在那里不动,业务侧所有涉及这张表的查询全部超时,监控上一片红,但"变更还没开始执行"。变更本身可能只需要几十毫秒,真正坑人的是前面那段不可见的等待。这也是为什么运维看问题不能只看 DDL 的执行耗时指标,要看它被阻塞了多久。有两个观测手段值得常态化用起来:一是定期看 performance_schema 里的 metadata_locks 表,直接看清谁在等谁;二是给 ALTER 语句设置一个合理的锁等待超时,宁可让它快速失败并触发告警,也不要让它无限期排队把业务一起拖死。快速失败的 DDL 只是一次没做成的变更,无限排队的 DDL 会演变成一次事故。
还需区分开 LOCK 的层级:LOCK=NONE 意味着变更期间读写都能进(这是 INPLACE 重建能达到的最好情况);LOCK=SHARED 意味着只允许读,写入会被阻塞,业务上的写接口会全部排队;LOCK=EXCLUSIVE 则是读写都堵。很多人以为"在线"就等于 LOCK=NONE,其实相当一部分 INPLACE 操作默认是 SHARED 级别,而 COPY 基本就是全程受限。
如果你的库里有一个忘记提交的事务,哪怕它只是 SELECT 了一下这张表然后睡着了,麻烦就来了:它会一直持有这张表的 MDL 读锁。你的 DDL 要拿排他写锁,必须等这个事务结束。于是刚才那条链路被引爆——DDL 排队,DDL 之后的所有查询一起排队。
这一点在实际运维中极其常见,因为它藏得深。开发同学在本地连上一个查询工具,开了自动事务、查了一把就放着不管;定时任务有一条慢 SQL 跑了一半卡住;某个报表查询本身就要跑十几分钟。这些都不是"故障",但它们中的任何一个都足以让一次本该毫秒完成的变更变成一场雪崩。
所以在变更之前查长事务是标准动作:information_schema 里的 innodb_trx 以及 SHOW PROCESSLIST 都能看出端倪,重点看时间最长、状态为 Sleep 却带着事务的连接。发现之后要么通知提交,要么由 DBA 判断后断开。这里有个取舍要说清楚:为了保证变更顺利而 kill 掉别人的连接是有代价的,决策要提前做,而不是等到 DDL 已经堵了两分钟才手忙脚乱。
还有一层连带影响:变更期间自身也是一个"长事务"的候选。一次全表拷贝持续几小时,期间它持有的锁类型和产生的 undo 都会影响周围的操作,尤其是那些依赖 purge 线程推进的清理工作。这也是大表变更倾向于放在低峰期的实际原因之一。
很多人规划变更窗口是按主库耗时算的:主库十分钟,那就留二十分钟。这个算法漏掉了从库那一整段。
原因在于回放。主库上的 DDL 在一定条件下可以并行或者由多个线程参与,但从库回放 binlog 的线程数默认是有限的,很多时候就是单线程在干。换句话说,主库上的并行到了从库这里要重新排成一列,执行只能按事件顺序推进。这也是为什么很多团队在重大变更前会临时提高从库的回放并行度,并提前确认业务侧的读是否落在从库上。主库十分钟完成的一次拷贝,从库要按顺序把同样多的事件重放一遍,期间它还得处理正常业务的写入。经验上从库追平的时间经常超过主库耗时,如果主库当时本身就是高写入状态,差距会更明显。
ROW 格式 binlog 在这里又补了一刀:主库上物理写入已经完成了,但 binlog 里记录的是全量行事件,从库要老老实实一行一行执行。这意味着从库要承担一份几乎同等量级的写入,而且很可能只能串行。
连带的风险是读写分离架构下的"读到旧数据"。变更在主库完成、从库还没追上这段时间里,从库上看到的还是旧结构,业务侧如果有 SQL 依赖新字段,就会报错。这类不一致在业务侧表现为"偶发报错过一会儿自己好了",排查起来非常浪费时间。如果业务对主从一致性敏感,那么在变更方案里就要明确:延迟到多少就暂停、超过多久要人工介入,而不是等它慢慢追。
如果要在"在线 DDL 失败原因"里排个名次,磁盘写满通常是排得很靠前的那一个。原因很直白:它使用的临时文件路径和自己配的数据目录未必在同一块盘,你盯着数据盘看还剩 40%,实际写满的可能是那个不太起眼的临时目录。磁盘检查时要把临时目录所在的挂载点单独看一遍,别只 df 数据目录。
然后是最折磨人的部分:失败之后的回滚,同样需要空间。这一点经常被忽略。一条 ALTER 执行到一半发现写不下去了,它不是简单地"删掉临时文件就完事",引擎要把已经做过的事情按事务语义退回原状,这期间一样要写入、一样要占用空间。也就是说,如果磁盘是在"刚好够"的状态下开始变更的,那么它很可能不够支持这次失败回滚。这就从"变更失败"升级成了"实例 hang 住",性质完全变了。
回滚的速度也不乐观。经验上的表现是,回滚往往不比正向执行快,有时更慢。于是你会看到这样一个局面:变更跑了四小时,磁盘满了,然后又花了几小时回滚,整晚都搭进去了。
所以磁盘余量的规划要给两次——做一次的钱,加一次失败的钱。这也是我一直建议按原表大小留出充足冗余的原因:多留的空间你不会因为用不上而损失什么,少留的空间会在最糟糕的时候跟你算总账。像一万网络这类提供独立服务器租用的服务商,磁盘和内存的组合可以自己选,在这种场景下给临时目录单独规划一块盘是很实际的做法,具体配置与可选规格以官网实时显示为准。
前面提到 ALGORITHM 子句可以用来做一层防护,因为它会让不支持的情况直接报错。但如果你没写这个子句,引擎就有可能自己决定:INSTANT 不行就退到 INPLACE,INPLACE 不行就退到 COPY。而下面这三类东西,是最常见的退化源头。
外键:表上存在外键关系时,很多变更没法走轻量路径。原因不难理解——结构调整需要同步校验子表级联关系,这不是改一个元数据就能绕过的。涉及外键的列做修改,通常就落到重建或拷贝那一档了。
触发器:表上如果已经有触发器,新增的在线变更逻辑和既有触发器的执行顺序、语义冲突都要处理,实现方式上往往就不允许走 INSTANT。
生成列:添加一个存储型生成列意味着每一行都要算出值并写进去,这是实打实的数据变更,不可能元数据化。虚拟生成列的情况相对好一些,但也受具体版本支持范围的限制。
把这三类单独记住是有意义的,因为它们都是"改表之前就已经存在的事实",而不是你在 ALTER 语句里可以临时关掉的开关。一张带外键的大表,它的变更成本天生就比同尺寸的无外键表高一个档位。选型阶段留下的东西,会在运维阶段连本带利地还回来。
原生 DDL 不好用的时候,业界的标准做法是上外部工具,主流是 pt-online-schema-change 和 gh-ost。它们的共同点是:先做一张影子表,把老表数据搬过去,在极短的一个切换窗口里把两张表换名。差别全在"怎么同步增量"上。
pt-osc 用的是触发器:在源表上建 INSERT / UPDATE / DELETE 三个触发器,业务写入的同时把变化同步到影子表。代价很直接——触发器运行在业务写入的事务里,等于给每一条写 SQL 追加了额外操作,写入延迟会上升,高并发写入的表上这个增幅可能非常明显。还有一层限制:触发器方案要求表上有主键或唯一索引才能正常工作,而且同一张表上不允许已有其他触发器,外键的处理也需要额外开关才能妥善应对。
gh-ost 走的是binlog 回放:它伪装成一个从库去拉取 binlog,把增量事件应用到影子表。触发器不进业务事务,所以对写入路径的侵入小得多,这是它相对 pt-osc 的主要优势。代价是它强依赖 binlog 格式为 ROW,而且它自己要维护一套连接、解析和应用流程,会持续消耗一定的网络和 CPU;它也提供了限流能力,可以按你设的阈值放慢搬运速度。
两个工具都不是免摩擦的:影子表意味着一份几乎等同大小的额外磁盘占用,这是绕不过去的第二份账单;切换那一下还要对源表短暂加锁、可能有极短的不可用窗口;还有老生常谈的那条——表上没有主键的话,两个工具都很吃力甚至不可用。再加上它们自身就有转速上限,一张超大表用工具搬运可能要跑上一天,这期间的磁盘占用、主从延迟、对业务 IO 的挤占,一样都不少。
把上面的散点收成一条可以照着做的顺序。
第一步,判类型。这条 ALTER 到底属于元数据级、INPLACE 重建还是 COPY 拷贝?涉及外键、触发器、生成列的先标红。判断不清就带上 ALGORITHM 去试,让引擎用报错告诉你答案,而不是让它默默降级。这一步的结论决定了后面所有人的工作量。
第二步,估空间。把临时文件、新表、redo、binlog 这几份一起算,再乘以一个安全系数。算完对照数据盘和临时目录的实际余量,不够就先扩容,不要抱着"也许够"的心态开工。
第三步,查长事务。innodb_trx 里按时间倒序看一遍,确认没有长期挂着不走的。同时确认当前主从延迟处于可接受范围,有明显的积压就别急着动。
第四步,定窗口。窗口时间 = 主库执行时间 + 从库追平时间 + 回滚余量,不是只看主库那一栏。峰值时段不做,这是常识也是纪律。
第五步,备回滚。提前想清楚"跑到一半要取消"该怎么办:哪些情况可以取消、取消之后是什么状态、要不要先加人手盯着磁盘。真出问题时,临时讨论是最差的选项。
第六步,盯过程。重点看两项:磁盘余量曲线和主从延迟。这两个指标里任何一个往坏的方向走,就要提前干预,而不是等它自己报警。
这一段要给明确的边界,不留"看情况"的口子。满足下面任何一条,我的建议是别在线做。
还有一条常被漏掉的边界:方案你自己没在预发环境上跑通过一遍,就别在线上试。预发的数据量可以比生产小,但至少要验证这条语句到底被判成了哪一类、有没有因为外键或生成列悄悄退化。这一步花的时间通常远低于在线上一次失败变更的成本。
磁盘余量不足以支撑一份完整拷贝加一次失败回滚。这是硬性条件,没有商量余地,因为这是唯一一个失败后可能演变成实例级事故的因素。表要做字符集转换。这基本必然是整表重写,量级最大的那一类,在线做的收益很小而风险很集中。变更牵扯到外键关系的调整。级联语义在线上很难校验干净,停机做反而不容易出错。表上已经有触发器,或者本身就是高并发写入的核心热表。工具方案的额外开销在这种表上会被放大,收益不划算。主从延迟本来就处于高位、或者从库规格明显低于主库。这种情况下再叠一层变更,很可能把延迟推到业务可接受范围之外。你的版本不支持你需要走的轻量算法,比如还停留在早期版本、INSTANT 覆盖不到你想做的那条语句,那么它注定是一次重建或拷贝,风险就要按重建或拷贝来管。
把这些写死成团队里的检查表,比每次靠经验拍脑袋可靠得多。变更审批时最该问的不是"这个要多长时间",而是"它属于哪一类、需要多少额外空间"。
| 变更类型 | 属哪一类 | 锁范围 | 额外空间 | 能否中途取消 |
|---|---|---|---|---|
| 行尾加可为 NULL 的列(INSTANT 支持时) | 仅元数据 | 极短排他窗口,通常无感知 | 基本不需要 | 瞬间完成,不存在中断问题 |
| 删除二级索引 | 仅元数据 | 短暂排他窗口 | 几乎不需要 | 同左 |
| 添加二级索引 | 原地重建 | LOCK=NONE 时读写可并发 | 主要是新索引与排序临时空间,通常远小于整表 | 可中断,但需要清理与回滚 |
| 扩大整数列宽度、部分 varchar 长度变更 | 原地重建(受版本与字符集条件限制) | 视操作而定,可能是 SHARED | 约等于一张表的量级,另加 redo 与 binlog | 可中断,回滚较慢 |
| 修改列的数据类型 | 整表拷贝 | 期间受限明显,写入受阻 | 约一张表,另加 redo 与 ROW 格式 binlog | 可中断,回滚同样耗时且耗空间 |
| 表字符集或排序规则转换 | 整表拷贝 | 基本等同于不可用 | 量级最大,建议按 1 倍以上冗余评估 | 可以,但代价高,不建议在线做 |
| 添加主键 | 重建或拷贝(需重组数据) | 受限明显 | 约一张表 | 可中断,回滚耗空间 |
| 添加存储型生成列、涉及外键的列变更 | 退化成整表拷贝 | 受限明显 | 约一张表,外加 binlog | 可以,需人工盯住磁盘 |
加字段一定不锁表吗?不一定。只有在它确实是元数据级操作时才几乎没有成本,而这要求版本支持、加在行尾、允许为空、行格式合适、不涉及外键等一堆条件。任何一条不满足,它就退回去重建或拷表。想知道答案,写 ALGORITHM=INSTANT 去执行,报错了就说明这条路走不通。
加索引会不会影响写入?会。即便 LOCK=NONE 允许并发写入,建索引本身要消耗 IO 和 CPU,磁盘被打满的话业务写入照样变慢。与此同时,变更期间的增量数据需要额外机制同步到新结构上,这部分开销随并发写入量上升。
磁盘要留多少才安全?经验上按不低于一张表的大小去准备额外空间,并且要把临时目录、redo 和 binlog 这几份分开算进去。再加一句:回滚也要空间,所以"刚好够"是不合格的规划。这几项加起来通常要按 1 到 1.5 倍的思路去看。
变更加到一半能停吗?技术上多数情况可以中断,但这不是"免费取消"。中断会触发清理与回滚,同样要写盘、同样耗时,有时比正向执行还慢。而且中断后表的状态需要人工确认,别当成 Ctrl+C。
从库延迟了要不要先停变更?如果是外部工具方案,用它的限流能力先把速度降下来观察,多数情况下可以继续;如果用了限流延迟还在持续上涨,就该停。原生 DDL 一旦开始,能做的干预有限,这也是为什么要在开工之前就把延迟的阈值和处置动作定好。
varchar 加长度为什么有时候要拷表?因为列的实际存储布局和长度标记绑定。当长度变化跨越了存储长度的表示阈值、或者字符集换算后字节数发生变化,已有每一行的物理内容都要重写,那就必然是重建或拷贝。在同一种表示范围内、字符集未变的情况下,才可能是原地甚至元数据级的。这也是"缩小长度"往往比"扩大长度"更贵的原因——缩小要校验已有数据会不会被截断。
大表变更放在业务低谷还是用工具?这不是二选一。工具解决的是"能不能持续对外服务",低谷解决的是"别<|hy_place▁holder▁no▁813|>的时候掉链子"。两者都应该做:用工具是为了避免长时间锁表,挑低谷是为了降低 IO 竞争、给磁盘和从库留出追赶余量。
没有主键的表能不能用这些工具?都不能指望。触发器方案依赖主键来定位要同步的行,binlog 方案同样依赖主键来保证增量回放能对应到具体记录。这类表先补主键,再谈在线变更。
本文讲的是数据库在线 DDL 的公开机制说明,涉及 INSTANT / INPLACE / COPY 三类实现、ALGORITHM 与 LOCK 子句、MDL 锁排队、临时空间与日志开销、以及 pt-online-schema-change 与 gh-ost 的实现原理,属于通用的技术原理层面内容。我没有做任何实测,也没有跑分数据;文中出现的量级描述都是经验性的定性判断,用"通常""经验上"这样的限定词标注,实际数值取决于你的数据分布、行格式、存储引擎配置、磁盘性能和当时的业务负载。
需要特别提醒的是,不同数据库、不同大版本甚至不同小版本之间的行为差异非常大。同一个ALTER语句在 MySQL 5.7、8.0 的各个小版本里可能归属不同类别,PostgreSQL、Oracle 的机制也完全是另一套。请以你实际使用版本的官方文档为准,尤其是"哪些操作支持 INSTANT""哪些操作允许 LOCK=NONE"这类条目,官方有明确的矩阵表,动手之前查一遍比任何经验都可靠。
如果你的表已经大到需要认真做这类规划,那么机器本身能不能让你按变更需求去配磁盘和内存就成了关键问题——大表重建吃的是本地磁盘余量与 IO,而不是某个抽象的"云性能指标"。深耕 IDC 19 年(成立于 2007 年)的一万网络提供独立服务器租用,可以更好地匹配这种偏运维侧的自定义需求,具体配置与可选规格以官网实时显示为准。
把全文的中心一句话说完:先判类型、再量空间,最后才谈用什么工具。磁盘余量不够,任何"在线"方案都是假的,直接安排停机窗口。
Copyright © 2013-2020 idc10000.net. All Rights Reserved. 一万网络 科技有限公司 版权所有 深圳市科技有限公司 粤ICP备07026347号
本网站的域名注册业务代理北京新网数码信息技术有限公司的产品