--- tags: [MySQL, Schema Migration, flyway, goose, migrate] create time: 2026-05-16 00:00 --- # Schema 迁移管理 ## 概述 生产环境中,数据库表结构不会一蹴而就。随着业务发展,频繁的结构变更需要通过版本化的迁移脚本来管理,而不是直接在数据库中手工 ALTER TABLE。 > [!QUESTION] 思考一下 > 如果你在凌晨三点接到电话——"生产库挂了,因为一个没有回滚脚本的迁移执行了一半就崩溃了",你会怎么做? > > 这个问题的答案涵盖了本章要讨论的全部主题:**版本化**、**可回滚**、**幂等性**以及 **dirty state 恢复**。 本文档将覆盖从工具选型到生产级实战的完整流程: | 阶段 | 核心问题 | 对应章节 | |------|---------|---------| | 选型 | 该选哪个迁移工具? | 主流迁移工具对比 | | 上手 | 怎么写迁移文件?怎么跑? | golang-migrate/migrate 实战 | | 设计 | 怎么写安全的 DDL? | 迁移设计原则 | | 进阶 | 千万级大表怎么改不锁表? | 生产级大表在线 DDL | | 工程化 | 如何在 CI/CD 中安全验证? | CI/CD 集成与测试 | ## 为什么需要迁移工具? ```mermaid flowchart TD V1["v1: 初始建表
CREATE TABLE users"] --> V2["v2: 加字段
ALTER TABLE users ADD COLUMN phone"] V2 --> V3["v3: 改列类型
ALTER TABLE users MODIFY COLUMN email VARCHAR(255)"] V3 --> V4["v4: 建新表
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 ``` ### 迁移文件示例 ```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; ``` ```sql -- 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; ``` ### 命令行操作 ```bash # 创建新的迁移文件 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 方式 ```go 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`。下次运行迁移时会拒绝执行直到你手动修复: > ```bash > # 确认迁移确实完成了,清除 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 文件内嵌到二进制中: ```go 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 混合事务 ```sql -- ❌ 危险: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")。 ## 迁移设计原则 ### 幂等性 每个迁移应该是**幂等的**——多次执行不会产生副作用。 ```sql -- ❌ 非幂等:第二次执行会报错 "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 -- ❌ 危险:一条 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,最后清理旧逻辑**。 ```mermaid sequenceDiagram participant C as Code Deploy participant M as Migration participant D as Database Note over C,D: Phase 1: 部署向后兼容代码(最关键的一步) C->>+D: 支持读写新列
空值时回退读旧列 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: 部署精简代码
只依赖新列 D-->>C: OK — 旧列可以 DROP 了 ``` > [!NOTE] 渐进式淘汰时间线 > | 阶段 | 时间窗口 | 说明 | > |------|---------|------| > | 双写期 | T ~ T+2h | 同时写新旧列,用于灰度验证 | > | 数据填充期 | T+2h ~ T+24h | 通过后台任务把旧数据刷到新列 | > | 切换期 | T+24h~48h | 流量逐渐切到只读新列 | > | 清理期 | 确认无误后 | DROP 旧列 | ## 生产级大表在线 DDL 当表超过千万行时,普通的 `ALTER TABLE` 即便支持 Online DDL 也会带来可观的复制延迟和资源消耗。这时需要借助专门的工具来实现**零锁变更**。 ### 方案对比 ```mermaid flowchart LR subgraph Native["MySQL 原生"] N1["Online DDL\nMySQL 5.6+"] --> S1["INPLACE 算法
仍有短暂 LOCK"] N2["INSTANT DDL\nMySQL 8.0.12+"] --> S2["几乎不锁表
但仅限特定操作"] end subgraph ThirdParty["第三方工具"] T1["pt-online-schema-change"] --> P1["触发器 + 影子表
Percona Toolkit"] T2["gh-ost"] --> P2["binlog 解析 + 别名切换
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 提供的经典工具,原理是**创建影子表 → 触发器同步 → 原子替换**。 ```bash # 基本用法:给 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 来同步增量数据,最后用**重命名表**实现原子切换。 ```bash # 基本用法 ./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 化验证 ```yaml # .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 ```bash # 在 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 的迁移文件完全一致,任何手动改库的行为都应该被禁止。 ## 关联笔记 - [[hhs/GORM/17-迁移工具]] — GORM 自带的 AutoMigrate vs 手动迁移工具的权衡 - [[hhs/GORM/01-安装与初始化]] — GORM 的表结构创建机制