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 的表结构创建机制
|