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/GORM/17-迁移工具.md
T
2026-04-28 20:56:51 +08:00

14 KiB
Raw Blame History

tags, create time
tags create time
GORM
Go
ORM
迁移
AutoMigrate
Migrator
版本化
2026-04-28 00:00

迁移工具

概述

数据库迁移是应用演进的基础设施——每次修改模型结构都需要将变更同步到数据库。GORM 提供了原生的 AutoMigrate,但对于生产环境来说,通常推荐使用版本化的迁移脚本来获得更细粒度的控制和回滚能力。

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 — 一键建表

基本用法

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 语句,已存在的结构会被跳过。

增量更新

// 第一次:只有 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 模式可以帮到你:

// 查看 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 实例来调用具体的操作:

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)。并非所有操作在所有数据库上都受支持:

// 利用类型断言检查具体驱动
if migrator, ok := db.Migrator().(*mysql.Migrator); ok {
    // MySQL 特有的迁移功能
}

版本化迁移方案(生产推荐)

在生产环境中,推荐使用专门的迁移工具配合 GORM:

方案一:Goose

# 安装
go install github.com/pressly/goose/v3/cmd/goose@latest

# 创建迁移文件(按时间戳命名)
goose create add_user_email sql

生成的文件类似 20260101_001_add_user_email.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 执行版本化迁移):

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

# 安装
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 脚本内容示例:

-- 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 脚本内容示例:

-- 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 集成——启动时先执行迁移再连接数据库:

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 配合使用,选择取决于团队偏好。

方案三:自建轻量级迁移表

适合小型项目——用一个表记录当前迁移版本号:

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 中引入专门的迁移工具来做生产部署。这样既享受了开发时的便利,又保证了生产环境的可控性。

迁移策略对比

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. 幂等性设计

迁移脚本应该能够重复执行而不报错:

-- ✅ 幂等写法
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 会锁表导致服务不可用:

-- 推荐方式:分步骤进行
-- 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 中的迁移检查

在持续集成流程中加入迁移检查,可以防止模型变更未经审核就部署到生产环境:

# .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 实现思路如下:

// 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 脚本 无法回滚 建立迁移脚本审查流程

关联笔记