别让数据库靠手改:Go golang-migrate 从版本脚本到生产发布详解

简介

业务代码有 Git 记录,数据库结构也应该有版本记录。

没有迁移工具时,数据库变更经常变成这样:

text 复制代码
本地手动加了一个字段
测试环境忘了建索引
生产环境不知道跑到哪个版本
上线失败后不知道怎么回滚
多人同时改表,脚本顺序互相打架

golang-migrate 解决的就是这个问题。

它把每次数据库结构变更写成一对文件:

text 复制代码
000001_create_users_table.up.sql
000001_create_users_table.down.sql

up.sql 负责升级,down.sql 负责回滚。执行过哪个版本、当前是不是失败状态,都会记录在数据库里的 schema_migrations 表中。

一句话概括:

text 复制代码
golang-migrate 让数据库结构变更像代码一样可追踪、可执行、可回滚。

golang-migrate 适合什么场景

适合:

text 复制代码
Go 项目需要管理数据库表结构
生产环境不想靠手动改表
需要把 DDL 纳入 Git 评审
需要支持回滚或指定版本迁移
需要在 CI/CD、Docker、Kubernetes 里自动执行迁移
GORM 项目想替代生产环境 AutoMigrate

不适合:

text 复制代码
只有一次性本地 demo
数据库结构完全不会变化
团队已经有成熟 Liquibase、Flyway、Atlas 流程
复杂数据修复更适合单独脚本和人工审核

golang-migrate 不负责设计表结构,也不负责判断 SQL 是否安全。它负责按版本顺序执行迁移,并记录执行状态。

和 GORM AutoMigrate 的区别

GORM AutoMigrate 很方便:

go 复制代码
db.AutoMigrate(&User{}, &Order{})

但它更适合开发阶段快速建表。

生产环境更看重这些东西:

text 复制代码
DDL 变更能评审
执行顺序固定
线上跑过什么版本能查
失败后能定位 dirty 状态
回滚脚本提前准备
大表变更能拆步骤

对比一下:

维度 golang-migrate GORM AutoMigrate
变更来源 SQL 迁移文件 Go 结构体推断
版本记录 schema_migrations 无完整迁移版本
回滚 通过 down.sql 不提供回滚
评审 SQL 文件进 Git 结构体变化比较隐蔽
生产可控性
常见定位 生产迁移 本地开发、测试原型

常见组合是:

text 复制代码
golang-migrate 管表结构
GORM 做 CRUD 和查询

安装 CLI

macOS 可以用 Homebrew:

bash 复制代码
brew install golang-migrate

Go 安装方式需要按数据库驱动打 tag。

PostgreSQL:

bash 复制代码
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

MySQL:

bash 复制代码
go install -tags 'mysql' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

SQLite:

bash 复制代码
go install -tags 'sqlite3' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

多个数据库一起支持:

bash 复制代码
go install -tags 'postgres mysql sqlite3' github.com/golang-migrate/migrate/v4/cmd/migrate@latest

验证:

bash 复制代码
migrate -version

Docker 方式:

bash 复制代码
docker run --rm migrate/migrate -version

迁移文件命名规则

文件名格式:

text 复制代码
{version}_{title}.up.{extension}
{version}_{title}.down.{extension}

例子:

text 复制代码
000001_create_users_table.up.sql
000001_create_users_table.down.sql
000002_create_orders_table.up.sql
000002_create_orders_table.down.sql

含义:

text 复制代码
version:版本号,必须能排序
title:描述文字,只是方便阅读
up:正向迁移
down:反向回滚
extension:文件类型,SQL 数据库通常是 sql

版本号可以是递增数字,也可以是时间戳。

推荐团队项目使用 -seq 生成递增数字:

bash 复制代码
migrate create -ext sql -dir db/migrations -seq create_users_table

会生成:

text 复制代码
db/migrations/000001_create_users_table.up.sql
db/migrations/000001_create_users_table.down.sql

时间戳也可以:

bash 复制代码
migrate create -ext sql -dir db/migrations create_users_table

时间戳能减少多人冲突,但版本号比较长。序列号更清楚,但合并代码时要注意冲突。

up 和 down 怎么写

