数据库结构变更从来不是简单的元数据调整。同样一条ALTER语句,在不同表规模、不同存储引擎、不同数据库版本下,可能带来完全不同的锁行为和业务影响。大表执行一次COPY算法的改表操作,可能让整个业务停摆数小时;而没有回滚预案的删除列动作,一旦应用报错,恢复数据往往要付出数倍代价。下面的内容会围绕升级前评估、执行策略、可逆脚本和事后监控四个环节展开,把SQL结构变更变成一套可控流程。

一、先评估结构变更的影响范围,别急着执行ALTER
生产环境中最危险的操作之一,就是对大表直接执行没有明确算法和锁级别的DDL。MySQL的ALTER TABLE支持COPY、INPLACE、INSTANT三种算法。COPY算法会创建临时表并拷贝全量数据,过程中写入会被阻塞或受到严重影响;INPLACE算法在引擎内部完成重建,多数情况下允许并发DML,但某些操作仍需要短暂的排他元数据锁;INSTANT算法只修改数据字典,通常瞬间完成,但对操作类型限制较多。如果不对表规模、版本能力和操作类型做区分,就可能把一条原本可以秒级完成的变更跑成小时级的全表拷贝。
升级前的第一件事是收集目标表的基础信息,包括估算行数、数据大小、索引大小,以及是否存在外键、触发器和主从延迟。下面这条查询可以帮助快速定位库中体积最大的表,结合业务优先级判断变更窗口。
SELECT TABLE_NAME,
TABLE_ROWS,
ROUND(DATA_LENGTH / 1024 / 1024, 2) AS data_mb,
ROUND(INDEX_LENGTH / 1024 / 1024, 2) AS index_mb
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'shop'
ORDER BY DATA_LENGTH DESC
LIMIT 10;
评估时还要特别关注变更类型。添加可空列、修改列默认值、添加索引通常风险较低;而修改列类型、转换字符集、删除列、重命名列则风险较高。前者在很多数据库版本中可以利用INSTANT或INPLACE算法,后者可能需要COPY算法,并且可能触发隐式类型转换,导致已有索引失效。如果目标表已经超过百万行,或者主从延迟本身就不稳定,建议不要直接在生产执行高风险DDL,而应该选择下一节介绍的在线变更工具。
二、用在线DDL和pt-osc降低锁表风险
对于原生MySQL,可以在ALTER语句中显式指定算法和锁级别。例如为订单表增加一个可空列,可以这样写:
ALTER TABLE orders ADD COLUMN coupon_code VARCHAR(32) NULL COMMENT '优惠券码', ALGORITHM=INPLACE, LOCK=NONE;
这里ALGORITHM=INPLACE和LOCK=NONE就是告诉数据库,尽可能在引擎内部完成变更,并且不阻塞读写。但要注意,并非所有操作都支持LOCK=NONE。比如在旧版本中修改列类型、删除列或重建表时,MySQL可能忽略这个子句,退化成COPY算法。因此不能只看语法,执行前必须通过官方文档或本地测试确认该操作在目标版本中的实际行为。
对于更大、更核心的业务表,通常使用Percona Toolkit中的pt-online-schema-change。它的核心思路是:创建一个与原表结构相同但已经完成变更的影子表,然后在原表上创建触发器,把变更期间产生的INSERT、UPDATE、DELETE同步到影子表,再分批将存量数据从原表拷贝过去。拷贝完成后,通过原子RENAME把影子表切换为正式表。整个过程对原表只加很短暂的排他锁,基本不影响业务读写。
pt-online-schema-change \ --alter "ADD COLUMN coupon_code VARCHAR(32) NULL COMMENT '优惠券码'" \ --host=127.0.0.1 \ --user=admin \ --password=secret \ --port=3306 \ --charset=utf8mb4 \ --max-load=Threads_running=50 \ --critical-load=Threads_running=100 \ --chunk-size=1000 \ --recursion-method=none \ D=shop,t=orders \ --execute
使用pt-osc也有几个前提条件。表必须有主键或唯一索引,否则无法分批拷贝;不能存在外键,因为触发器同步会与外键约束产生冲突;磁盘空间至少需要原表大小的额外一倍,用于存放影子表和binlog。切换瞬间会有一个短暂的元数据锁,通常在秒级以下,但需要在低峰期执行。如果业务表已经有大量触发器,也要先检查触发器命名冲突问题。
三、采用expand-contract模式编写可逆的迁移脚本
结构变更能否安全回滚,往往取决于你有没有提前设计向前兼容的过渡方案。直接修改列类型或删除旧列,会让旧版本应用立刻报错。更稳妥的做法是采用expand-contract模式:先扩展数据库结构,让新旧代码都能运行;应用完成切换并稳定后,再收缩结构。以新增一个非空列为例,不要一步到位加NOT NULL,而是先加可空列,再回填数据,最后收紧约束。
-- 阶段一:先添加可空列,使用 INPLACE 算法并禁止锁表 ALTER TABLE orders ADD COLUMN coupon_code VARCHAR(32) NULL COMMENT '优惠券码', ALGORITHM=INPLACE, LOCK=NONE; -- 阶段二:分批回填历史数据,避免长事务 UPDATE orders SET coupon_code = 'DEFAULT' WHERE coupon_code IS NULL LIMIT 1000; -- 对应回滚:删除可空列 ALTER TABLE orders DROP COLUMN coupon_code, ALGORITHM=INPLACE, LOCK=NONE;
每一条升级脚本都应当配备对应的回滚脚本,并且放在同一个发布包或变更单里评审。缺少回滚脚本的DDL不允许进入生产。对于修改列类型这种高风险操作,更推荐增加一个新列,由应用双写新旧两个字段,数据校验通过后再切换读取逻辑,最后在保证没有旧代码依赖时删除旧列。这样可以避免一次修改造成整表重建。
-- 新增目标列,兼容旧应用读取 ALTER TABLE users ADD COLUMN mobile_new VARCHAR(20) NULL COMMENT '新手机号格式', ALGORITHM=INPLACE, LOCK=NONE; -- 双写阶段由应用同时写入 mobile 和 mobile_new -- 校验完成后切换读取,旧列暂不删除 -- 回滚脚本:删除新列,恢复只读旧列 ALTER TABLE users DROP COLUMN mobile_new, ALGORITHM=INPLACE, LOCK=NONE;
这个模式的另一个好处是回滚路径非常清晰。如果新列出现数据不一致或应用异常,只需删除新列即可回到原来的稳定状态,不需要恢复整张表。旧列的保留时间至少要覆盖一个完整的版本发布周期,通常建议保留七天以上,等监控数据确认没有旧版本应用继续访问该列,再安排独立的收缩变更删除旧列。
四、升级后的验证、监控和回滚触发条件
DDL执行完成只是整个流程的一半。真正的安全在于执行后能不能快速发现异常并做出回滚决策。升级完成后应重点观察三类指标:锁等待数量、慢查询数量和主从延迟。锁等待突然升高,说明新的表结构可能引发了更频繁的行锁或间隙锁竞争;慢查询增多,说明新的索引或列类型可能改变了执行计划;主从延迟放大,则说明从库重放DDL时可能遇到了额外的IO压力。
SELECT waiting_pid,
waiting_query,
blocking_pid,
blocking_query,
wait_age
FROM sys.innodb_lock_waits
WHERE wait_age > 5
ORDER BY wait_age DESC;
回滚的触发条件应该提前约定,而不是等到问题爆发后再临时讨论。例如变更后十分钟内出现超过五个锁等待超时、业务核心接口错误率上升百分之一、或者主从延迟持续超过三十秒,就应当立即启动回滚。执行回滚时也要有顺序:先让应用停止写入或切换到维护模式,再执行回滚DDL,最后回滚应用版本。不要只回滚应用而不回滚数据库结构,那样可能让新旧结构混用,进一步扩大问题。
回滚窗口期同样需要管理。已经删除的列很难快速恢复,所以旧列或影子表应保留足够长时间。数据库账号权限也应区分执行DDL的管理员和日常读写账号,避免开发人员在业务高峰期误执行结构变更。每次DDL都应记录审计日志,包含执行人、目标表、原始SQL、回滚SQL、执行时间和结果。这样一旦出现问题,定位和追溯都会清晰很多。
把这些动作串联起来,SQL结构变更就不再是一次赌博,而是一条可以反复执行的流水线。升级前有评估,执行中控制锁粒度,脚本设计保持可逆,升级后监控到阈值自动触发回滚,数据库升级才能跟上业务迭代的节奏。