千万级大表加字段避坑指南:从MDL锁到在线DDL工具实战

千万级大表加字段避坑指南:从MDL锁到在线DDL工具实战 先说结论给千万级大表加字段不是一条ALTER TABLE ... ADD COLUMN就能搞定的。语句确实简单但你在生产库上直接执行等你的大概率不是“执行成功”而是主从延迟飙红、业务查询卡死、一堆Waiting for table metadata lock挂在show processlist里运气差点直接拖垮整个核心交易链。这篇文章我把当时踩过的坑、排查思路、以及最后怎么安全落地的方案全盘整理出来希望能帮你少走弯路。要特别说明一下这里的实际场景是这样的有两张表tablea是源表tableb是目标表两台库甚至都不在同一个实例上数据是准实时同步的。现在需求要求在tableb上加一个新字段同时新字段的数据还得从tablea的某列转换过来。听起来是不是更熟悉了这类“跨库同步 结构变更”的场景才是生产环境里最常见的死亡组合。1. 内容整体设计与思路拆解1.1 为什么千辛万苦做成千万级后“加个字段”反而成了事故很多同学对加字段的印象还停留在“测试库百八十万行一条ALTER TABLE两三秒就跑完”。等数据量到了千万级、上亿级情况完全不同了。核心原因就是ALTER TABLE在 MySQL 里的成本并不是字面上“就加一个字段”它可能意味着整张表的重建、索引的更新、还有大量的日志写入。MySQL 5.6 之后虽然推出了在线 DDLOnline DDL可以在部分ALGORITHM和LOCK组合下不阻塞 DML但它仍然会占用大量 IO 和 CPU。尤其是当新加字段需要把旧行数据全部复制到新表结构时机器负载会瞬间拉满相当于给正在平稳运行的数据库做了一次“重体力劳动”。如果此时原本业务流量就不低那实际效果跟直接锁表差别真不大。更隐蔽的一个坑是元数据锁Metadata LockMDL。在 MySQL 中任何 DDL 操作都要先获取表的MDL写锁即使你用的是ALGORITHMINPLACE, LOCKNONE也需要在最开始和最后短暂获取MDL排他锁。如果这时候有事务没提交、持有MDL读锁你的 DDL 就会一直卡在“等待阶段”表面上看什么都没发生实际上后面所有针对这张表的读写请求全都被堵住了直接形成雪崩。所以这个问题的本质不是“加字段的 SQL 怎么写”而是**“在现有数据规模和业务并发下如何在尽量不阻塞读写、不产生主从延迟的情况下完成表结构变更”**。想清楚这个方案自然就清晰了。1.2 三条路线选错了真会翻车针对千万级大表加字段业内常用的手段无非三种原生ALTER TABLE、pt-oscPercona Toolkit 的在线表结构变更工具、gh-ostGitHub 开源的无触发器方案。我当时做方案选型时把三者的适用场景、风险都列了一遍这里直接放出来供参考。方案核心原理优点潜在风险适用场景原生 ALTER TABLE直接对原表执行结构变更操作简单、无额外依赖可能锁表、主从延迟高、复制大表数据耗时长数据量小、允许短暂锁表、负载较低pt-osc创建影子表通过触发器同步增量分批拷贝旧数据最后原子切换表名细粒度分批拷贝对负载影响小可限制拷贝速度能监控进度依赖触发器额外增加主库写放大要求 binlog 是 row 格式千万级以上、业务不能停、对主从延迟敏感gh-ost不依赖触发器基于解析 binlog 实现增量同步支持暂停、恢复、流控对主库影响小可优雅限流、可动态调整参数、可随时暂停部署相对复杂要求严格的 binlog 参数配置核心生产库、允许操作工具介入、对稳定性要求极高我最终的建议是千万级以下且确认是业务低峰期可以谨慎用原生ALTER TABLE千万级以上务必走 pt-osc 或 gh-ost。如果你想兼顾稳定性和可控性gh-ost 是首选如果你们运维体系里还没有 gh-ostpt-osc 也完全够用毕竟它在业界的验证时间更长。这个选型思路后面我会结合实操细节展开。有一点先提醒一下方案选型不是你觉得哪种“高级”就选哪种而是要看你现场环境对锁的容忍度、主从架构的稳定性、以及你手里能不能快速出回滚方案。2. 核心细节解析与实操要点2.1 原生 ALTER TABLE 的三种算法和四种锁先分清再说MySQL 里ALTER TABLE的算法参数主要有INSTANT、INPLACE、COPY三种锁级别有NONE、SHARED、EXCLUSIVE组合起来决定了这个 DDL 会不会阻塞业务。COPY是最古老的方式简单说就是建一张新表把旧数据全部复制过去这个过程必须全程锁表数据量一旦上来直接不可用。INPLACE是 MySQL 5.6 引入的允许在引擎层做原地修改部分场景下可以不阻塞 DML。INSTANT是 MySQL 8.0.12 之后才有的能力它只修改元数据不涉及数据文件的重建所以速度极快。当时我们目标库正好是 MySQL 8.0 版本所以最理想的情况是直接走INSTANT方式加列。关键来了INSTANT并不是所有场景都能触发。比如加列时有DEFAULT值且没有指定NOT NULL通常可以如果你新加的列带的是BLOB/TEXT类型或者加列的位置不是“追加到末尾”那很可能自动降级成INPLACE甚至COPY。另外如果表里已经有大量已经使用INSTANT方式变更过的列中间还有版本限制整个表可能已经积累了一定数量的“instant add column”元数据继续加的时候就会报ERROR 4080。所以实操上你不能只看“MySQL 是 8.0 所以肯定秒加”。建议先单独执行一次查看performance_schema里的信息确认实际用的算法和锁级别。通过ALTER TABLE ... ALGORITHMINSTANT, LOCKNONE显式指定如果引擎不支持会直接报错而不是帮你默默降级成COPY这样反而更安全。2.2 逃不掉的元数据锁真正的隐形杀手前面提过 MDL这里单独拿出来说是因为我踩过的坑基本都是它引起的。加字段本身不是瓶颈瓶颈在于你发起 DDL 的那一刻有没有人正卡在长时间事务上。MDL 锁分读锁和写锁普通SELECT、UPDATE、DELETE会持有 MDL 读锁ALTER TABLE需要 MDL 写锁。读锁和写锁互斥多个读锁可以共存。当你执行ALTER TABLE时如果某个长事务一直不提交它的 MDL 读锁就像占着茅坑不拉屎你的 DDL 只能在队列里等而 MySQL 的元数据锁等待又不像行锁那样有超时机制去主动杀死默认只能一直等下去。更气人的是MySQL 8.0 加入了lock_wait_timeout但默认值是 31536000 秒也就是一年。这意味着你一旦 started 这个 DDL在没获得锁之前它就是无限期等待。而所有后续发起的新查询也在它的锁队列后面排队。最终结果是你只是想加个字段结果整张表的读写全被堵死了。所以大表 DDL 前必须检查是否有未提交的长事务information_schema.innodb_trx是否有主从延迟带来的回放阻塞是否有历史会话还占着连接我自己的习惯是在执行 DDL 前先模拟探测SET SESSION lock_wait_timeout5; ALTER TABLE tableb ADD COLUMN new_col INT NOT NULL DEFAULT 0;如果语句在 5 秒内失败并报Lock wait timeout exceeded说明当前有持锁事务这时候你就得去查sys.schema_table_lock_waits视图把阻塞源找出来而不是盲目重试。2.3 实操前必须检查的四个状态无论你最终选哪种方案下面几项检查都必须在变更前做一遍缺一个都可能出大事。第一表大小和行数。千万级这个说法太笼统你得看实际行数和数据文件大小。ALTER TABLE的耗时跟数据文件大小强相关几千万行的大宽表光拷贝数据就可能个把小时。先跑一下SELECT table_name, table_rows ROUND((data_length index_length) / 1024 / 1024, 2) AS total_mb FROM information_schema.tables WHERE table_schema your_db AND table_name IN (tablea, tableb);第二binlog 格式和参数。如果你打算走 pt-osc 或 gh-ostbinlog_format 必须是ROWgh-ost 还要求binlog_row_imageFULL。确认方式SHOW VARIABLES LIKE binlog_format; SHOW VARIABLES LIKE binlog_row_image;如果不满足后续工具会直接报错提前查能少走很多弯路。第三剩余磁盘空间。INPLACE/COPY算法需要临时表空间至少预留表本身 1 倍到 1.5 倍的空间。磁盘满了变更失败不说数据库可能直接进入只读保护那损失就大了。第四主从延迟情况。如果你有从库变更期间的负载上升大概率会拖慢复制。在Slave上执行SHOW SLAVE STATUS\G看Seconds_Behind_Master是不是 0如果持续不为 0建议先解决问题、等到低峰期再操作。3. 实操过程与核心环节实现3.1 场景还原tablea 的老库和 tableb 的新表先还原一下我当时的场景。tablea在 A 库是业务源表每天持续有大量写入tableb在 B 库是目标报表/结果表数据通过一套准实时同步任务从 A 库抽取过来。现在需求是tableb必须加一个新字段new_col这个字段的值来自tablea的old_col的转换结果可能是CASE WHEN或者函数表达式。这类“跨库同步 结构变更”比单纯的给一张表加字段更麻烦因为同步任务通常是一个INSERT INTO tableb SELECT ... FROM tablea或类似逻辑。如果你直接给tableb加字段同步任务写得稍微死板一点比如明确指定列名会导致同步任务立即报错。更麻烦的是同步过程中源表数据量也在变化你必须在“停同步”“改结构”“重启同步”和“不停同步在线变更”之间做取舍。我们当时决定了两个原则第一绝不停业务源库不能停第二目标库的变更不能影响到源库写入。所以在方案上优先选了 gh-ost把目标库tableb当做被操作对象源表tablea完全不动只在变更完成后把同步任务里的目标列做一次适配。3.2 方案 A如果能用原生 ALTER TABLE底线操作是什么如果你评估下来数据量不大、锁等待窗口能接受或者你已经决定跑原生语句那步骤也要谨慎。我当时对另一个较小业务表约 300 万行就是走这个方案规则如下先确认表是 MySQL 8.0.12 以上且新加的列满足INSTANT条件非TEXT/BLOB、无复杂的表达式默认值。显式指定算法和锁级别防止引擎静默降级ALTER TABLE tableb ADD COLUMN new_col INT NOT NULL DEFAULT 0 COMMENT 新增字段 AFTER some_col, ALGORITHMINSTANT, LOCKNONE;这里AFTER some_col是我故意把字段插到某个列后面。如果你没这个诉求建议直接不写AFTER让新列追加到表末尾INSTANT成功率更高。执行前在当前会话设置一个几秒的锁等待超时SET SESSION lock_wait_timeout5;这样如果拿不到 MDL 写锁语句几秒内就失败而不是无限期卡住。执行后立刻观察主从延迟和show processlist确认没有异常会话堆积。这个方案在兼容性条件满足时体验是最顺滑的。但要注意ALGORITHMINSTANT这个关键字只有 MySQL 8.0 才支持5.7 的库就别硬试了。3.3 方案 Bpt-osc 处理千万级大表的标准姿势如果目标库还是 MySQL 5.7或者你判断原生 DDL 风险太大那就上pt-online-schema-change。工具的安装很简单Percona Toolkit 装好后直接用它。典型命令长这样pt-online-schema-change \ --hostyour_b_host \ --port3306 \ --useryour_user \ --passwordyour_password \ --alter ADD COLUMN new_col INT NOT NULL DEFAULT 0 COMMENT 新增字段 \ --charsetutf8mb4 \ Dyour_db,ttableb \ --chunk-size2000 \ --max-lag3 \ --max-load Threads_running50 \ --execute说几个关键参数--chunk-size2000每批拷多少行默认是 1000行数越大整体越快但单批对 IO 冲击也越大。建议先 2000 起步观察负载再调。--max-lag3当从库延迟超过 3 秒工具会自动暂停批量拷贝等延迟恢复。这个参数对线上环境特别重要我一般设 2-3 秒。--max-load主库Threads_running超过 50工具就暂停冷却一会儿再继续。这就是流控。它内部会做这些事创建一张和tableb结构一致的空影子表比如_tableb_new在tableb上创建三个触发器AFTER INSERT、AFTER UPDATE、AFTER DELETE然后在影子表上执行ALTER TABLE加字段接着分批把旧数据从tableb拷到影子表最后RENAME TABLE把原表换成新表旧表备份改名。这里有个大坑触发器会增加主库写放大。对写密集的业务拷数过程的每一笔写都会额外触发一次触发器写入影子表所以你要提前估算对主库的影响。我当时的做法是把--max-load调低一点控制在业务高峰期接受范围内。3.4 方案 Cgh-ost 的无触发器方案最适合夜里的核心表后来我们给最核心的那张表做变更用的是 gh-ost。和 pt-osc 相比它不依赖触发器而是伪装成一个从库拉取 binlog 事件把 DDL 期间发生的增量变更重放到影子表上。这样对源库主库几乎没有额外写放大。gh-ost 的命令大致如下gh-ost \ --hostyour_b_host \ --port3306 \ --useryour_user \ --passwordyour_password \ --databaseyour_db \ --tabletableb \ --alterADD COLUMN new_col INT NOT NULL DEFAULT 0 COMMENT 新增字段 \ --chunk-size2000 \ --max-lag-millis3000 \ --max-loadThreads_running50 \ --initially-drop-old-table \ --initially-drop-ghost-table \ --execute关键参数--max-lag-millis3000允许的最大主从延迟超了自动暂停。--max-loadThreads_running50主库线程超过 50 自动限流。--execute真正执行。如果不带这个参数默认是--test-on-replica之类的测试/预览模式不会对线上表做任何改动。我当时实测下来gh-ost 对目标库的负载影响比 pt-osc 平滑得多因为它是基于 binlog 的增量重放理论上主库压力只是多了一个从库连接。它还可以在拷数过程中动态调整--max-loadThreads_running80也可以暂停、恢复发SIGUSR1和SIGUSR2信号这个在出了问题时非常救急。使用 gh-ost 的前提是 binlog 必须是 ROW 格式且binlog_row_imageFULL。如果你库里的 binlog 还是STATEMENT格式它直接跑不起来。另外它在切换表名时需要短暂获取 MDL 写锁这个窗口很短但也要安排在低峰期。3.5 同步场景下的切换与校验细节在tableb结构变更完成后同步任务不会自动感知新列所以还要处理“从tablea同步到tableb”时的数据适配。我当时是做了一套临时同步脚本先把new_col的值填上。核心思路是从源表tablea里查出old_col字段然后做一次映射转换写回目标表tableb。这个“回填”操作必须放在 DDL 完成之后、同步任务重启之前否则会出现中间态。回填的 SQL 建议分批处理比如按主键 ID 范围分块UPDATE tableb b JOIN tablea a ON b.biz_id a.id SET b.new_col CASE WHEN a.old_col Y THEN 1 WHEN a.old_col N THEN 0 ELSE -1 END WHERE b.id BETWEEN 0 AND 500000;一次更新 50 万行循环推进对比一次更新几千万行风险要小得多。每批之间观察主从延迟控制在低位再继续下一批。同步任务本身在tableb加字段完成后也需要把插入列的清单里补上new_col并且明确new_col的来源表达式。这里最容易出错的是同步脚本里有显式列名比如INSERT INTO tableb (col1, col2, ...) VALUES ...如果源表tablea还没加上对应的old_col或者你在转换逻辑里用错了字段就会导致回填后的数据不一致。我当时是多花了半小时把同步任务全部显式列名列出来跟tableb的新结构做了一次 diff才放心重启。4. 常见问题与排查技巧实录4.1 卡在 Waiting for table metadata lock怎么把阻塞源揪出来我在实际变更中遇到最多的就是 MDL 等待。那时候现象是这样的ALTER TABLE执行后一直卡住show processlist里一大片Waiting for table metadata lock。这个状态的本质是你的 DDL 在等待 MDL 写锁而某个长事务或多个会话正持有着这张表的 MDL 读锁。定位阻塞源的技巧很实用-- 查看当前有哪些事务在跑 SELECT * FROM information_schema.innodb_trx\G -- 用系统视图查 MDL 锁等待关系MySQL 8.0 支持 SELECT * FROM sys.schema_table_lock_waits\Gsys.schema_table_lock_waits会直接告诉你是谁阻塞了谁。找到持有锁的会话 ID 后去information_schema.processlist里看它在执行什么。如果确定是不重要的长事务可以KILL掉对应会话如果是有业务意义的只能等它正常提交同时想办法通知业务方尽快提交或回滚。一个经验在变更前先用session lock_wait_timeout设置 5 秒去试水能避免很多“假死”场面。如果 5 秒内没拿到锁直接失败你就有机会从容排查而不是让一条 DDL 在那里默默堵死整个线上表。4.2 主从延迟彪到几千秒别慌这么处理做 gh-ost 或者 pt-osc 的时候工具本身有--max-lag限流所以还挺稳。但如果你用的是原生ALTER TABLE就没这个保护了主库一忙从库Seconds_Behind_Master能直接飙到几千秒。遇到这情况先看是不是 DDL 本身造成的如果SHOW PROCESSLIST里主库的ALTER TABLE还处于query end或者copy to tmp table阶段那从库延迟就是 DD 事件回放太慢导致的。这时候最忌讳的是“直接杀 DDL”因为杀了一半的 DDL 会导致表被锁住或留下临时表后续还得清理更麻烦。比较稳的做法是先让 DDL 继续跑完同时把读流量切走减少从库压力如果实在等不了可以观察SHOW SLAVE STATUS里的SQL_Delay和Retrieved_Gtid_Set、Executed_Gtid_Set确认是不是真的追不上如果确认是 DDL 回放导致延迟且你有可靠备份可以考虑先停掉从库复制STOP SLAVE SQL_THREAD等主库 DDL 结束、主库负载回落后再START SLAVE。在 DDL 执行期间临时停 SQL_THREAD 是比直接杀 DDL 更安全的选择。但要注意停了之后从库的 relay log 会继续接收如果磁盘空间不足也可能卡住操作要量力而行。4.3 磁盘临时空间不够变更失败怎么办大表的COPY或者INPLACE算法需要临时文件空间。MySQL 默认的临时表空间路径在数据目录下空间不足时ALTER TABLE会直接失败甚至可能导致ibtmp1文件自动膨胀。我们出现过一次因为磁盘满导致库进入只读的情况非常惊险。做法建议变更前先看磁盘剩余df -h估算所需空间ALTER TABLE如果走重建一般需要和目标表大小相当的临时空间比如表占了 50GB那你就得预留 50GB 以上。如果走COPY还要更多。临时文件目录可以独立出来在my.cnf里设置tmpdir到一个大容量的独立磁盘这样即使空间膨胀也不会拖垮数据盘。4.4 几个容易被忽视的隐藏细节第一个隐藏细节是新列的位置。如果你用了AFTER 某列在 MySQL 8.0 里INSTANT算法可能直接失效5.7 更是只能COPY或INPLACE重建。我们当时就是为了让新字段跟业务字段排在一起结果实测从秒级变成了十分钟级。这个取舍一定要提前想清楚不要为了“列顺序好看”付出巨大代价。第二个隐藏细节是新列默认值与NOT NULL的取舍。在千万级大表上ADD COLUMN new_col VARCHAR(100) NOT NULL DEFAULT default_value的代价通常会比ADD COLUMN new_col VARCHAR(100) NULL高。原因是NOT NULL DEFAULT在某些算法下需要重建表来填充默认值而在 8.0 的INSTANT里虽然可以秒加但后续业务写入时也会有约束校验开销。实际落地时如果业务接受建议先允许NULL再通过回填脚本补齐数据最后可选再收紧为NOT NULL。第三个隐藏细节是同步任务和 DDL 的时序。如果你是跨库同步的场景先改tableb再改同步任务会出现“目标表多列但同步语句没带新列”这通常问题不大但如果你先改了同步任务去查tablea.old_col而tablea还没加这个列那同步任务直接报错。所以顺序必须是源表确认有数据源 → 目标表在线加列 → 回填数据 → 修改同步任务 → 重启同步。第四个隐藏细节是字符集和排序规则。新加的VARCHAR字段如果没显式指定字符集会继承表的默认字符集。如果你在回填时要从tablea直接把字符串搬过来注意两边字符集不一致会导致Illegal mix of collations错误甚至UPDATE JOIN直接失败。我当时就吃过亏最后统一给新列加了CHARACTER SET utf8mb4。4.5 常见问题速查表现象可能原因处理方式ALTER TABLE 一直卡住MDL 锁等待长事务持有读锁查sys.schema_table_lock_waitsKILL 阻塞事务执行时报ERROR 4080INSTANT 算法已无法继续加列改用INPLACE或 pt-osc/gh-ost主从延迟持续上涨大 DDL 回放太慢限流、降级、或临时停 SQL_THREADbinlog 格式不支持binlog_formatSTATEMENT改ROW并重启实例或改 session磁盘被抓爆临时表空间膨胀独立 tmpdir、预留 1.5 倍空间UPDATE JOIN 报错字符集不匹配两表字符集不同显式指定CONVERT或用CAST处理这些坑每一个都不是什么“高端问题”但它们结合起来足以让一个简单“加字段”的需求演变成 DBA 凌晨三点的大抢救。这也是我把整段经验写出来的原因。我个人的体会是在任何数据量规模面前永远不要低估 DDL 的代价。加字段只是最简单的结构变更它背后却牵连着 MDL 锁、主从延迟、磁盘空间、binlog 格式、同步链路稳定性等一串问题。施工前花半小时做状态检查和方案选型远比出事后的通宵回滚要划算得多。后来我做这类变更的时候都会习惯性地准备好回滚脚本、监控指标、和各路干系人的通知渠道形成一个标准操作流程。不是每个人都会天天碰千万级大表但碰到一次这套经验也许就能救你一次。