up.sql 写正向变更:

sql 复制代码
CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

down.sql 写反向回滚:

sql 复制代码
DROP TABLE IF EXISTS users;

新增字段:

sql 复制代码
ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active';

回滚字段要看数据库能力。SQLite 很多版本不支持直接 DROP COLUMN,可能需要重建表。MySQL、PostgreSQL 通常可以写:

sql 复制代码
ALTER TABLE users DROP COLUMN status;

迁移脚本不要只想着"怎么上去",也要提前想"怎么退回来"。

schema_migrations 表

执行迁移后,数据库里会出现一张版本表,默认名字是:

text 复制代码
schema_migrations

常见字段含义:

text 复制代码
version:当前迁移版本
dirty:是否处于失败状态

正常状态可能是:

text 复制代码
version = 2
dirty = false

迁移执行中途失败,可能变成:

text 复制代码
version = 2
dirty = true

dirty 状态下,继续 updown 通常会失败。需要先人工确认数据库真实状态,再用 force 修正版本。

CLI 常用命令

下面用 SQLite 举例,数据库文件是 app.db,迁移目录是 db/migrations

应用所有未执行迁移:

bash 复制代码
migrate -path db/migrations -database "sqlite3://app.db" up

只应用接下来的 1 个版本:

bash 复制代码
migrate -path db/migrations -database "sqlite3://app.db" up 1

回滚最近 1 个版本:

bash 复制代码
migrate -path db/migrations -database "sqlite3://app.db" down 1

回滚所有版本:

bash 复制代码
migrate -path db/migrations -database "sqlite3://app.db" down -all

跳转到指定版本:

bash 复制代码
migrate -path db/migrations -database "sqlite3://app.db" goto 2

查看当前版本:

bash 复制代码
migrate -path db/migrations -database "sqlite3://app.db" version

强制设置版本:

bash 复制代码
migrate -path db/migrations -database "sqlite3://app.db" force 2

删除数据库所有内容:

bash 复制代码
migrate -path db/migrations -database "sqlite3://app.db" drop -f

drop -f 很危险,只适合本地测试库或明确允许清空的环境。

MySQL 和 PostgreSQL DSN

MySQL:

bash 复制代码
migrate \
  -path db/migrations \
  -database "mysql://root:123456@tcp(127.0.0.1:3306)/shop?charset=utf8mb4&parseTime=True&loc=Local" \
  up

PostgreSQL:

bash 复制代码
migrate \
  -path db/migrations \
  -database "postgres://postgres:123456@127.0.0.1:5432/shop?sslmode=disable" \
  up

连接串里如果有特殊字符,比如密码包含 @/#,需要 URL Encode。

更稳的写法是把数据库 URL 放进环境变量:

bash 复制代码
export DATABASE_URL="postgres://postgres:123456@127.0.0.1:5432/shop?sslmode=disable"
migrate -path db/migrations -database "$DATABASE_URL" up

实战 Demo:用 SQLite 管理用户和订单表

项目结构:

text 复制代码
migrate-demo/
├── db/
│   └── migrations/
│       ├── 000001_create_users_table.up.sql
│       ├── 000001_create_users_table.down.sql
│       ├── 000002_create_orders_table.up.sql
│       └── 000002_create_orders_table.down.sql
├── go.mod
└── main.go

初始化:

bash 复制代码
mkdir migrate-demo
cd migrate-demo
go mod init migrate-demo
go get github.com/golang-migrate/migrate/v4
go get github.com/golang-migrate/migrate/v4/database/sqlite3
go get github.com/golang-migrate/migrate/v4/source/file
go get github.com/mattn/go-sqlite3

创建迁移目录:

bash 复制代码
mkdir -p db/migrations

生成迁移文件:

bash 复制代码
migrate create -ext sql -dir db/migrations -seq create_users_table
migrate create -ext sql -dir db/migrations -seq create_orders_table

000001_create_users_table.up.sql

sql 复制代码
CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);

000001_create_users_table.down.sql

sql 复制代码
DROP TABLE IF EXISTS users;

000002_create_orders_table.up.sql

