推荐使用数字前缀强制排序的目录结构(如migrations/001_init.sql、002_add_index.go),或独立go包实现migration.migrator接口,配合migration_history元数据表记录已执行版本,确保幂等性与多实例一致性。

迁移脚本该用什么结构组织才不会乱
Go 里没有像 Django 或 Rails 那样开箱即用的迁移框架,所以得自己搭骨架。核心是把每个迁移看作一个带 Up 和 Down 方法的结构体,再用版本号(比如 "20240515_add_user_email")做唯一标识。别用时间戳+描述这种自由命名——CI/CD 里一旦两个分支同时提交同名文件就冲突。
推荐目录结构:migrations/ 下放 001_init.sql、002_add_index.go 这类文件,用数字前缀强制排序;或者更稳妥地用独立 Go 包,每个迁移实现 migration.Migrator 接口:
type Migrator interface {
Version() string
Up(db *sql.DB) error
Down(db *sql.DB) error
}
- 所有迁移必须能重复执行
Up(加判断:先查表是否存在再建) -
Down不必 100% 可逆,但至少不能报错(比如删字段前先确认字段存在) - 避免在迁移里调用外部服务或读取配置文件——部署时环境可能不一致
怎么让迁移自动识别已执行的版本
靠一张元数据表(比如 migration_history)记录执行过的 version 和 applied_at。每次运行迁移前,先查这张表,跳过已存在的版本。别用文件系统时间戳或本地缓存——多实例部署时必然出错。
关键逻辑在 migrate.Up() 函数里:
rows, _ := db.Query("SELECT version FROM migration_history ORDER BY applied_at")
defer rows.Close()
executed := map[string]bool{}
for rows.Next() {
var v string
rows.Scan(&v)
executed[v] = true
}
for _, m := range allMigrations {
if !executed[m.Version()] {
if err := m.Up(db); err != nil {
return err
}
_, _ = db.Exec("INSERT INTO migration_history (version) VALUES (?)", m.Version())
}
}
- 务必用事务包裹单次
Up操作,失败就回滚,再写入migration_history - 表名和字段名不要硬编码,抽成常量
const historyTable = "migration_history" - SQLite 默认不支持
ALTER COLUMN,MySQL 8.0+ 才支持重命名字段——跨数据库时得拆成DROP + ADD
如何安全地回滚到指定版本
回滚不是“撤销最后一条”,而是“降到目标版本”。需要先获取当前最新版本,再按 Down 顺序倒着执行,直到目标版本的下一条。难点在于:有些 Down 操作不可逆(比如删表),这时候只能停机导出数据,手动恢复。
- 命令行工具要支持
migrate down --to 003,而不是migrate rollback - 执行
Down前必须校验目标版本是否在历史记录中——防止跳过中间版本导致状态错乱 - 禁止在生产环境自动执行
Down,只允许人工确认后触发,并记录操作人和时间戳到migration_history - 如果某次迁移包含数据转换(如 JSON 字段拆成多列),
Down必须能重建原始结构,否则直接报错退出
为什么 migrate.Run() 会在 CI 中随机失败
常见原因是并发执行:多个测试进程或部署任务同时跑迁移,抢着往 migration_history 插记录,触发唯一键冲突或死锁。根本解法不是加锁,而是让迁移入口具备幂等性——同一版本多次执行 Up 必须成功返回。
- 用
CREATE TABLE IF NOT EXISTS替代CREATE TABLE - 索引创建前先查
SELECT name FROM sqlite_master WHERE type='index' AND name='idx_user_email'(SQLite)或对应系统表 - PostgreSQL 的
DO $$ BEGIN ... END $$块可封装条件逻辑,但 Go 里更推荐用db.QueryRow先探查再操作 - 本地开发用
sqlite,CI 用postgres?迁移 SQL 语法差异会暴露——统一用github.com/amacneil/dbmate这类支持多方言的 CLI 工具生成骨架,Go 只负责调用
最麻烦的永远不是写迁移,而是有人在生产库手动改了 schema 却没提 PR。上线前用 schema diff 工具比对预期与实际结构,比任何文档都管用。











