数据库迁移不是把几条 ALTER TABLE 放进项目就结束了。真正要解决的是:数据库如何从版本 A 稳定地变成版本 B,团队如何审查这次变化,失败后如何恢复,以及多台应用实例如何避免同时改表。
Go 生态里的方案大致分成两类:
- 版本化迁移:每次变更写成不可修改的迁移文件,按顺序执行。
golang-migrate和goose属于这一类。 - 声明式迁移:描述目标 Schema,由工具计算差异并生成或执行迁移。Atlas、GORM 的 Atlas 集成和 Ent 的版本化迁移属于这一类;GORM
AutoMigrate和 Ent Automatic Migration 则是直接在运行时对齐 Schema。
先给结论
| 场景 | 推荐 | 原因 |
|---|---|---|
| 只想维护 SQL,方案简单、可控 | golang-migrate | 文件格式直观,CLI 和 Go 库都成熟,适合和 sqlc、手写 SQL 配合 |
| 需要 SQL + Go 函数迁移,或要把迁移嵌入二进制 | goose | 同时支持 SQL、Go migration、embed.FS,还可以按需配置数据库锁 |
| 希望自动生成 SQL、在 CI 中检查风险 | Atlas | 支持声明式和版本化工作流,能 diff、lint,并和 GORM、Ent 等 ORM 集成 |
| 本地原型、测试库、一次性内部工具 | GORM AutoMigrate / Ent Automatic Migration | 几乎不用写迁移文件,上手成本最低 |
| 生产系统 | 版本化迁移 | 每次变更可审查、可追踪,发布和数据库变更可以独立控制 |
如果没有特别的 ORM 约束,我的默认选择是:
- SQL 为主:先选
golang-migrate。 - 需要在迁移中执行复杂 Go 逻辑:选
goose。 - 团队希望从 Schema 或 ORM 模型自动生成迁移,并且重视 CI 风险检查:选 Atlas。
版本化迁移和声明式迁移
版本化迁移:数据库变更就是提交历史
典型目录如下:
migrations/
├── 000001_create_users.up.sql
├── 000001_create_users.down.sql
├── 000002_add_users_email.up.sql
└── 000002_add_users_email.down.sql
工具在数据库中记录已经执行过的版本,之后只执行还没有执行的文件。迁移文件一旦进入生产环境,就应该视为不可修改的历史记录;发现问题时新增修复迁移,而不是回头编辑旧文件。
优点是确定性强:Pull Request 中看到的 SQL,就是生产环境准备执行的 SQL。缺点是开发者需要自己判断字段重命名、数据回填、索引构建等细节,工具不会替你猜出业务意图。
声明式迁移:描述目标状态,由工具计算差异
声明式方案只关心“现在的 Schema”和“目标 Schema”之间的差异:
当前数据库 ──┐
├── diff / plan ──> 迁移 SQL ──> 目标数据库
目标 Schema ─┘
这能减少手写重复 DDL,尤其适合表多、索引多、ORM 模型比较复杂的项目。但自动生成的 SQL 仍然需要人工审查:工具通常能发现结构差异,却不一定知道一次改名其实应该保留数据,也不知道什么时候必须采用“扩展—迁移—收缩”的发布步骤。
因此,声明式并不等于“不需要迁移文件”。比较稳妥的做法是:由工具生成版本化 SQL,提交到 Git,经过 CI 检查后再发布。
方案一:golang-migrate
项目地址:github.com/golang-migrate/migrate
golang-migrate 是 Go 社区里最常见的 SQL-first 方案之一,同时提供 CLI 和 Go 库。迁移通常拆成 up 和 down 两个文件:
-- 000001_create_users.up.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 000001_create_users.down.sql
DROP TABLE users;
常用命令大致是:
migrate create -ext sql -dir migrations -seq create_users
migrate -path migrations -database "$DATABASE_URL" up
migrate -path migrations -database "$DATABASE_URL" version
优点
- SQL 文件就是最终变更,容易审查和排查问题。
- 同时支持 CLI 和库调用,可以作为独立发布步骤,也可以嵌入 Go 程序。
- 数据库驱动和迁移来源分离,除了本地文件,也能使用
io/fs、嵌入资源等来源。 - 支持的数据库类型较多,适合不想把迁移逻辑绑定到某个 ORM 的项目。
注意点
down只能回滚 Schema,不能自动恢复已经删除或改写的数据;生产环境不要把它当作数据库备份。- 迁移执行失败后可能进入 dirty 状态。应该先确认数据库实际状态,再使用
force修正版本,不能习惯性地强行跳过错误。 - 多实例同时启动时,不要默认认为应用启动迁移是安全的。官方文档也提示,多实例部署需要数据库锁支持;更简单的做法是让 CI/CD 或一次性 Job 负责迁移。
- 它只负责执行迁移,不负责判断“添加非空列会不会锁表”“删除列是否会破坏旧版本应用”等业务和发布风险。
适合谁
使用 database/sql、sqlx、sqlc 或轻量数据访问层,并希望迁移过程尽量透明的团队。它是“我愿意自己写 SQL,但不愿意自己写版本管理器”的方案。
方案二:goose
goose 同样是版本化迁移,但比 golang-migrate 更强调两种输入:SQL 文件和 Go 函数。SQL migration 可以写成:
-- 00002_add_user_status.sql
-- +goose Up
ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
-- +goose Down
ALTER TABLE users DROP COLUMN status;
CLI 用法比较直接:
goose -dir migrations postgres "$DATABASE_URL" up
goose -dir migrations postgres "$DATABASE_URL" status
它也支持把迁移嵌入二进制:
//go:embed migrations/*.sql
var migrationFS embed.FS
如果迁移需要调用 Go 代码,例如读取旧数据、分批回填、调用领域规则,可以注册 Go migration。使用 *sql.Tx 的形式时,迁移可以运行在事务中;对于 CREATE INDEX CONCURRENTLY 这类不能放进事务的语句,则可以在 SQL 文件中使用 -- +goose no transaction。
优点
- SQL 和 Go migration 可以混用,覆盖范围比纯 SQL 方案更广。
- 支持
embed.FS,适合构建一个自包含的 migration binary。 - Provider API 可以减少全局状态,并支持配置
SessionLocker。但数据库锁不是默认开启的,调用方必须明确配置或保证只有一个迁移进程。 - 支持 status、up、down、redo、按版本执行等常用操作,学习成本不高。
注意点
- Go migration 会把业务代码和数据库变更绑在一起,迁移执行环境必须包含对应的 Go 包和配置。能用 SQL 完成的变更,通常不必写成 Go migration。
- 默认事务并不代表所有 DDL 都能安全放进一个事务;数据库方言和具体语句仍然需要单独确认。
- 开发阶段可能产生并行的时间戳迁移。合并到主干或发布前要统一顺序,避免团队长期依赖 out-of-order 迁移。
适合谁
迁移不只是改表,还需要复杂的数据变换;或者团队希望把 migration 文件和 Go 程序一起打包发布。对于普通 CRUD 服务,goose 和 golang-migrate 的差距没有大到值得反复切换,选团队更熟悉的即可。
方案三:Atlas
项目地址:github.com/ariga/atlas,文档:atlasgo.io
Atlas 的定位不是“另一个只会执行 SQL 文件的 CLI”,而是 Schema-as-Code 工具。它同时支持两种工作流:
- Declarative:把目标 Schema 应用到数据库,类似 Terraform 的目标状态模型。
- Versioned:根据目标 Schema 和已有迁移目录生成新的版本化迁移文件,再由 CI/CD 执行。
版本化工作流可以概括为:
# 根据 ORM / HCL / SQL Schema 生成迁移文件
atlas migrate diff add_user_email --env local
# 检查破坏性变更、表锁、数据依赖等风险
atlas migrate lint --env local
# 对目标数据库执行已经审核过的迁移
atlas migrate apply --env production
Atlas 支持 GORM、Ent、Bun、Beego、sqlc 等 Go 生态输入,也可以和 golang-migrate 的迁移目录配合。因此它不一定要替换现有迁移执行器:可以让 Atlas 负责 diff 和 lint,让现有工具负责 apply。
优点
- 能从 Schema 或 ORM 模型自动生成迁移,减少手写 DDL 的重复工作。
migrate lint可以在 Pull Request 阶段提示删除表、删除列、非空约束、表重写、长时间锁等风险。- 支持声明式和版本化两种方式,团队可以从自动对齐开始,再逐步切换到受审查的版本化迁移。
- 对 GORM 和 Ent 有官方集成;不需要为了获得自动 diff 而放弃已有 ORM。
注意点
- Atlas CLI 和配置比
golang-migrate、goose更复杂,小项目可能用不上。 - 自动 diff 不是业务语义推断器。字段从
old_name变成new_name时,工具可能生成删除旧列、添加新列;如果这是重命名,必须人工改成保留数据的迁移。 - 生成的 SQL 仍然要看,尤其是大表、生产流量高、跨多个数据库方言的项目。
- 声明式迁移如果直接连生产库执行,审查和回滚边界会变模糊。生产环境更适合生成并提交版本化迁移,然后只执行已审核的文件。
适合谁
Schema 较复杂、使用 GORM 或 Ent、希望把数据库检查纳入 CI,或者已经遇到“迁移 SQL 能执行,但发布会锁表”的团队。
方案四:GORM AutoMigrate 和 Ent Automatic Migration
GORM AutoMigrate
db.AutoMigrate(&User{}, &Order{})
GORM 官方文档说明,AutoMigrate 会创建缺失的表、列、索引、约束,并在部分类型或可空性变化时修改已有列;为了保护数据,它不会删除已经不再使用的列。
这对本地开发非常方便,但它有几个天然限制:
- 没有清晰的、按提交记录保存的迁移意图。
- 模型变化不一定能表达“重命名”还是“删除后新建”。
- 应用启动时自动改生产库,会把数据库 DDL 和应用启动耦合在一起。
- 大表上的类型、索引和约束变更,仍然可能造成锁、重写或长时间执行。
GORM 官方也提供了 Atlas 集成:当 AutoMigrate 不够用时,可以使用 GORM Provider 生成版本化迁移。
Ent Automatic Migration
Ent 的 Automatic Migration 通过 client.Schema.Create(ctx) 让数据库对齐生成的 Schema。默认是 append-only 模式:创建新资源、追加列、扩展列类型;删除列和索引需要显式打开选项。
Ent 官方文档把 Automatic Migration 定位为原型、开发和测试用途,并建议关键生产环境使用 Versioned Migration。Ent 的版本化迁移由 Atlas 生成 SQL 文件,之后可以使用 Atlas、golang-migrate 等工具执行。
什么时候可以用自动迁移
- 测试数据库每次都可以销毁重建。
- 本地原型还没有稳定的数据。
- 内部工具停机几秒钟没有影响,且数据库规模很小。
一旦数据库中有重要数据、需要灰度发布或需要多人协作,自动迁移就应该退回开发环境,把生产变更改成版本化文件。
关键能力对比
| 产品 | SQL-first | Go 函数迁移 | 自动生成 SQL | Migration 文件可审查 | CI 风险检查 | 运行时自动对齐 | embed.FS | 适合生产默认值 |
|---|---|---|---|---|---|---|---|---|
| golang-migrate | 强 | 不作为主要模型 | 否 | 强 | 需自行组合 | 否 | 支持库调用 | 高 |
| goose | 强 | 强 | 否 | 强 | 需自行组合 | 否 | 支持 | 高 |
| Atlas | 支持 | 可通过生成后编辑 / 外部步骤处理 | 强 | 强(版本化模式) | 内置 lint / analyzers | 可做,但生产不建议直接使用 | 不是核心用法 | 高 |
| GORM / Ent 自动迁移 | 弱 | 可写业务代码,但不建议这样管理生产历史 | 强,但粒度和审查能力较弱 | 弱 | 需自行补充 | 是 | 不适用 | 低 |
这里的“回滚”要谨慎理解。结构上的 down 只代表工具可以执行反向 SQL,不代表数据能恢复。例如:
ALTER TABLE users DROP COLUMN email;
即使再写一个 ADD COLUMN email,原来的数据也不会回来。因此生产发布更常见的做法是“向前修复”:保留已执行迁移,新增一个修复迁移,而不是直接执行 down。
推荐的生产工作流
1. 迁移由独立步骤执行
不要让每一个应用实例启动时都执行迁移。更稳妥的流水线是:
构建应用
↓
创建 / 审查迁移
↓
CI:空库执行、风险检查、必要的回滚测试
↓
部署 Job:只运行一次迁移
↓
部署兼容新旧 Schema 的应用
如果确实要在应用启动时迁移,至少要使用工具提供的数据库锁,并确认目标数据库和驱动的锁语义;不要用“多台实例大概率不会同时启动”当作并发控制。
2. 采用 expand / migrate / contract
以把 users.name 改成 users.display_name 为例,不要一步删除旧列:
- 增加
display_name,保持可空或提供安全默认值。 - 应用同时写入旧列和新列。
- 回填历史数据,并确认新版本已经只读新列。
- 删除旧列,作为后续独立迁移。
这样旧版本应用和新版本应用可以短时间共存,适合滚动发布和灰度发布。Atlas 能帮助识别部分危险 DDL,但字段重命名和双写策略仍然需要业务代码配合。
3. 测试迁移,不只测试 Go 代码
最小检查集可以是:
# 对空数据库执行全部 up
make db-up
# 检查当前版本和迁移状态
make db-status
# 在临时数据库中验证 down,再重新 up
make db-down-up
测试应尽量使用和生产相同的大版本数据库。SQLite 上通过,不代表 PostgreSQL 或 MySQL 上也通过;事务、锁、索引构建和 ALTER TABLE 行为都可能不同。
最终选择
- 普通 Go 服务 + 手写 SQL / sqlc:
golang-migrate。它的模型最小,迁移历史最容易读懂。 - 需要数据回填、复杂转换或 Go 代码参与:
goose。只把确实需要 Go 的部分写成 Go migration,其余继续用 SQL。 - GORM / Ent 项目,且希望自动 diff、lint、CI 审查:Atlas 的版本化工作流。开发时可以从 ORM 模型生成 SQL,生产时执行 Git 中已经审核的迁移文件。
- 原型和测试库:GORM
AutoMigrate或 Ent Automatic Migration。数据库一旦成为长期资产,就切换到版本化迁移。
数据库迁移工具的差异,最终没有 SQL 变更本身的风险大。工具负责记录顺序、生成脚本和执行;是否会丢数据、锁表、破坏旧版本应用,仍然需要开发者按数据库和发布流程做判断。