--- tags: [GORM, Go, ORM, 迁移, AutoMigrate, Migrator, 版本化] create time: 2026-04-28 00:00 --- # 迁移工具 ## 概述 数据库迁移是应用演进的基础设施——每次修改模型结构都需要将变更同步到数据库。GORM 提供了原生的 `AutoMigrate`,但对于生产环境来说,通常推荐使用**版本化的迁移脚本**来获得更细粒度的控制和回滚能力。 ```mermaid flowchart LR Code["Go struct 变更"] --> AutoMigrate["AutoMigrate
自动同步 schema"] Code --> MigrationFile["版本化 SQL 文件
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 --> |个人项目/
快速原型| Small["AutoMigrate + DryRun 验证"] ProjectSize --> |小型团队/
单一数据库| Medium["自建迁移版本表
+ AutoMigrate 兜底"] ProjectSize --> |企业级/
多环境/多数据库| Large["专业迁移工具
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-多数据库支持]]