18 KiB
tags, create time
| tags | create time | |||||
|---|---|---|---|---|---|---|
|
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意味着上一个迁移可能只执行了一半——表可能被删了但索引还没建完。强制清脏前务必确认:
- 当前 schema 状态与预期版本是否一致
- 如果有不一致,需要手工补跑缺失的 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 的三大限制
- 不支持外键 — 有外键约束的表无法使用
- 触发器开销 — 每个插入/更新/删除都要额外执行触发器
- 内存占用 — 影子表和原表在同一实例上,磁盘空间要预留至少 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] 为什么需要回滚再重跑? 这一步验证了两件事:
- down 脚本可用 — 不是所有迁移都有 down 脚本
- 幂等性 — 先 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 的迁移文件完全一致,任何手动改库的行为都应该被禁止。
关联笔记
- hhs/GORM/17-迁移工具 — GORM 自带的 AutoMigrate vs 手动迁移工具的权衡
- hhs/GORM/01-安装与初始化 — GORM 的表结构创建机制