sql 复制代码
CREATE TABLE orders (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    order_no TEXT NOT NULL UNIQUE,
    amount_cent INTEGER NOT NULL,
    status TEXT NOT NULL DEFAULT 'created',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);

000002_create_orders_table.down.sql

sql 复制代码
DROP TABLE IF EXISTS orders;

执行 CLI 迁移:

bash 复制代码
migrate -path db/migrations -database "sqlite3://app.db" up

查看版本:

bash 复制代码
migrate -path db/migrations -database "sqlite3://app.db" version

回滚一步:

bash 复制代码
migrate -path db/migrations -database "sqlite3://app.db" down 1

再升级:

bash 复制代码
migrate -path db/migrations -database "sqlite3://app.db" up

Go 代码内执行迁移

有些项目会在服务启动时自动执行迁移。

完整 main.go

go 复制代码
package main

import (
	"database/sql"
	"errors"
	"fmt"
	"log"
	"os"

	"github.com/golang-migrate/migrate/v4"
	"github.com/golang-migrate/migrate/v4/database/sqlite3"
	_ "github.com/golang-migrate/migrate/v4/source/file"
	_ "github.com/mattn/go-sqlite3"
)

func main() {
	dbPath := "app.db"
	if len(os.Args) > 1 {
		dbPath = os.Args[1]
	}

	db, err := sql.Open("sqlite3", dbPath)
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	m, err := newMigrator(db, "file://db/migrations")
	if err != nil {
		log.Fatal(err)
	}
	defer m.Close()

	if err := applyMigrations(m); err != nil {
		log.Fatal(err)
	}

	if err := seedAndQuery(db); err != nil {
		log.Fatal(err)
	}
}

func newMigrator(db *sql.DB, sourceURL string) (*migrate.Migrate, error) {
	driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
	if err != nil {
		return nil, err
	}

	m, err := migrate.NewWithDatabaseInstance(sourceURL, "sqlite3", driver)
	if err != nil {
		return nil, err
	}
	return m, nil
}

func applyMigrations(m *migrate.Migrate) error {
	if err := m.Up(); err != nil {
		if errors.Is(err, migrate.ErrNoChange) {
			fmt.Println("迁移状态:没有新版本")
			return nil
		}
		return fmt.Errorf("执行迁移失败:%w", err)
	}

	version, dirty, err := m.Version()
	if err != nil {
		return err
	}
	fmt.Printf("迁移状态:version=%d dirty=%v\n", version, dirty)
	return nil
}

func seedAndQuery(db *sql.DB) error {
	_, err := db.Exec(`
		INSERT INTO users(email, name)
		VALUES(?, ?)
		ON CONFLICT(email) DO UPDATE SET name = excluded.name
	`, "zhangsan@example.com", "张三")
	if err != nil {
		return err
	}

	_, err = db.Exec(`
		INSERT INTO orders(user_id, order_no, amount_cent, status)
		SELECT id, ?, ?, ?
		FROM users
		WHERE email = ?
		ON CONFLICT(order_no) DO NOTHING
	`, "NO1001", 29900, "created", "zhangsan@example.com")
	if err != nil {
		return err
	}

	rows, err := db.Query(`
		SELECT u.email, u.name, o.order_no, o.amount_cent
		FROM users u
		JOIN orders o ON o.user_id = u.id
		ORDER BY o.id
	`)
	if err != nil {
		return err
	}
	defer rows.Close()

	for rows.Next() {
		var email string
		var name string
		var orderNo string
		var amountCent int64
		if err := rows.Scan(&email, &name, &orderNo, &amountCent); err != nil {
			return err
		}
		fmt.Printf("%s %s %s %.2f\n", email, name, orderNo, float64(amountCent)/100)
	}
	return rows.Err()
}

运行:

bash 复制代码
go run main.go

输出类似:

text 复制代码
迁移状态:version=2 dirty=false
zhangsan@example.com 张三 NO1001 299.00

再次运行:

bash 复制代码
go run main.go

输出类似:

text 复制代码
迁移状态:没有新版本
zhangsan@example.com 张三 NO1001 299.00

这里复用了已有的 *sql.DB

go 复制代码
driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})

再交给 migrate:

go 复制代码
migrate.NewWithDatabaseInstance(sourceURL, "sqlite3", driver)

