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:23:33 +08:00

340 lines
9.7 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 --> Dev{"使用场景?"}
MigrationFile --> Dev
Dev --> |快速原型/个人项目| Quick["✅ AutoMigrate 够用"]
Dev --> |团队协作/生产环境| 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{},
)
}
```
AutoMigrate 会在以下情况下自动操作:
- **表不存在** → CREATE TABLE
- **列不存在** → ALTER TABLE ADD COLUMN
- **列类型不匹配** → ALTER TABLE MODIFY COLUMN
- **索引不存在** → CREATE INDEX
### 增量更新
```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{})
// 不会删除已有的字段,只会添加新列和索引
```
> [!warning] AutoMigrate 的限制
> - **不能删除**不再出现在 struct 中的列(需要手动处理)
> - **不能重命名**列(需要先删除再创建)
> - **不能修改主键**定义
> - MySQL 下 `ALTER TABLE MODIFY COLUMN` 可能会重建整张表(锁表!)
> - 某些复杂的类型变更(如 varchar 改 int)可能不被支持
### 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 的组合
> DryRun 模式下的 AutoMigrate 会构建 SQL 语句但**不执行**。结合这个特性可以做迁移前校验——在 CI 中检查新模型是否会导致破坏性变更。
## Migrator 接口 —— 细粒度控制
GORM 提供了 `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")
```
> [!note] 不同驱动的 Migrator 实现
> 每个驱动都有自己的 `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
-- +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;
```
与 GORM 集成:
```go
func migrateDB(db *gorm.DB) {
// 先让 GORM 做必要的表存在性检查和基础初始化
db.AutoMigrate(&User{})
// 然后用 goose 执行版本化迁移脚本
goose.Run("up", dsn)
}
```
### 方案二: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.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);
-- <向下迁移>
ALTER TABLE users DROP COLUMN IF EXISTS email;
```
```go
// 启动时执行迁移
func initDB() {
m, err := migrate.New(
"migrations/file://./migrations",
dsn,
)
if err != nil {
log.Fatal(err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
log.Fatal(err)
}
// 迁移成功后才连接 GORM
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{})
}
```
### 方案三:自建轻量级迁移表
适合小型项目——用一个表记录当前迁移版本号:
```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?
| 维度 | AutoMigrate | 版本化迁移 |
|------|------------|-----------|
| 可预测性 | GORM 内部决定执行顺序 | SQL 完全可控 |
| 回滚能力 | ❌ 无内置回滚 | ✅ 有 Down 脚本 |
| 团队协作 | 容易冲突(各自改了 model 就 auto) | 合并 SQL 文件 |
| 数据库差异 | 自动处理方言 | 手写 SQL 需考虑目标 DB |
| CI/CD 集成 | 难判断是否有未执行的变更 | migration_version 表可做 gate |
| 审计追踪 | ❌ | ✅ 谁在什么时候改了什么 |
## 迁移策略对比
```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: Check AutoMigrate consistency
run: |
# 创建一个空的临时 DB,运行 AutoMigrate,然后比较 schema
go run ./cmd/check-schema ${{ secrets.DB_DSN }}
```
## 常见坑点速查
| 问题 | 原因 | 解决方案 |
|------|------|---------|
| AutoMigrate 删不掉旧字段 | GORM 设计上不支持 drop | 手动写迁移脚本或手动 DROP |
| 大表 ALTER 导致服务中断 | InnoDB 全表重建 | 用 pt-online-schema-change 或 gh-ost |
| 迁移脚本不幂等 | 重复部署报错 | 使用 IF NOT EXISTS 条件判断 |
| 本地环境与生产环境不一致 | 不同数据库方言 | Docker 容器里跑相同版本的 DB |
| 忘记执行 Down 脚本 | 无法回滚 | 建立迁移脚本审查流程 |
## 关联笔记
- [[01-安装与初始化]]
- [[02-模型定义]]
- [[08-事务管理]]
- [[13-多数据库支持]]