This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hhs/MySQL/36-Schema 迁移管理.md
T
2026-05-17 00:06:11 +08:00

491 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 的表结构创建机制