这样不会因为迁移逻辑额外创建一套数据库连接。

注意 m.Close() 的时机。迁移器持有数据库驱动,过早关闭可能连带关闭底层连接。迁移后还要继续使用同一个 *sql.DB 时,把 m.Close() 放到业务使用结束之后。

ErrNoChange 不是错误

m.Up() 没有新脚本可执行时,会返回:

go 复制代码
migrate.ErrNoChange

这通常是正常状态。

常见处理:

go 复制代码
if err := m.Up(); err != nil {
	if errors.Is(err, migrate.ErrNoChange) {
		return nil
	}
	return err
}

不要把 ErrNoChange 当成启动失败。

Dirty 状态怎么处理

迁移执行到一半失败时,数据库会进入 dirty 状态。

常见报错类似:

text 复制代码
Dirty database version 2. Fix and force version.

处理步骤:

text 复制代码
查看失败的 migration 文件
确认数据库是否已经部分执行
手动修复数据库到某个确定状态
使用 force 把版本号调整到真实状态
重新执行 migrate up

比如版本 2 的脚本执行失败,确认数据库实际上还停在版本 1,可以执行:

bash 复制代码
migrate -path db/migrations -database "sqlite3://app.db" force 1
migrate -path db/migrations -database "sqlite3://app.db" up

如果确认版本 2 的变更已经完整完成,只是迁移工具状态没写干净,可以执行:

bash 复制代码
migrate -path db/migrations -database "sqlite3://app.db" force 2
migrate -path db/migrations -database "sqlite3://app.db" up

force 只改版本记录,不会自动修复表结构。执行前必须确认数据库真实状态。

Go 代码识别 Dirty

ErrDirty 是一个具体错误类型,不是固定哨兵值。

可以这样判断:

go 复制代码
if err := m.Up(); err != nil {
	var dirty migrate.ErrDirty
	if errors.As(err, &dirty) {
		return fmt.Errorf("数据库迁移处于 dirty 状态,version=%d", dirty.Version)
	}
	if errors.Is(err, migrate.ErrNoChange) {
		return nil
	}
	return err
}

dirty 状态不要在程序里自动 Force。更稳的方式是停止启动,交给发布流程或运维脚本处理。

Steps、Goto、Force 对应的 Go API

CLI 命令和 Go API 基本能对应起来:

text 复制代码
up             -> m.Up()
up 2           -> m.Steps(2)
down 1         -> m.Steps(-1)
goto 3         -> m.Migrate(3)
force 2        -> m.Force(2)
version        -> m.Version()

示例:

go 复制代码
if err := m.Steps(-1); err != nil {
	return err
}

自动回滚在生产服务启动阶段很少使用。更常见的是发布失败后由流水线或人工确认后执行。

embed.FS:把迁移文件打进二进制

文件源 file://db/migrations 依赖运行目录存在迁移文件。

如果希望迁移文件跟着二进制一起发布,可以使用 embed.FSsource/iofs

go 复制代码
package main

import (
	"database/sql"
	"embed"
	"errors"
	"fmt"
	"log"

	"github.com/golang-migrate/migrate/v4"
	"github.com/golang-migrate/migrate/v4/database/sqlite3"
	"github.com/golang-migrate/migrate/v4/source/iofs"
	_ "github.com/mattn/go-sqlite3"
)

//go:embed db/migrations/*.sql
var migrationFiles embed.FS

func main() {
	db, err := sql.Open("sqlite3", "app.db")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	sourceDriver, err := iofs.New(migrationFiles, "db/migrations")
	if err != nil {
		log.Fatal(err)
	}

	dbDriver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
	if err != nil {
		log.Fatal(err)
	}

	m, err := migrate.NewWithInstance("iofs", sourceDriver, "sqlite3", dbDriver)
	if err != nil {
		log.Fatal(err)
	}
	defer m.Close()

	if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
		log.Fatal(err)
	}

	version, dirty, err := m.Version()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("version=%d dirty=%v\n", version, dirty)
}

适合:

text 复制代码
单二进制部署
容器镜像内不想单独复制 migrations 目录
命令行工具自带初始化数据库能力

