Files
cs-note/hhs/MySQL/08-工程实践/36-Schema 迁移管理.md
T
2026-05-24 11:42:38 +08:00

18 KiB
Raw Blame History

tags, create time
tags create time
MySQL
Schema Migration
flyway
goose
migrate
2026-05-16 00:00

Schema 迁移管理

概述

生产环境中,数据库表结构不会一蹴而就。随着业务发展,频繁的结构变更需要通过版本化的迁移脚本来管理,而不是直接在数据库中手工 ALTER TABLE。

[!QUESTION] 思考一下 如果你在凌晨三点接到电话——"生产库挂了,因为一个没有回滚脚本的迁移执行了一半就崩溃了",你会怎么做?

这个问题的答案涵盖了本章要讨论的全部主题:版本化、可回滚、幂等性以及 dirty state 恢复。

本文档将覆盖从工具选型到生产级实战的完整流程:

阶段 核心问题 对应章节
选型 该选哪个迁移工具? 主流迁移工具对比
上手 怎么写迁移文件?怎么跑? golang-migrate/migrate 实战
设计 怎么写安全的 DDL? 迁移设计原则
进阶 千万级大表怎么改不锁表? 生产级大表在线 DDL
工程化 如何在 CI/CD 中安全验证? CI/CD 集成与测试

为什么需要迁移工具?

flowchart TD
    V1["v1: 初始建表<br/>CREATE TABLE users"] --> V2["v2: 加字段<br/>ALTER TABLE users ADD COLUMN phone"]
    V2 --> V3["v3: 改列类型<br/>ALTER TABLE users MODIFY COLUMN email VARCHAR(255)"]
    V3 --> V4["v4: 建新表<br/>CREATE TABLE orders"]
    
    Dev["开发环境"] -->|"手动执行"| OK1["✅ 没问题"]
    Staging["测试环境"] -->|"不知道要跑哪些脚本"| FAIL1["❌ 遗漏"]
    Prod["生产环境"] -->|"怕出错不敢动"| FAIL2["❌ 停滞"]
    
    Migrate["迁移工具自动化"] --> Auto1["Dev → Staging → Prod 一致性 ✅"]
    Auto1 --> Auto2["可回滚 ✅"]
    Auto2 --> Auto3["审计追踪 ✅"]
    
    style FAIL1 fill:#EE5A24,color:#fff
    style FAIL2 fill:#EE5A24,color:#fff
    style Auto1 fill:#00D866,color:#fff

主流迁移工具对比

工具 语言 核心特性 安装方式 适用场景
golang-migrate/migrate Go 简洁 API、支持多种 database、按序执行 go get -u github.com/golang-migrate/migrate/v4/cmd/migrate@latest Go 项目首选
pressly/goose Go CLI + API、embed.FS 内嵌迁移文件、自包含二进制 go install github.com/pressly/goose/v3/cmd/goose@latest 需要将迁移嵌入二进制的团队
Flyway Java 企业级、支持多语言、CI 集成、Schema History Table Homebrew / Maven / Docker Java/跨语言团队
Laravel Migrator PHP 框架内置、自动跟踪版本 内置于 Laravel 框架 Laravel 项目
Alembic Python SQLAlchemy 生态、自动生成迁移脚手架 pip install alembic Python/数据科学
SQLAlchemy 2.0+ Python alembic 是事实标准 同上 Python Web 服务

[!TIP] 选型建议

  • Go 项目内部用 → golang-migrate(稳定、社区大)或 goose(可以 embed 进 binary,部署更方便)
  • 团队混合语言(Java/Python/Go) → Flyway(协议统一)
  • 单体项目起步 → 选你们最熟悉的语言的库即可,迁移工具的差异不如"坚持使用它"重要

golang-migrate/migrate 实战

项目结构

migrations/
├── 000001_create_users_table.up.sql
├── 000001_create_users_table.down.sql
├── 000002_add_phone_to_users.up.sql
├── 000002_add_phone_to_users.down.sql
└── 000003_create_orders_table.up.sql

迁移文件示例

