491 lines
18 KiB
Markdown
491 lines
18 KiB
Markdown
---
|
||
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: 初始建表<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
|
||
```
|
||
|
||
### 迁移文件示例
|
||
|
||
```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: 支持读写新列<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 也会带来可观的复制延迟和资源消耗。这时需要借助专门的工具来实现**零锁变更**。
|
||
|
||
### 方案对比
|
||
|
||
```mermaid
|
||
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 提供的经典工具,原理是**创建影子表 → 触发器同步 → 原子替换**。
|
||
|
||
```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 的表结构创建机制
|