Docker Compose 里执行 migrate

迁移可以作为独立容器执行。

示例:

yaml 复制代码
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: 123456
      POSTGRES_DB: shop
    ports:
      - "5432:5432"

  migrate:
    image: migrate/migrate
    volumes:
      - ./db/migrations:/migrations
    command:
      [
        "-path",
        "/migrations",
        "-database",
        "postgres://postgres:123456@postgres:5432/shop?sslmode=disable",
        "up"
      ]
    depends_on:
      - postgres

实际生产环境还要处理数据库就绪检查。depends_on 只保证容器启动顺序,不保证数据库已经能连接。

CI/CD 中怎么放

常见发布流程:

text 复制代码
构建应用镜像
启动数据库或连接目标数据库
执行 migrate up
迁移成功后发布新应用
迁移失败则停止发布

GitHub Actions 或其他流水线里,迁移命令通常类似:

bash 复制代码
migrate \
  -path db/migrations \
  -database "$DATABASE_URL" \
  -verbose \
  up

Kubernetes 里常见两种做法:

text 复制代码
Job:发布前跑一次迁移
Init Container:主容器启动前先跑迁移

更推荐 Job 或发布流水线控制迁移。应用多副本同时启动时,如果每个副本都尝试迁移,虽然工具有锁机制,但发布流程会变得更难观察。

大表变更怎么拆

小表直接 ALTER TABLE 问题不大。大表要谨慎。

比如给大表新增一个非空字段,不建议一步到位:

sql 复制代码
ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active';

更稳的拆法:

text 复制代码
第 1 个版本:新增可空字段
第 2 个版本:后台分批回填历史数据
第 3 个版本:应用代码开始写新字段
第 4 个版本:加 NOT NULL 约束

索引也一样。PostgreSQL 大表索引可以考虑 CREATE INDEX CONCURRENTLY,但这类语句不能放在普通事务里。不同数据库限制不同,迁移文件要按数据库特性写。

down 文件怎么写才靠谱

好的 down.sql 不是随便写个 DROP TABLE

新增表:

sql 复制代码
DROP TABLE IF EXISTS orders;

新增索引:

sql 复制代码
DROP INDEX IF EXISTS idx_orders_status;

新增字段:

sql 复制代码
ALTER TABLE orders DROP COLUMN status;

新增枚举值、数据修复、字段拆分这类变更,回滚可能很难。确实无法回滚时,也要保留 down.sql,里面写清楚原因:

sql 复制代码
-- irreversible migration: historical data cannot be reconstructed

不要创建 0 字节空文件。某些数据库会把空查询当成错误。

迁移文件提交后不要修改

已经合并并执行过的迁移文件,不要再改。

错误做法:

text 复制代码
修改 000003_add_status.up.sql
重新提交

问题在于:

text 复制代码
已经执行过版本 3 的环境不会重新执行
没执行过版本 3 的环境会执行新内容
不同环境数据库结构开始分叉

正确做法:

text 复制代码
新增 000004_fix_status_default.up.sql
新增 000004_fix_status_default.down.sql

迁移文件一旦进入主分支,就应该像发布出去的接口一样对待。

多人协作的版本冲突

两个分支都创建了:

text 复制代码
000006_add_user_status.up.sql
000006_create_payment_table.up.sql

合并时就会冲突。

处理方式:

text 复制代码
保留先合并的版本号
后合并的分支重新生成新版本号
检查 up/down 是否还能按顺序执行
重新跑 up、down、up

提交迁移前建议跑:

bash 复制代码
migrate -path db/migrations -database "$DATABASE_URL" up
migrate -path db/migrations -database "$DATABASE_URL" down 1
migrate -path db/migrations -database "$DATABASE_URL" up

这样能提前发现 down.sql 写错的问题。

常见错误一:只写 up,不认真写 down

只写升级,不写回滚,发布失败时就会很被动。

至少要做到:

text 复制代码
能回滚表、字段、索引
不能回滚的数据变更写明原因
上线前测试 down 再 up

常见错误二:dirty 后直接 force

force 不是修复数据库结构。

错误流程:

bash 复制代码
migrate force 2
migrate up