-- 000001_create_users_table.up.sql
CREATE TABLE users (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE,
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(64) NOT NULL,
    status TINYINT NOT NULL DEFAULT 1,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 000001_create_users_table.down.sql
DROP TABLE IF EXISTS users;
-- 000002_add_phone_to_users.up.sql
-- safe: ADD COLUMN 默认值为 NOT NULL 时不会锁全表(MySQL 8.0.12+)
ALTER TABLE users ADD COLUMN phone VARCHAR(20) AFTER email;

-- 000002_add_phone_to_users.down.sql
ALTER TABLE users DROP COLUMN phone;

命令行操作

# 创建新的迁移文件
migrate create -ext sql -dir migrations -seq create_products_table

# 执行所有未运行的迁移
migrate -path migrations -database "mysql://user:pass@tcp(host:3306)/db" up

# 向前 migration 指定步数
migrate -path migrations -database "mysql://..." up 2

# 回退指定步数
migrate -path migrations -database "mysql://..." down 1

# 查看当前状态
migrate -path migrations -database "mysql://..." version

# 强制设置版本号(极端情况下的修复手段)
migrate -path migrations -database "mysql://..." force 3

Go API 方式

package main

import (
    "log"
    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/mysql"
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

func main() {
    m, err := migrate.New(
        "file://migrations",                          // 迁移文件目录
        "mysql://user:pass@tcp(localhost:3306)/app_db", // 数据库连接
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // 运行所有未执行的迁移
    if err := m.Up(); err != nil && err != migrate.ErrNoChange {
        log.Fatal(err)
    }
    
    // 查看当前版本
    version, dirty, err := m.Version()
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("Current migration version: %d (dirty=%v)", version, dirty)
}

[!WARNING] Dirty State 如果迁移过程中途崩溃(如网络中断),migration table 会被标记为 dirty=true。下次运行迁移时会拒绝执行直到你手动修复:

# 确认迁移确实完成了,清除 dirty 标志
migrate -path migrations -database "mysql://..." force 3

[!CAUTION] 为什么不能盲目清 dirty? dirty=true 意味着上一个迁移可能只执行了一半——表可能被删了但索引还没建完。强制清脏前务必确认:

  1. 当前 schema 状态与预期版本是否一致
  2. 如果有不一致,需要手工补跑缺失的 DDL 后再 force

预防优于修复:生产环境大表 DDL 建议在低峰期执行,并在预发环境充分验证。

goose 的 Embed FS 方式

相比 golang-migrate 需要外部挂载 migrations 目录,goose 支持将 SQL 文件内嵌到二进制中:

import (
    "embed"
    "github.com/pressly/goose/v3"
)

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

func init() {
    goose.SetBaseDir("./") // go.work 根目录
    goose.SetDialect("mysql")
}

func RunMigrate(ctx context.Context) error {
    return goose.ContextualMigrateUp(ctx, migrations)
}

好处:不需要在目标服务器维护 migrations 目录,二进制即一切,适合容器化部署。

常见陷阱与最佳实践

迁移命名规范

约定 示例 说明
序号_描述.up.sql 000003_create_orders_table.up.sql 5 位数字确保排序正确
序号_描述.down.sql 000003_create_orders_table.down.sql 每个 up 配 down
动词开头 add_phone_to_users, create_orders 可读性强
禁止空格 ✅ create_users_table ❌ create users table 避免 shell 转义问题

回滚文件不是必须写的

[!EXAMPLE] 什么时候可以省略 .down.sql?

  • DROP TABLE / DROP COLUMN → down 里怎么写?你不知道之前的 schema 长什么样。此时 down 写 "无法自动回滚" 即可。
  • 创建只读表 → down 中可以 DROP,但这种场景本身就应该尽量避免。

结论:down 脚本的价值在于"安全撤销"。如果你不确定怎么回滚,宁可留空并记录原因,也不要写一个会丢失数据的回滚脚本。

禁止在迁移中使用 DML 混合事务

-- ❌ 危险:DDL + DML 混在同一个迁移里
ALTER TABLE users ADD COLUMN bio TEXT;
UPDATE users SET bio = 'default' WHERE status = 1;

-- ✅ 拆成两个迁移
-- 000004_add_bio_column.up.sql
ALTER TABLE users ADD COLUMN bio TEXT;

-- 000005_fill_default_bio.up.sql
UPDATE users SET bio = 'default' WHERE status = 1 AND bio IS NULL;

原因:某些迁移工具对 DDL 和 DML 的事务语义处理不同,混在一起可能导致 DDL 提交了但 DML 失败,数据处于半填充状态。

索引迁移的额外关注

[!NOTE] 索引创建的锁行为 MySQL 8.0+ InnoDB 支持 Online DDL,但加索引操作依然会:

  • 扫描全表构建 B+Tree(读取量 ≈ 表大小 × 列数)
  • 期间新写入的数据会被记录在 change buffer 或 redo log 中
  • 如果表正在被高频写入,可能会触发多次 rebuild

建议:1000 万行以上的大表加索引,使用 pt-online-schema-change 或 gh-ost(见下方"生产级在线 DDL")。

迁移设计原则

幂等性

每个迁移应该是幂等的——多次执行不会产生副作用。

-- ❌ 非幂等:第二次执行会报错 "Column already exists"
ALTER TABLE users ADD COLUMN bio TEXT;

-- ✅ 幂等:IF NOT EXISTS 防止重复创建
CREATE TABLE IF NOT EXISTS products (
    id BIGINT PRIMARY KEY,
    name VARCHAR(200)
);

[!QUESTION] ALTER TABLE ADD COLUMN 如何做到幂等? MySQL 本身不支持 ADD COLUMN IF NOT EXISTS,但有一种间接做法:利用视图或存储过程先检查列是否存在。不过实践中更推荐的做法是—— 让迁移工具保证不重复执行。golang-migrate/goose/flyway 都会维护一个 schema version table,已执行过的版本不会再跑。所以你只需要在单个文件内避免幂等问题即可。

原子性

同一事务中多个 DDL 语句不是原子的(MySQL InnoDB 对 DDL 使用隐式提交)。因此 把互不相关的变更分拆到不同迁移文件,既是安全策略,也是工程最佳实践。

-- ❌ 危险:一条 SQL 包含多种变更类型
ALTER TABLE orders
    ADD INDEX idx_status (status),
    ADD COLUMN source VARCHAR(50),
    CHANGE COLUMN description detail TEXT;

-- ✅ 安全:每一步独立迁移文件,单步失败不会污染全局状态
-- migration/001_add_source_column.up.sql
ALTER TABLE orders ADD COLUMN source VARCHAR(50);

-- migration/002_add_status_index.up.sql
ALTER TABLE orders ADD INDEX idx_status (status);

-- migration/003_rename_description_to_detail.up.sql
ALTER TABLE orders CHANGE COLUMN description detail TEXT;

[!TIP] 分步的额外好处:每步可以单独评估锁时间

  • ADD COLUMN → 通常毫秒级(MySQL 8.0.12+ instantDDL)
  • ADD INDEX → 可能几分钟到几小时(需要全表扫描排序)
  • CHANGE/COLUMN TYPE → 可能需要 rebuild 表,最长

分开后你可以决定哪些用普通 up、哪些需要在低峰期手动执行。

向前兼容(Three-Phase Deployment)

[!WARNING] 最常见的线上事故场景 代码直接部署了"只读新列"的新逻辑 + 同时执行了迁移,结果新旧实例混跑期间旧实例读到 NULL 值导致业务异常。

记住黄金法则:先部署兼容代码,再改 schema,最后清理旧逻辑。

sequenceDiagram
    participant C as Code Deploy
    participant M as Migration
    participant D as Database

    Note over C,D: Phase 1: 部署向后兼容代码(最关键的一步)
    C->>+D: 支持读写新列<br/>空值时回退读旧列
    C->>D: 同时写入新旧两列
    D-->>-C: OK — 新旧schema都工作

    Note over C,D: Phase 2: 执行迁移补数据
    M->>D: ALTER TABLE ADD COLUMN new_col DEFAULT ...
    Note over M,D: 此时已有代码兜底,即使回滚也安全
    D-->>M: migration done

    Note over C,D: Phase 3: 清理旧逻辑(可选延后)
    C->>D: 部署精简代码<br/>只依赖新列
    D-->>C: OK — 旧列可以 DROP 了

[!NOTE] 渐进式淘汰时间线

阶段 时间窗口 说明
双写期 T ~ T+2h 同时写新旧列,用于灰度验证
数据填充期 T+2h ~ T+24h 通过后台任务把旧数据刷到新列
切换期 T+24h~48h 流量逐渐切到只读新列
清理期 确认无误后 DROP 旧列

生产级大表在线 DDL

当表超过千万行时,普通的 ALTER TABLE 即便支持 Online DDL 也会带来可观的复制延迟和资源消耗。这时需要借助专门的工具来实现零锁变更。

方案对比

flowchart LR
    subgraph Native["MySQL 原生"]
        N1["Online DDL\nMySQL 5.6+"] --> S1["INPLACE 算法<br/>仍有短暂 LOCK"]
        N2["INSTANT DDL\nMySQL 8.0.12+"] --> S2["几乎不锁表<br/>但仅限特定操作"]
    end

    subgraph ThirdParty["第三方工具"]
        T1["pt-online-schema-change"] --> P1["触发器 + 影子表<br/>Percona Toolkit"]
        T2["gh-ost"] --> P2["binlog 解析 + 别名切换<br/>GitHub"]
    end

    S1 -.→|"百万行以下"| OK
    S2 -.→|"500万行内 ADD/DROP COLUMN"| OK
    P1 -.→|"超大表 复杂DDL"| BIG
    P2 -.→|"超大表 要求极低延迟"| BIG

    style S2 fill:#00D866,color:#fff
    style P2 fill:#FFA500,color:#fff

pt-online-schema-change(OSC)

Percona Toolkit 提供的经典工具,原理是创建影子表 → 触发器同步 → 原子替换。

# 基本用法:给 orders 表添加索引
pt-online-schema-change \
  --alter="ADD INDEX idx_status (status)" \
  D=app_db,t=orders \
  --execute

# 生产环境推荐参数
pt-online-schema-change \
  --alter="ADD INDEX idx_status (status)" \
  D=app_db,t=orders \
  --chunk-time=0.5          # 每批处理 0.5s,控制单批影响
  --max-lag=1s              # 主从延迟超过 1s 自动暂停
  --check-interval=500      # 每 500ms 检查一次延迟
  --recursion-method=processlist  # 检测连接源的方式
  --parallel-copy=4          # 多副本并发拷贝(MySQL 8.0+)
  --no-drop-old-table       # 不立即删除旧表,先确认无误后再手工 DROP

[!CAUTION] OSC 的三大限制

  1. 不支持外键 — 有外键约束的表无法使用
  2. 触发器开销 — 每个插入/更新/删除都要额外执行触发器
  3. 内存占用 — 影子表和原表在同一实例上,磁盘空间要预留至少 1x 表大小

gh-ost(推荐)

GitHub 开源的工具,通过解析 binlog 来同步增量数据,最后用重命名表实现原子切换。

# 基本用法
./gh-ost \
  --user="migrator" \
  --password="pass" \
  --host="127.0.0.1" \
  --port=3306 \
  --database="app_db" \
  --table="orders" \
  --alter="ADD INDEX idx_status (status)" \
  --test-on-replica               # 先在副本上测试(强烈建议)
  --allow-on-master               # 最后在主库执行时去掉 --test-on-replica

# 关键参数
--cut-over=atomic           # 原子切换(默认),也有 graceful 模式
--max-lag-millis=1000       # 容忍的主从延迟阈值
--throttle-control-replicas="replica1,replica2"  # 多个副本监控点
--initially-drop-old-table  # 先删旧影子表,避免冲突
--initially-drop-ghost-table

[!TIP] 选型决策树

你的表有多大?
├─ < 100 万行 → MySQL 原生 Online DDL 就够了
├─ 100 万 ~ 1000 万 → INSTANT DDL(加/删列用 native,索引用 OSC)
└─ > 1000 万 → gh-ost(首选)或 OSC
        ↓
    有没有外键约束?
    ├─ 有 → OSC 不适用,只能用 gh-ost
    └─ 无 → gh-ost(资源更友好)优于 OSC

CI/CD 集成与测试

迁移脚本如果只在开发环境跑过就上了生产,等于开盲盒。CI 管道必须包含迁移验证环节。

Docker 化验证

# .github/workflows/migrate-check.yml
name: Migration Validation
on: pull_request
jobs:
  test-migration:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root
        ports:
          - 3306:3306
        options: >-
          --health-cmd "mysqladmin ping -h localhost"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - name: Run migrations up
        run: |
          migrate -path migrations -database "mysql://root:root@tcp(localhost:3306)/test_db" up
      - name: Verify schema
        run: |
          mysql -h localhost -u root -proot test_db -e "SHOW TABLES;"
      - name: Rollback and re-apply
        run: |
          migrate -path migrations -database "mysql://root:root:root@tcp(localhost:3306)/test_db" down $(migrate -path migrations -database "mysql://..." version)
          migrate -path migrations -database "mysql://root:root@tcp(localhost:3306)/test_db" up

[!IMPORTANT] 为什么需要回滚再重跑? 这一步验证了两件事:

  1. down 脚本可用 — 不是所有迁移都有 down 脚本
  2. 幂等性 — 先 rollback 再 apply 等价于干净状态从头跑,暴露出依赖顺序问题

Schema Diff 作为 PR Check

# 在 PR 中自动比对开发环境 schema 与迁移文件的差异
# 安装 schemacatch 或用 flyway validate
flyway -url=jdbc:mysql://localhost:3306/app_dev validate

输出示例:

VALID: Migrations checksum mismatch is not allowed
-> Currently on version: 15/15

[!QUOTE] 经验之谈 "99% 的生产事故不是因为迁移逻辑错了,而是因为本地跑的版本和 CI 检验的版本不一致。" — 保持 CI、staging、prod 的迁移文件完全一致,任何手动改库的行为都应该被禁止。

关联笔记