Schema 版本化迁移与在线 DDL

表结构变更(DDL)应像代码一样版本化、可评审、可回放。手工执行 ALTER TABLE 是生产事故的高发区,尤其在大表上。本章讲清迁移工具、演进模式与在线变更。

为什么需要版本化迁移

  • 可追溯:每个环境(开发/测试/生产)的结构一致,来源清晰。
  • 可重放:新环境能一键从零建库到最新版本。
  • 可评审:DDL 与代码一起走代码评审与 CI。
  • 可回滚:出现问题时有明确的回退路径。

迁移工具

工具语言/生态特点
golang-migrateGo轻量,CLI + 库,支持多数据库
GooseGo简单,Go 函数式迁移
Atlas多语言声明式 schema,自动生成迁移
FlywayJava/JVM成熟,社区版功能完整
LiquibaseJava/JVMXML/YAML 声明式
AlembicPythonSQLAlchemy 配套
sqldefGo声明式 diff

golang-migrate 为例:

# 安装
go install -tags 'mysql postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

# 生成迁移文件(时间戳前缀,保证顺序)
migrate create -ext sql -dir db/migrations -seq add_order_index

# 应用 / 回滚
migrate -database "$DATABASE_URL" -path db/migrations up
migrate -database "$DATABASE_URL" -path db/migrations down 1

目录结构遵循「成对」约定:

db/migrations/
├── 000001_init.up.sql
├── 000001_init.down.sql
├── 000002_add_order_index.up.sql
└── 000002_add_order_index.down.sql
CREATE INDEX idx_orders_status_created
  ON orders (status, created_at);
DROP INDEX idx_orders_status_created ON orders;

在 Go 代码中嵌入并在启动时迁移:

package main

import (
    "embed"
    "log"

    "github.com/golang-migrate/migrate/v4"
    "github.com/golang-migrate/migrate/v4/database/mysql"
    "github.com/golang-migrate/migrate/v4/source/iofs"
)

//go:embed db/migrations/*.sql
var migrations embed.FS

func runMigrations(db *sql.DB) error {
    src, err := iofs.New(migrations, "db/migrations")
    if err != nil {
        return err
    }
    driver, err := mysql.WithInstance(db, &mysql.Config{})
    if err != nil {
        return err
    }
    m, err := migrate.NewWithInstance("iofs", src, "mysql", driver)
    if err != nil {
        return err
    }
    if err := m.Up(); err != nil && err != migrate.ErrNoChange {
        return err
    }
    log.Println("migrations applied")
    return nil
}
Warning

迁移应在部署流程中由单一实例串行执行,并加分布式锁或迁移表防止多实例并发迁移。多数工具会在数据库中记录版本表(如 schema_migrations),天然提供幂等与顺序保证。

expand-contract:安全的结构演进

直接「改列名/改类型」会同时破坏新旧代码。安全的做法是分阶段演进,让新旧版本共存

目标:把 users.name 拆成 first_name + last_name

1. Expand   新增 first_name / last_name 列(可空)
2. 双写     应用同时写 name 与新列(迁移期)
3. 回填     分批把历史数据 name 拆入新列
4. 切换     应用改为只读写新列
5. Contract 确认无引用后,删除 name 列

每一步都可独立发布与回滚,避免「一次性大迁移」带来的停机和不可逆风险。

-- 1. 扩展:新增列(先允许 NULL,避免锁与默认值回填)
ALTER TABLE users ADD COLUMN first_name VARCHAR(64) NULL;
ALTER TABLE users ADD COLUMN last_name  VARCHAR(64) NULL;
-- 3. 分批回填,避免长事务与大锁
UPDATE users
SET first_name = SUBSTRING_INDEX(name, ' ', 1),
    last_name  = SUBSTRING_INDEX(name, ' ', -1)
WHERE first_name IS NULL
LIMIT 1000;   -- 循环执行直到回填完成
-- 5. 收缩:确认代码不再引用后删除旧列
ALTER TABLE users DROP COLUMN name;

大表在线 DDL

直接 ALTER TABLE 在大表上可能长时间锁表。对策:

手段说明
MySQL 8 Instant DDL加列、改默认值等操作秒级完成ALGORITHM=INSTANT
gh-ostGitHub 出品,通过 binlog 同步影子表,无触发器在线变更
pt-online-schema-changePercona 工具,用触发器同步影子表
PostgreSQLPG 11+ ADD COLUMN 带常量默认值不重写全表;建索引用 CONCURRENTLY
-- 优先尝试 INSTANT,失败再退回其他算法
ALTER TABLE orders
  ADD COLUMN channel VARCHAR(16) NULL,
  ALGORITHM=INSTANT;
-- 并发建索引,不阻塞写(注意不能在事务块内执行)
CREATE INDEX CONCURRENTLY idx_orders_user
  ON orders (user_id);
gh-ost \
  --host=10.0.0.1 --database=shop --table=orders \
  --alter="ADD COLUMN channel VARCHAR(16) NULL" \
  --allow-on-master --execute
Tip

判断 DDL 是否会重写表:MySQL 中改列类型、改字符集、加索引通常需要重建;加/删列、改默认值在 MySQL 8 多为 INSTANT。执行前用 ALGORITHM=INPLACE, LOCK=NONE 显式要求,从报错中确认可否在线。

回滚与向前修复

  • 优先向前修复:多数团队只维护 up 迁移,出错时用新迁移修正,而不是 down
  • down 的陷阱:数据迁移(如拆分、回填)通常不可逆down 只能撤销结构,无法还原数据。
  • 有损变更要谨慎:删列、改类型、截断,务必先备份并在低峰执行。
  • 迁移与发布解耦:先迁移(向后兼容)→ 再发布新代码 → 最后清理旧结构。
Warning

永远不要在生产手工执行未经评审、没有备份、没有回滚方案的 DDL。高危操作包括:DROPTRUNCATE、改主键、改字符集、无 WHEREUPDATE。生产变更应走工单 + 审核 + 低峰窗口 + 备份。

跨库与分片下的迁移

  • 分库分表意味着同一张逻辑表有多个物理表,迁移需遍历所有分片(工具需支持批量)。
  • 变更必须保持各分片结构一致,否则中间件路由会出错。
  • 跨版本/异构迁移要同步核对字符集、时区、排序规则、sql_mode、序列等差异。

小结

  • Schema 像代码一样版本化:工具 + 成对 up/down + CI 评审。
  • expand-contract 分五步安全演进,新旧版本共存、逐步切换。
  • 大表用 Instant DDL / gh-ost / CREATE INDEX CONCURRENTLY 实现在线变更。
  • 优先向前修复;有损变更先备份;迁移与代码发布解耦。
  • 分片场景需遍历全部分片并保持结构一致。