Files
cs-note/hhs/GORM/17-迁移工具.md
T
2026-05-24 11:42:38 +08:00

446 lines
14 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: [GORM, Go, ORM, 迁移, AutoMigrate, Migrator, 版本化]
create time: 2026-04-28 00:00
---
# 迁移工具
## 概述
数据库迁移是应用演进的基础设施——每次修改模型结构都需要将变更同步到数据库。GORM 提供了原生的 `AutoMigrate`,但对于生产环境来说,通常推荐使用**版本化的迁移脚本**来获得更细粒度的控制和回滚能力。
```mermaid
flowchart LR
Code["Go struct 变更"] --> AutoMigrate["AutoMigrate<br/>自动同步 schema"]
Code --> MigrationFile["版本化 SQL 文件<br/> goose / golang-migrate"]
AutoMigrate --> UseCase{"使用场景?"}
MigrationFile --> UseCase
UseCase --> |快速原型/个人项目| Quick["✅ AutoMigrate 够用"]
UseCase --> |团队协作/生产环境| Strict["✅ 版本化迁移方案"]
style Code fill:#4FC08D,color:#fff
style Quick fill:#3B82F6,color:#fff
style Strict fill:#EAB308,color:#fff
```
## 正文
### AutoMigrate — 一键建表
#### 基本用法
```go
func migrate(db *gorm.DB) error {
return db.AutoMigrate(
&User{},
&Order{},
&Product{},
)
}
```
以上代码展示了最基础的用法。调用时传入所有需要管理的模型即可——GORM 会依次处理每个模型对应的表,并执行以下四种操作:
- **表不存在** → CREATE TABLE
- **列不存在** → ALTER TABLE ADD COLUMN
- **列类型不匹配** → ALTER TABLE MODIFY COLUMN
- **索引不存在** → CREATE INDEX
> [!question] AutoMigrate 每次运行都会重新建表吗?
>
> 不会。它基于 Go struct tag 与数据库 schema 的差异做**增量比对**:只在确实缺少某个东西时才发出 ALTER 语句,已存在的结构会被跳过。
#### 增量更新
```go
// 第一次:只有 User 和 Order
db.AutoMigrate(&User{}, &Order{})
// 第二次:给 User 加了 Email 字段
type User struct {
ID uint
Name string
Email string `gorm:"size:128;uniqueIndex"` // 新增字段
}
db.AutoMigrate(&User{})
// 不会删除已有的字段,只会添加新列和索引
```
理解了增量更新的特性后,需要了解它的**边界**——AutoMigrate 设计上是"只加不改"的:
> [!warning] AutoMigrate 的限制
> - **不能删除**不再出现在 struct 中的列(需要手动处理)
> - **不能重命名**列(需要先删除再创建)
> - **不能修改主键**定义
> - MySQL 下 `ALTER TABLE MODIFY COLUMN` 可能会重建整张表(锁表!)
> - 某些复杂的类型变更(如 varchar 改 int)可能不被支持
#### DryRun 预览变更
有时在正式执行迁移之前,你想知道 GORM 会生成哪些 SQL。DryRun 模式可以帮到你:
```go
// 查看 AutoMigrate 会做什么,但不执行
err := db.Session(&gorm.Session{DryRun: true}).AutoMigrate(&User{})
if err != nil {
log.Printf("AutoMigrate 将产生的 SQL 错误(仅 DryRun): %v", err)
}
```
> [!tip] 生产场景建议
> 在团队项目中,可以将 DryRun + AutoMigrate 加入 CI Pipeline——每次 PR 提交时校验新模型不会引入破坏性变更。如果 DryRun 报错,说明 struct tag 变更无法在当前数据库环境下执行。
### Migrator 接口 —— 细粒度控制
AutoMigrate 适合自动化场景,但在某些情况下你需要对迁移过程进行**更精细的控制**。比如:在迁移之前先检查某个表是否已存在、获取列的元数据信息、或者手动重命名列。这时就可以通过 `db.Migrator()` 获取 Migrator 实例来调用具体的操作:
```go
migrator := db.Migrator()
// 检查表是否存在
if migrator.HasTable(&User{}) {
fmt.Println("users 表已存在")
}
// 检查列是否存在
if migrator.HasColumn(&User{}, "email") {
fmt.Println("email 列已存在")
}
// 获取列的类型信息
column, err := migrator.ColumnType(&User{}, "email")
fmt.Printf("列名: %s, 类型: %s, 长度: %d\n",
column.Name(), column.DatabaseTypeName(), column.Length())
// 重命名列(如果驱动支持)
migrator.RenameColumn(&User{}, "name", "username")
// 删除列
migrator.DropColumn(&User{}, "old_field")
// 创建/删除索引
migrator.CreateIndex(&User{}, "IdxEmail")
migrator.HasIndex(&User{}, "IdxEmail")
migrator.DropIndex(&User{}, "IdxEmail")
```
从代码可以看出,Migrator 暴露了**逐个字段级别**的操作能力——你可以精确地检查、创建、删除某个具体的列或索引。这对于在迁移脚本中执行复杂变更非常有用。
> [!note] 不同驱动的 Migrator 实现
> 每个驱动都有自己的 `Migrator` 实现(如 `*mysql.Migrator`、`*postgres.Migrator`)。并非所有操作在所有数据库上都受支持:
>
> ```go
> // 利用类型断言检查具体驱动
> if migrator, ok := db.Migrator().(*mysql.Migrator); ok {
> // MySQL 特有的迁移功能
> }
> ```
### 版本化迁移方案(生产推荐)
在生产环境中,推荐使用专门的迁移工具配合 GORM:
#### 方案一:Goose
```bash
# 安装
go install github.com/pressly/goose/v3/cmd/goose@latest
# 创建迁移文件(按时间戳命名)
goose create add_user_email sql
```
生成的文件类似 `20260101_001_add_user_email.sql`,内容结构如下:
```sql
-- +goose Up
ALTER TABLE users ADD COLUMN email VARCHAR(128) UNIQUE;
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT FALSE;
-- +goose Down
ALTER TABLE users DROP COLUMN email;
ALTER TABLE users DROP COLUMN email_verified;
```
> [!note] Goose 的注释指令
> `-- +goose Up` 和 `-- +goose Down` 是 Goose 识别迁移方向的特殊注释标记。Goose 通过解析这些标记来执行对应方向的迁移。
与 GORM 集成(从 GORM 实例中获取底层 `*sql.DB`,交给 goose 执行版本化迁移):
```go
import (
"log"
"os"
"github.com/pressly/goose/v3"
"gorm.io/gorm"
)
func migrateDB(db *gorm.DB) error {
// 从 GORM DB 中取出底层的 *sql.DB 交给 goose 管理
sqlDB, err := db.DB()
if err != nil {
return err
}
goose.SetBaseDir("./migrations") // 指定迁移文件目录
goose.SetLogger(log.New(os.Stdout, "", 0))
return goose.Up(sqlDB) // 按版本号依次执行未运行的 Up 脚本
}
```
> [!note] Goose 如何保证幂等?
> Goose 内部维护了一张 `goose_db_version` 表记录已执行的迁移版本。每次运行 `goose Up` 时只会执行版本号大于当前记录的脚本——因此重复运行不会报错。这与 AutoMigrate 的行为互补:AutoMigrate 负责确保 struct 对应的表存在,Goose 负责应用后续的版本化变更。
#### 方案二:golang-migrate
```bash
# 安装
go install github.com/golang-migrate/migrate/v4/cmd/migrate@latest
# 创建命名迁移(支持序号)
migrate create -ext sql -dir migrations -seq add_user_email
```
生成文件结构如下:
```
migrations/
├── 001_add_user_email.up.sql
└── 001_add_user_email.down.sql
```
`up` 脚本内容示例:
```sql
-- migrations/001_add_user_email.up.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- PG 特有
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(128);
ALTER TABLE users ADD CONSTRAINT unique_email UNIQUE (email);
```
`down` 脚本内容示例:
```sql
-- migrations/001_add_user_email.down.sql
ALTER TABLE users DROP CONSTRAINT IF EXISTS unique_email;
ALTER TABLE users DROP COLUMN IF EXISTS email;
```
与 GORM 集成——启动时先执行迁移再连接数据库:
```go
import (
"log"
"os"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/mysql"
_ "github.com/golang-migrate/migrate/v4/source/file"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func initDB() {
m, err := migrate.New(
"migrations/file://./migrations",
dsn,
)
if err != nil {
log.Fatal(err)
}
// ErrNoChange 表示当前已经是最新状态,不算错误
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
log.Fatal(err)
}
// 迁移成功后才连接 GORM
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{})
}
```
> [!note] golang-migrate vs Goose
> `golang-migrate` 的优势在于支持**命名迁移文件**(如 `001_add_user_email.up.sql`),文件名可读性更强。而 Goose 的默认行为是按时间戳生成序列号。两者都能与 GORM 配合使用,选择取决于团队偏好。
#### 方案三:自建轻量级迁移表
适合小型项目——用一个表记录当前迁移版本号:
```go
type MigrationRecord struct {
ID uint `gorm:"primaryKey"`
Version string `gorm:"size:32;uniqueIndex;not null"`
Applied time.Time
}
// 迁移脚本库
var migrations = []struct {
Version string
SQL string
}{
{"v0.1", "CREATE TABLE IF NOT EXISTS users (id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL)"},
{"v0.2", "ALTER TABLE users ADD COLUMN email VARCHAR(128) UNIQUE"},
{"v0.3", "ALTER TABLE users ADD COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP"},
}
func ApplyMigrations(tx *gorm.DB) error {
var appliedVersions []string
tx.Model(&MigrationRecord{}).Pluck("version", &appliedVersions)
for _, m := range migrations {
if contains(appliedVersions, m.Version) {
continue // 已执行过,跳过
}
log.Printf("执行迁移: %s", m.Version)
if err := tx.Exec(m.SQL).Error; err != nil {
return fmt.Errorf("迁移 %s 失败: %w", m.Version, err)
}
tx.Create(&MigrationRecord{Version: m.Version, Applied: time.Now()})
}
return nil
}
```
> [!question] 为什么生产环境不推荐只用 AutoMigrate?
>
> 核心原因是**不可回滚**。当你的代码从 `git` checkout 了一个旧版本,AutoMigrate 无法"撤销"之前添加的列或索引——它只会跳过已存在的部分。而版本化迁移方案通过 Down 脚本可以轻松应对回退场景:
| 维度 | AutoMigrate | 版本化迁移 |
|------|------------|-----------|
| 可预测性 | GORM 内部决定执行顺序 | SQL 完全可控 |
| 回滚能力 | ❌ 无内置回滚 | ✅ 有 Down 脚本 |
| 团队协作 | 容易冲突(各自改了 model 就 auto) | 合并 SQL 文件 |
| 数据库差异 | 自动处理方言 | 手写 SQL 需考虑目标 DB |
| CI/CD 集成 | 难判断是否有未执行的变更 | migration_version 表可做 gate |
| 审计追踪 | ❌ | ✅ 谁在什么时候改了什么 |
> [!tip] 实际开发中的常见做法
> 很多团队会选择 **AutoMigrate + DryRun** 作为本地开发的默认方式,同时在 CI/CD pipeline 中引入专门的迁移工具来做生产部署。这样既享受了开发时的便利,又保证了生产环境的可控性。
### 迁移策略对比
```mermaid
flowchart TD
ProjectSize{"项目规模?"}
ProjectSize --> |个人项目/<br/>快速原型| Small["AutoMigrate + DryRun 验证"]
ProjectSize --> |小型团队/<br/>单一数据库| Medium["自建迁移版本表<br/>+ AutoMigrate 兜底"]
ProjectSize --> |企业级/<br/>多环境/多数据库| Large["专业迁移工具<br/>goose / migrate"]
style Small fill:#A0AEC0,color:#fff
style Medium fill:#3B82F6,color:#fff
style Large fill:#4FC08D,color:#fff
```
### 迁移最佳实践
在编写任何迁移脚本之前,建立一个核心理念:**每一次数据库变更都应该是可追踪、可回滚的**。下面从三个维度展开:
#### 1. 幂等性设计
迁移脚本应该能够重复执行而不报错:
```sql
-- ✅ 幂等写法
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(20);
CREATE INDEX IF NOT EXISTS idx_users_phone ON users(phone);
-- ❌ 非幂等——重复执行会报 "column already exists" 错误
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
```
#### 2. 大表加列的最佳方式
对线上大表(百万级以上)直接 `ALTER TABLE ADD COLUMN` 会锁表导致服务不可用:
```sql
-- 推荐方式:分步骤进行
-- Step 1: 添加可为 NULL 的新列(不阻塞写入)
ALTER TABLE large_table ADD COLUMN new_column INT;
-- Step 2: 后台任务分批回填数据
-- UPDATE large_table SET new_column = default_val WHERE ... LIMIT 10000;
-- (循环直到全部回填)
-- Step 3: 加上约束(此时数据已经合规了)
ALTER TABLE large_table MODIFY COLUMN new_column INT NOT NULL DEFAULT 0;
ALTER TABLE large_table ADD INDEX idx_new_col (new_column);
```
#### 3. CI Pipeline 中的迁移检查
在持续集成流程中加入迁移检查,可以防止模型变更未经审核就部署到生产环境:
```yaml
# .github/workflows/db-check.yml
jobs:
check-migrations:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
- name: Run dependency download
run: go mod download
- name: DryRun 校验新模型变更
run: |
# DryRun 模式下 AutoMigrate 只构建 SQL 不执行
# 如果 struct tag 存在语法错误或类型不兼容,此处会报错
go run ./cmd/dryrun-check ${{ secrets.DB_DSN }}
```
配套的 Go 实现思路如下:
```go
// cmd/dryrun-check/main.go
func main() {
dsn := os.Args[1]
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{})
// DryRun 模式:构建但不执行任何 SQL
err := db.Session(&gorm.Session{DryRun: true}).AutoMigrate(
&User{}, &Order{}, &Product{},
)
if err != nil {
log.Fatalf("⚠️ 模型变更无法应用: %v\n", err)
}
fmt.Println("✅ 模型变更可以通过 AutoMigrate")
}
```
> [!tip] 进阶方案:schema 快照对比
> 可以结合 `godbcompare` 或手动导出目标库的 `SHOW CREATE TABLE` 结果,与 AutoMigrate 生成的结构做 diff——这样可以检测出 **AutoMigrate 不会执行的破坏性变更**(如删除列)。
### 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| AutoMigrate 删不掉旧字段 | GORM 设计上不支持 drop | 手动写迁移脚本或手动 DROP |
| 大表 ALTER 导致服务中断 | InnoDB 全表重建 | 用 pt-online-schema-change 或 gh-ost |
| 迁移脚本不幂等 | 重复部署报错 | 使用 IF NOT EXISTS 条件判断 |
| 本地环境与生产环境不一致 | 不同数据库方言 | Docker 容器里跑相同版本的 DB |
| 忘记执行 Down 脚本 | 无法回滚 | 建立迁移脚本审查流程 |
## 关联笔记
- [[01-安装与初始化]]
- [[02-模型定义]]
- [[08-事务管理]]
- [[13-多数据库支持]]