站点跑上两三年之后,数据库这边会开始出现一种很别扭的状态:数据不多,表也不大,但每次改表结构都要挑时间、发通知、甚至停机。个人站长最常见的做法是「夜里十二点停站,跑 SQL,重启,看能不能打开」,运气好十分钟,运气不好就是一场事故。
这篇文章讲的是在线表结构变更:哪些操作在 MySQL 里天生是阻塞的、哪些能靠参数做到不锁表、什么时候必须借助外部工具,以及在小站点上怎么用最低成本把这件事做安全。所有讨论基于 MySQL 5.7 与 8.0,InnoDB 引擎。
一、先分清:哪些 DDL 天生不阻塞,哪些必须重建表
MySQL 的 DDL 不是一类东西。5.6 引入的 Online DDL 之后,表结构变更被分成几种执行方式,其中最关键的区分是「能不能原地改」。
能原地的,通常只改元数据:改列名、改表的注释、加一个允许 NULL 的新列(在 8.0 里是 INSTANT,几乎瞬间完成)、改默认值、增删二级索引(8.0 支持 INPLACE,但要注意写放大)。这类操作即使在大表上也就是秒级,不需要专门的窗口。
必须重建表的,是那些改变了行存储格式的操作:改列类型(INT 改 BIGINT)、改字符集、把列从 NULL 改成 NOT NULL、加一个带默认值的 NOT NULL 列(在 5.7 里要重建,8.0 的 INSTANT 支持有前提)、调整主键、ROW_FORMAT 变更。重建意味着 MySQL 要在后台建一张新表、把数据全量拷一遍、再把增量同步过去,最后做一次短暂的元数据切换。数据量大的时候,这个「拷贝」阶段可能持续几十分钟甚至更久。
所以在线变更的第一步永远是分类:这次改动属于哪一类。分类错了,后面选什么工具都是白费。用一个很直接的命令就能看出来——先在不带数据的测试库上执行一次,看 SHOW PROCESSLIST 里那条语句在做什么、SHOW ENGINE INNODB STATUS 里有没有出现拷贝表的记录。测试库这一步花五分钟,能省掉一次线上事故。
二、Online DDL 的三个参数:不是设了就不锁表
很多人看文档看到 ALGORITHM=INPLACE, LOCK=NONE 就以为万事大吉。这两个选项的真实含义是「我希望」而不是「我保证」:MySQL 如果不支持,会直接报错退出(这是好事,说明它拒绝在你不接受的方式下执行),但即使支持 LOCK=NONE,在整个拷贝期间仍然需要持有元数据锁的某些阶段。
真正的麻烦在开始和结束两个瞬间。开始的瞬间需要获取一个短暂的排他元数据锁,如果这个时候有一个长事务正在读这张表,DDL 会等它;而后面的所有查询会排在 DDL 后面等锁——这就是那个著名的连锁阻塞:一个慢查询把 DDL 挡住,DDL 又把后面所有请求挡住,站点瞬间全部超时。
结束的瞬间同样危险。拷贝完成后要做元数据切换,这时如果还有未提交的长事务引用旧表,DDL 会等;等待期间新来的查询全部排队。实际事故里,绝大多数「改表把站点搞挂」都发生在这两个瞬间,而不是拷贝本身。
所以必做的准备是两件:改表前先把长事务清掉,以及设置一个足够短的 lock_wait_timeout,让 DDL 在拿不到锁的时候快速失败,而不是加入排队。
-- 1) 先看有没有长事务
SELECT trx_id, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 30;
-- 2) 让 DDL 拿不到锁就放弃,而不是把整站堵住
SET SESSION lock_wait_timeout = 5;
ALTER TABLE typecho_contents ADD COLUMN views INT NOT NULL DEFAULT 0, ALGORITHM=INPLACE, LOCK=NONE;lock_wait_timeout 只对元数据锁等待有效,设成 5 秒意味着拿不到锁就报错。报错是好事:脚本退出、告警触发、站点不受影响,你可以十分钟后再试一次,而不是让整个站点排队等三十分钟。
三、给主从结构加一条额外的纪律
如果站上有从库,DDL 的风险会乘上「复制延迟」这一项。主库上执行一个二十分钟的重建,从库要串行重放这个操作,期间从库的数据一直落后。如果你的读请求分担在从库上,用户会看到「刚发的文章在列表里不存在」这类灵异现象。
更麻烦的是 5.7 与 8.0 的差异:某些 DDL 在主库上是 INPLACE,在从库上却可能退化成 COPY,导致从库延迟远大于预期。判断办法是改完之后立刻查 SHOW SLAVE STATUS 的 Seconds_Behind_Master 和 Slave_SQL_Running_State,看它当前在跑什么。如果从库在执行 DDL,那就等它跑完再宣布变更完成,别在主库上跑完就去改应用代码。
还有一条容易被忽略的:DDL 语句在 binlog 里的记录方式。老版本里,主库如果用了 LOCK=NONE,binlog 需要记录成 ROW 格式才能保证从库一致。检查 binlog_format 是 ROW 或 MIXED(推荐 ROW),如果是 STATEMENT,某些 DDL 会直接拒绝执行或者导致从库不一致。这个参数是全局的,改它需要重启或者影响现有连接,所以应该在部署阶段就定好,而不是等改表时才发现。
四、什么时候该上外部工具
原生 Online DDL 够用的边界很清晰:表的行数在几百万以内、磁盘剩余空间足够放下一份完整拷贝、允许一次短暂的元数据锁切换。超出这个边界,就该考虑 gh-ost 或 pt-online-schema-change 这类工具。
它们的核心思路不一样,选择时要知道差别。pt-osc 用触发器把增量写到新表,拷贝期间主表的每一次写入都要额外执行触发器,对写入性能有实际影响,且触发器与某些应用逻辑(比如依赖 LAST_INSERT_ID)会打架。gh-ost 不用触发器,改成伪装成一个从库去读 binlog 拿增量,对主库的写性能影响小,但需要 binlog 是 ROW 格式,且在切换的瞬间要有一个短暂的锁表窗口(--cut-over 方式可控)。
# gh-ost 的最小用法:先演练,再真跑
gh-ost \
--host=127.0.0.1 --user=ghost --password-file=/etc/ghost.pw \
--database=zz1984 --table=typecho_contents \
--alter="ADD COLUMN views INT NOT NULL DEFAULT 0" \
--allow-on-master --exact-rowcount \
--chunk-size=1000 --max-load=Threads_running=20 \
--initially-drop-ghost-table --initially-drop-old-table \
--execute--max-load 是这类工具最有价值的参数:它会持续监控 Threads_running,一旦超过阈值就暂停拷贝,等负载降下来再继续。这让变更可以在一台已经被业务占满的机器上安全执行。--exact-rowcount 提供一个准确(但需要扫一遍表)的进度百分比,数据量不大时值得开,能让你知道还剩多久。
真跑之前一定要做一次 --dry-run 或者在一个从库上完整跑一遍,并且确认三件事:磁盘剩余空间够放新表加 binlog、ghost 账号有建表删表和外键相关权限、以及该表没有被外键引用(有外键时工具会直接拒绝,这是设计上的保护)。
对一个人维护的内容站来说,我在大多数情况下会先用原生的 ALGORITHM=INPLACE 试一次,报错再考虑 gh-ost。原因是工具本身的引入成本不低(账号、权限、磁盘、监控),而几十万行的表用原生方式通常几分钟就完成。
五、变更的安全流程:五步,每步都可退
把上面的技术点串成流程,才不容易出错。我固定用五步,每一步都有明确的通过判据。
第一步,在测试库上执行。目标是拿到两样东西:真实耗时,以及 MySQL 选择了哪种算法。耗时决定了要不要开窗口,算法决定了要不要换工具。
第二步,备份被改动的表。不需要全库备份,只 mysqldump 这一张表就够,但要确认导出的文件大小正常、结尾有 Dump completed 字样。表结构变更出问题时的回退路径就是「把这张表恢复回去」,而这个路径只有在你真的有一份能用的 dump 时才存在。
第三步,检查前置条件。磁盘空间(至少是表大小加索引大小的两倍)、长事务、复制状态、以及当前连接数。这四项任何一项不满足就先解决,不要「边跑边看」。
第四步,执行。带上 LOCK=NONE,带上 lock_wait_timeout,并在另一个终端里持续观察 Threads_running 和 SHOW PROCESSLIST。观察的目的不是「看着它」,而是「在它开始阻塞的时候能立刻 kill」。这就是为什么 lock_wait_timeout 要短:让系统自己快速放弃,比你手忙脚乱地找线程 ID 可靠得多。
第五步,验证。查 SHOW CREATE TABLE 确认结构对了,查 SELECT COUNT(*) 确认行数没变,然后改应用代码上线,最后从应用侧访问一个真正用到新列的页面。这一步最容易被跳过,但它决定了你是「改完了」还是「以为改完了」。
六、三个真实踩过的坑
第一个坑:加了一个带默认值的 NOT NULL 列,以为 8.0 是瞬时的,结果表里有一列是 TEXT 类型,触发了重建。8.0 的 INSTANT 加列有前提条件——新列必须是表里的最后一列、不能是全文索引的一部分、表不能是 ROW_FORMAT=COMPACT 等。改之前用 SHOW CREATE TABLE 看一下 ROW_FORMAT 和列的顺序,能避免这次误判。
第二个坑:改完之后应用报「Unknown column」,因为代码里用的是 SELECT *,并且有缓存层缓存了结果集的列结构。这类问题不是数据库的错,但会让人误以为是 DDL 没生效。判据很简单:直接 mysql -e "SHOW COLUMNS FROM 表 LIKE '新列'" 确认列在,问题就必在应用侧。
第三个坑:变更过程本身没问题,但第二天备份失败——因为备份脚本里 mysqldump --single-transaction 遇到了一张在变更期间被重建的表,元数据不一致。这类连锁效应是「改表」这件事真正麻烦的地方:它影响的不只是这一次查询,还有所有依赖表结构的东西。所以变更完成后,除了验证应用,还要跑一次备份脚本和一次恢复演练。
七、小结
在线表结构变更的核心不是工具,而是分类与时机。先分清这次改动是改元数据还是重建表,重建类要清长事务、设短 lock_wait_timeout、盯住开始与结束两个瞬间。有从库的话,等从库把 DDL 重放完再宣布完成。超出原生的适用范围再用 gh-ost,并且一定先 dry-run。流程上固定为「测试库 → 备份单表 → 查前置条件 → 带保护执行 → 五项验证」,最后别忘了跑一次备份和恢复演练,因为改表影响的是整个体系,不只是这一条语句。