migratex
基于 pressly/goose/v3 的数据库迁移工程化封装:SQL 迁移文件 + Cobra 命令 + 生成器 + 静态 lint + MySQL 并发锁。
migratex 不自研迁移执行引擎——读取、排序、执行 up/down、版本管理全部由 goose Provider 完成;migratex 负责把它工程化:
| 职责 |
承担方 |
迁移文件生成(make migration)、SQL 模板 |
migratex |
Cobra 命令、参数、危险操作防护(fresh --force) |
migratex |
| 静态 lint(版本冲突、缺 Up/Down、未完成迁移) |
migratex |
| 孤儿版本检测(数据库有记录、源文件缺失) |
migratex |
MySQL GET_LOCK 并发互斥(goose 官方无 MySQL locker) |
migratex |
| 读取迁移、排序、执行 up/down、版本表管理 |
goose |
| 业务查询/写入/事务/model 定义/连接管理 |
GORM(调用方) |
边界:正式数据库结构变更只走 SQL 迁移文件;GORM 只做业务 ORM 与连接管理,不使用 AutoMigrate 变更正式库结构;迁移文件不引用业务 model。
限制(v0.1.0)
- 迁移执行引擎仅支持 MySQL(
NewCommands 对其他方言返回 ErrUnsupportedDatabase)
- 不支持
-- +goose StatementBegin/StatementEnd 复杂语句块(存储过程/触发器),lint 直接报错
- goose 版本表是"当前应用集合",不是审计流水:回滚会删除版本行,不保留历史轨迹
接入前提
- Go
1.26;迁移执行仅支持 MySQL。
- 调用方自行维护 GORM 的连接池和凭据;迁移库不读取或写入
.env 文件。
- MySQL DSN 必须包含
parseTime=true。迁移账户至少需要迁移目标库的建表、改表、建/删索引及读写 goose_db_version 的权限。
- 在生产环境中,应用进程使用的连接池
MaxOpenConns 不得设为 1;migrate fresh --force 与 migrate refresh --force 需要一条连接持有迁移锁,并使用另一条连接删表/回滚和重建。
安装与目录
发布版本请固定使用 tag,而非 latest:
go get github.com/gtkit/migratex@<发布版本>
推荐从应用模块根目录执行命令,目录约定如下:
shop/
├── cmd/shop/main.go
├── database/migrations/
├── internal/models/
└── internal/repository/
WithProjectName 用于设置应用的 Go module path,只影响 make model、make cmd、make migration create_* 等生成代码的 import 路径。默认可省略:未显式设置时,生成命令会从执行所在目录向上查找项目 go.mod 自动解析 module path;仅当既未设置、又无法从 go.mod 解析时,这些生成命令才返回包装 migration.ErrInvalidArgument 的错误并拒绝落盘(migrate/lint/help 等不需要 import 前缀的命令不受影响)。正式生产二进制通常只注册 commands.Migrate;代码生成命令可仅保留在开发工具二进制中。
完整接入 Demo
仓库提供了可编译示例:examples/basic/main.go。以下代码可直接作为应用入口,唯一需要替换的是 module path、二进制名和各目录:
package main
import (
"log"
"os"
"time"
migrate "github.com/gtkit/migratex"
"github.com/spf13/cobra"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func main() {
dsn := os.Getenv("APP_MYSQL_DSN")
if dsn == "" {
log.Fatal("APP_MYSQL_DSN is required")
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatalf("open database: %v", err)
}
commands, err := migrate.NewCommands(
db,
migrate.WithProjectName("example.com/shop"),
migrate.WithMigrationDir("database/migrations"),
migrate.WithTimeout(5*time.Minute),
)
if err != nil {
log.Fatalf("create migration commands: %v", err)
}
root := &cobra.Command{Use: "shop", SilenceUsage: true}
root.AddCommand(commands.Migrate, commands.Make)
if err := root.Execute(); err != nil {
log.Fatal(err)
}
}
在项目根目录设置无真实密钥提交的环境变量后执行:
export APP_MYSQL_DSN='app_user:<password>@tcp(127.0.0.1:3306)/shop?charset=utf8mb4&parseTime=true&loc=Local'
go run ./cmd/shop migrate lint --strict
go run ./cmd/shop migrate status
上例使用 cmd/shop;验证仓库自带示例时相应替换为 go run ./examples/basic。DSN 中的 <password> 是占位符,禁止写入代码、README 之外的真实配置或 Git 历史。
迁移文件格式
单文件 goose 注解格式,纯数字 UTC 时间戳版本号:
database/migrations/
├── 20260713153045_create_users_table.sql
└── 20260713153102_add_email_to_users_table.sql
-- +goose NO TRANSACTION
-- +goose Up
ALTER TABLE `users` ADD COLUMN `email` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '邮箱';
-- +goose Down
ALTER TABLE `users` DROP COLUMN `email`;
规则:
- 版本号为文件名第一个
_ 之前的纯数字(goose 解析规则),生成器保证严格递增;
- 每个迁移必须同时有
Up 与 Down 注解段;
- MySQL DDL 迁移标注
-- +goose NO TRANSACTION——MySQL DDL 隐式提交,外层事务提供不了原子性,标注让语义诚实(lint 对缺标注的 DDL 报 warning);
- 模板不生成幂等 guard(
IF NOT EXISTS、存在性检查):版本模型保证每个迁移只执行一次,静默跳过反而会掩盖结构漂移;
- 文件一旦执行,不再修改;需要变更就新建迁移。
命令参考
migrate
| 命令 |
作用 |
migrate up |
执行所有 pending 迁移 |
migrate down --force [--step=N] |
回滚已应用迁移,默认最新一条;--step=N 最多回滚 N 条,已应用不足 N 条时回滚全部并成功,输出实际数量;N 必须为正整数;必须带 --force |
migrate down-to <version> --force |
回滚到指定版本(不含该版本);必须带 --force |
migrate reset --force |
回滚全部迁移;必须带 --force |
migrate refresh --force |
单锁内先回滚全部(走各迁移 Down)再重跑全部 Up;⚠ 会执行所有 Down,可能删除字段、表与数据,不作为常规生产发布命令;不删版本表(区别于 fresh);必须带 --force;需要连接池至少 2 条连接 |
migrate mark-applied [--to <version>] --force |
把 pending 版本写入版本表标记为已应用,不执行任何迁移 SQL,用于 baseline 已存在等价 schema 的存量库;--to 只标记不超过该版本的 pending;必须带 --force,执行前打印将标记的版本清单 |
migrate status |
当前版本 + 每个迁移的 Applied/Pending 与执行时间 + 孤儿版本检测;只读,不加锁、不创建版本表 |
migrate pretend |
打印 pending 迁移文件及其 Up SQL;只读,不执行任何语句(不是数据库执行计划) |
migrate fresh --force |
删除当前库全部基表(不动 view)后重新执行所有迁移,全程持有迁移锁;不带 --force 拒绝执行;需要连接池至少 2 条连接(一条持锁),SetMaxOpenConns(1) 时快速报错 |
migrate lint [--strict] |
静态检查迁移文件,不连数据库,可进 CI;--strict 下 warning 也失败 |
写类命令(up/down/down-to/reset/refresh/mark-applied/fresh)在触碰数据库前会对迁移目录强制执行与 migrate lint 相同的静态检查,存在 error 级问题(如 empty_up/empty_down 半成品)时直接拒绝执行——goose 对空 Up/Down 迁移会静默"成功"并写/删版本记录,强制 lint 是防止版本状态漂移的执行前防线。
refresh 与 fresh 的区别:fresh 直接 DROP 当前库全部基表后重放全部迁移(无视 Down 脚本,用于测试库/灾备重建);refresh 只通过各迁移的 Down 段回滚再重跑(信任 Down、保留迁移历史路径)。两者都会毁数据;fresh、refresh 与 reset 均强制 --force,其中 fresh 因直接删表风险更高,只应用于本地或隔离测试库。
mark-applied 只写 goose 版本表、不跑迁移 SQL:仅当数据库结构已等价于这些迁移的净效果时才可使用(如从其他工具迁移过来、或手工建好了历史表)。它不比对实际表结构是否真的匹配——标错会让 up 跳过真实建表、造成 schema 漂移,故强制 --force。典型流程:库已对应到历史某版本时用 --to <version> 标记到该点,其后的新迁移仍由 migrate up 正常执行。
make migration
# 建表(同时生成 model/repository 脚手架)
myapp make migration create_users_table
# 从已注册的 GORM model 生成固定 SQL 建表迁移(不生成脚手架)
myapp make migration create_users_table --from-model
# 加字段:--type 必填,拒绝生成半成品
myapp make migration add_email_to_users_table --type 'VARCHAR(128)' --not-null --default "''" --comment '邮箱'
# 加字段并指定位置
myapp make migration add_pay_mch_id_to_orders_table \
--type 'VARCHAR(64)' --not-null --default "''" \
--comment '支付商户号' --after pay_channel
# 修改表(生成空骨架,补全前 lint 会以 empty_up/empty_down 拦截执行)
myapp make migration update_users_table
# 删字段 / 删索引 / 删表(Down 无法自动推导,需按骨架注释补全反向 SQL,
# 否则 empty_down 会拦截所有写类命令——空 Down 会被 goose 当成功回滚,造成漂移)
myapp make migration drop_column_email_from_users_table
myapp make migration drop_index_email_from_users_table
myapp make migration drop_users_table
make migration 在写入任何文件前完成全部校验(版本合法性、参数、路径冲突),校验失败不落盘——create_* 不会留下 model/repository 孤儿脚手架;迁移目录中存在任何非法版本的 .sql 文件(非 <14 位 UTC 时间戳>_<名称>.sql)时拒绝生成,先重命名存量文件。
create_*_table --from-model 复用 WithDDLModels 中表名匹配的 GORM model,并以 GORM dry-run 生成完整的建表 SQL(包括索引等关联 DDL);生成文件的 Down 为直接 DROP TABLE,不含 IF EXISTS / IF NOT EXISTS guard。迁移名中的 <table> 必须与 GORM 解析出的实际表名完全一致,例如默认 User model 的表名为 users 时应使用 create_users_table。该模式要求在创建命令时已注册模型:
commands, err := migrate.NewCommands(
db,
migrate.WithDDLModels(&models.User{}),
)
if err != nil {
return err
}
模型仅在生成时读取。生成后的 .sql 文件才是迁移事实来源:提交后不得根据当前 model 重写它;后续字段、索引或约束变更必须创建新的迁移。--from-model 仅支持建表,不负责从当前 model 自动推导已有表的差异。
make model、make ddl、make ddl diff 等代码/DDL 生成命令与迁移执行解耦,make ddl 只产出草稿 SQL 供人工审核;migrate up 始终执行版本化 SQL,不做 AutoMigrate 推断。
make model
# 生成 user 模型的 model + repository 脚手架
myapp make model user
一条命令生成以下文件(均 writeSkipIfExists,已存在则跳过、不覆盖):
| 文件 |
内容 |
<ModelDir>/model.go |
BaseID + BaseTimeField 公共嵌入字段(首次生成) |
<ModelDir>/doc.go |
model 包注释(首次生成) |
<ModelDir>/<name>.go |
模型结构体 + TableName + GetStringID + 基于标准库 encoding/json 的序列化方法 |
<RepositoryDir>/<name>/<name>_i.go |
Repository + New(db *gorm.DB) + mdbCtx 事务收口 |
<RepositoryDir>/<name>/<name>_util.go |
Get / ExistsByID / CreateOrUpdate |
<RepositoryDir>/dbtx/dbtx.go |
基于 context 的事务传递与 gorm 事务管理器(首次生成) |
生成的仓储遵循以下契约:
- 事务收口:
mdbCtx 统一返回 dbtx.DB(ctx, r.db)——ctx 内携带事务句柄时自动参与同一事务,否则用底座连接。用 dbtx.NewManager(db).Do(ctx, fn) 开启事务,仓储无需改动即事务安全。
- 查询区分「未找到」与「库故障」:
Get 返回 (<Model>, found bool, err error),gorm.ErrRecordNotFound 被拦在仓储内以 found=false, err=nil 表达,其余库错误以 err != nil 上抛,不吞错。
- 存在性判断用强类型主键:
ExistsByID(ctx, id int64),不生成基于动态字段名的 IsExist(免于 SQL 注入且无需白名单)。
dbtx 包的 import 路径由 WithRepositoryDir 推导(默认 <module>/internal/repository/dbtx),与其生成位置保持同源。生成的 model / repository / dbtx 只依赖标准库与 gorm,在全新空项目(go.mod + gorm 依赖)内即可直接 go build 通过,不再依赖 internal/pkg/paginator 或 github.com/gtkit/json。
分页按需自行实现——工具不内置分页约定。下面是一段纯 gorm 的示例,可直接加进生成的仓储:
// Paginate 返回第 page 页(每页 size 条)及总数;调用方据 total 自行构造分页元数据。
func (r *Repository) Paginate(ctx context.Context, page, size int) (items []models.User, total int64, err error) {
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
db := r.mdbCtx(ctx).Model(models.User{})
if err = db.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("count users: %w", err)
}
if err = db.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error; err != nil {
return nil, 0, fmt.Errorf("list users: %w", err)
}
return items, total, nil
}
make cmd
# 生成一个 cobra 命令脚手架(名称用 snake_case)
myapp make cmd backup_database
生成 <CmdDir>/<name>.go(默认 cmd/backup_database.go,writeFailIfExists——已存在则报错不覆盖),内容是导出的 Cmd<Struct>(如 CmdBackupDatabase)命令变量及其构造;不自动注册,请在你的 root 命令上显式 rootCmd.AddCommand(cmd.CmdBackupDatabase)(不假设你的命令骨架形态)。输出目录用 WithCmdDir 或 --cmd-dir 覆盖。
lint 规则
| 规则 |
级别 |
说明 |
invalid_filename |
error |
文件名不符合 <数字版本>_<名称>.sql |
invalid_version |
error |
版本前缀不是正整数(0 为 goose 保留) |
duplicate_version |
error |
多个文件版本号相同 |
missing_up / missing_down |
error |
缺少 Up / Down 注解段 |
empty_up |
error |
Up 段没有 SQL(未完成的迁移) |
empty_down |
error |
Down 段没有 SQL(goose 会把空 Down 当成功回滚并删版本记录,造成漂移) |
statement_block_not_supported |
error |
使用了 StatementBegin/End 复杂块 |
nonstandard_version |
error |
版本前缀不是合法的 14 位 UTC 时间戳(migratex 版本号强约束为生成器时间戳格式,不兼容任意正整数版本) |
missing_no_transaction |
warning |
含 DDL 但缺 NO TRANSACTION 标注 |
error 级问题除使 migrate lint 失败外,也会让所有写类命令在触碰数据库前拒绝执行。
日常与生产操作流程
开发一个迁移
以下流程以新增字段为例;所有命令均在应用模块根目录执行:
# 1. 生成可直接执行的字段迁移
shop make migration add_email_to_users_table \
--type 'VARCHAR(128)' --not-null --default "''" --comment '邮箱'
# 2. 审核生成的 Up/Down SQL;对 update_*、drop_* 草稿必须手动补全两个方向
editor database/migrations/<14位UTC时间戳>_add_email_to_users_table.sql
# 3. 提交前只做静态检查,再查看将执行的文件和 SQL
shop migrate lint --strict
shop migrate pretend
# 4. 在专用开发库执行,并核对状态
shop migrate up
shop migrate status
pretend 展示的是 pending 文件的 Up 段原文,不是数据库优化器确认过的执行计划;它用于人工审核,不能替代备份、SQL 审查或预发演练。已在任一环境执行的迁移文件必须永久保留且不可修改。
部署到生产
- 合并迁移前在 CI 执行
shop migrate lint --strict,并确保新增版本号高于目标分支已有最大版本;
- 先对目标数据库做可恢复备份,并在预发库按同一版本文件执行
shop migrate up;
- 在发布流程中以单独迁移 Job / 单个发布步骤执行
shop migrate up,成功后才滚动业务实例;不要在每个应用实例启动钩子中无条件执行迁移;
- 迁移 Job 成功后执行
shop migrate status,确认没有 Pending 或 orphan version;
- 业务发布需要兼容扩容/缩容时采用 expand-contract:先加可空字段或索引、发布兼容代码、回填数据、最后在后续发布删除旧结构。
MySQL GET_LOCK 会保护并发写类命令,但锁只解决“同一批迁移不能交错”,不替代发布编排、备份或结构兼容设计。migrate fresh --force 是本地/隔离测试库工具,禁止用于生产库。
回滚与故障恢复
- 已确认 Down SQL 可逆且业务兼容时,可执行
shop migrate down --force 或 shop migrate down-to <version> --force;生产事故优先评估前向修复,避免回滚导致数据丢失。
- 若 MySQL DDL 的某条语句失败,先停止自动重试,执行
shop migrate status 并人工核对真实 schema;手工回滚或补齐已生效的部分后,再运行 shop migrate up。
- 若 status 报告 orphan version,说明版本表记录与源文件不一致;禁止删除版本行或伪造 SQL 文件,必须先核对每个环境的 schema 和发布记录。
reset 会回滚全部迁移,仅适用于可丢弃数据的开发/测试库;生产恢复不使用该命令作为常规手段。
版本纪律(多人协作必读)
goose 按版本号线性执行,已应用版本之前的"迟到"迁移默认报错、绝不静默跳过。团队纪律:
- 迁移只从主干最新代码生成(生成器保证版本号严格递增且恒为合法 14 位 UTC 时间戳,同秒冲突取下一秒;lint 对非时间戳版本报 error,手工命名的文件同样受此约束);
- 包含迁移的分支合并前必须 rebase;版本落后时删掉旧文件重新生成;
- CI 里跑
migrate lint,并校验新增迁移版本号大于目标分支最大版本;
- 如果迟到迁移已合并但尚未在任何环境执行:将文件重命名为新版本号后重新执行——这是唯一推荐的修复路径,migratex 不提供 allow-missing 开关;
- 如果迟到迁移已在其他环境执行过:属于事故,需人工核对各环境 schema 后处理,不要靠重命名掩盖。
失败与恢复
goose 没有 dirty 锁死状态:迁移失败即停,失败的迁移不会记入版本表,修复后重跑 migrate up 会从该迁移重试。
注意 MySQL DDL 非事务:一个迁移里有多条语句时,第二条失败后第一条已经生效,直接重跑会在第一条上撞"已存在"。因此:
- 一个迁移尽量只做一件事;能合并的列/索引变更合并成一条
ALTER TABLE(MySQL 8.0 单条 DDL 原子);
- 失败后先看报错、人工核对数据库真实结构;
- 手工回滚/补齐已生效的部分,再重跑
migrate up。
migrate status 会额外报告孤儿版本(版本表里有记录但源文件已删除)——这是 goose 自身 Status 的盲区,出现时说明有人删了已应用的迁移文件,需要人工处理。
并发与锁
- 写类命令(up/down/down-to/reset/refresh/mark-applied/fresh)通过 MySQL
GET_LOCK advisory lock 互斥,锁名由数据库名 + 版本表名稳定派生,等待时长跟随 WithTimeout;fresh 从删表起、refresh 从回滚起全程持有该锁,mark-applied 在写版本表期间持锁,窗口不会与其他迁移交错;
- 读类命令(status/pretend/lint)真正只读:不获取锁、不创建版本表、无任何数据库写入(status/pretend 直查版本表而非 goose 的
Provider.Status——后者会加会话锁并建表),可与执行中的迁移并行;
- 多实例部署同时启动执行迁移是安全的:后到者等锁,拿到锁后发现无 pending 即空跑。
Options
| Option |
默认值 |
说明 |
WithProjectName(string) |
缺省时从 go.mod 解析 |
module path,用于代码生成 import 路径;未设置时从项目 go.mod 自动解析 |
WithMigrationDir(string) |
database/migrations |
迁移文件目录 |
WithModelDir(string) |
internal/models |
model 生成目录 |
WithRepositoryDir(string) |
internal/repository |
repository 生成目录 |
WithDDLDir(string) |
database/ddl |
DDL 输出目录 |
WithCmdDir(string) |
cmd |
make cmd 生成目录 |
WithTimeout(time.Duration) |
5 分钟 |
迁移操作超时,同时决定锁等待上限 |
WithLogger(migration.Logger) |
stdout 日志 |
结构化日志注入(goose 日志也经此输出) |
WithDDLModels(...any) |
无 |
注册可用于 make ddl 的 GORM 模型 |
错误判定:所有失败路径返回包装 sentinel 的 error,可用 errors.Is 判定 migration.ErrInvalidArgument、migration.ErrUnsupportedDatabase、migration.ErrLintFailed。
除不传 Option 与 nil Option 外,Option 的非法输入(空项目/目录、非正超时、nil logger)会使 NewCommands 返回 migration.ErrInvalidArgument,不会静默回退默认配置。
测试
本仓库的完整发布检查:
make verify-release
该命令运行 vet、golangci-lint、gofumpt、gosec、govulncheck、竞态测试、基准、覆盖率和 CHANGELOG 校验;总覆盖率默认必须达到 80%,CI 可通过 COVERAGE_MIN=85 make verify-release 提高阈值。make tag 会先拒绝脏工作区和已存在版本标签,再运行相同发布检查,成功后仅创建本地中文附注标签,不会修改版本、创建 commit 或推送远端。
单元测试直接 go test ./...。迁移执行的集成测试需要真实 MySQL,通过环境变量显式开启:
# ⚠️ 目标库会被清空,必须指向专用测试库;DSN 必须带 parseTime=true
MIGRATEX_MYSQL_DSN='root:pass@tcp(127.0.0.1:3306)/migratex_test?parseTime=true' \
go test -race -count=1 ./...
未设置该变量时集成测试自动跳过。
License
MIT,见 LICENSE。