缺少了最重要的一步:检查数据库真实状态。

正确流程:

text 复制代码
查看失败 SQL
查看已执行到哪一步
手动补齐或撤销半成品
确认真实版本
force 到真实版本
继续迁移

常见错误三:在应用多副本里同时自动迁移

应用启动时自动 m.Up() 很方便,但多副本部署要小心。

更推荐:

text 复制代码
单独迁移 Job
发布流水线 migration step
应用启动前明确完成迁移

如果必须应用启动自动迁移,要确认目标数据库驱动支持锁,并且启动失败能被监控发现。

常见错误四:把大量数据修复塞进 DDL 迁移

迁移文件可以写 DML,但不要把几百万行回填直接塞进普通迁移里。

更稳的做法:

text 复制代码
DDL 迁移先加字段或表
后台任务分批回填数据
验证完成后再加约束或切换代码

这样失败范围更小,也更容易重试。

常见错误五:把 drop -f 放进脚本

drop -f 会删除数据库内容。

它适合:

text 复制代码
本地重置
临时测试库
CI 中一次性数据库

不适合:

text 复制代码
共享开发库
测试公共库
生产环境

脚本里出现 drop -f,要特别警惕。

工程实践建议

推荐目录:

text 复制代码
db/
  migrations/
    000001_create_users_table.up.sql
    000001_create_users_table.down.sql
internal/
  database/
    migrate.go
  model/
  repository/

常见规则:

  • 每次结构变更新增一对 up/down
  • 已经提交并执行过的迁移文件不要修改
  • down.sql 必须跟着 up.sql 一起评审
  • 发布前至少在临时库跑 up
  • 重要发布前测试 downup
  • dirty 状态必须人工确认后再 force
  • 大表变更拆成多个小版本
  • 应用代码和数据库迁移要规划先后顺序
  • 生产迁移优先放到发布流水线或独立 Job
  • GORM AutoMigrate 不负责生产结构变更

常用命令速查

bash 复制代码
# 创建迁移文件
migrate create -ext sql -dir db/migrations -seq create_users_table

# 升级到最新
migrate -path db/migrations -database "$DATABASE_URL" up

# 升级 1 个版本
migrate -path db/migrations -database "$DATABASE_URL" up 1

# 回滚 1 个版本
migrate -path db/migrations -database "$DATABASE_URL" down 1

# 回滚全部
migrate -path db/migrations -database "$DATABASE_URL" down -all

# 查看版本
migrate -path db/migrations -database "$DATABASE_URL" version

# 跳转到版本 3
migrate -path db/migrations -database "$DATABASE_URL" goto 3

# 修正版本
migrate -path db/migrations -database "$DATABASE_URL" force 3

总结

golang-migrate 的核心不复杂:

text 复制代码
一对 up/down 文件
一个递增版本号
一张 schema_migrations 表
一组 up、down、goto、force 命令

真正难的不是工具命令,而是迁移纪律:

text 复制代码
变更进 Git
上线前验证
失败能定位
回滚有脚本
大表分阶段
dirty 不乱 force

GORM 能让 CRUD 更顺手,golang-migrate 能让数据库结构变更更可控。两者分工清楚后,项目里的表结构就不会再靠口头同步和手动执行 SQL 维持。

相关推荐
yiqiefeimeng14 小时前
Rust还是Go?编程语言的未来之争
rust·go·编程语言·技术社区·未来之争
唐青枫15 小时前
别把 GORM 当 SQL 生成器:Go GORM 从模型映射到事务实战详解
go
妙码生花17 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(五十四):管理员个人资料页面、管理员日志优化
前端·后端·go
他们叫我秃子19 小时前
前端开发转 Go 全栈(四):代码写在前面,却要最后执行?我终于搞懂了 defer
前端·后端·go
他们叫我秃子1 天前
前端开发转 Go 全栈(三):从函数到 error,Go 连“失败”都要明确返回
前端·后端·go
程序员爱钓鱼1 天前
Go 数字类型详解
后端·面试·go
妙码生花2 天前
从 PHP 到 AI + Golang,程序员自救转型手记(五十二):管理员权限检查中间件,AI 随意放行预闯大祸
前端·后端·go