Skip to content
Charles
Go back

Go 数据库迁移方案对比

Edit page

数据库迁移不是把几条 ALTER TABLE 放进项目就结束了。真正要解决的是:数据库如何从版本 A 稳定地变成版本 B,团队如何审查这次变化,失败后如何恢复,以及多台应用实例如何避免同时改表。

Go 生态里的方案大致分成两类:

先给结论

场景推荐原因
只想维护 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 约束,我的默认选择是:

版本化迁移和声明式迁移

版本化迁移:数据库变更就是提交历史

典型目录如下:

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 库。迁移通常拆成 updown 两个文件:

-- 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

优点

注意点

适合谁

使用 database/sqlsqlxsqlc 或轻量数据访问层,并希望迁移过程尽量透明的团队。它是“我愿意自己写 SQL,但不愿意自己写版本管理器”的方案。

方案二:goose

项目地址:github.com/pressly/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

优点

注意点

适合谁

迁移不只是改表,还需要复杂的数据变换;或者团队希望把 migration 文件和 Go 程序一起打包发布。对于普通 CRUD 服务,goose 和 golang-migrate 的差距没有大到值得反复切换,选团队更熟悉的即可。

方案三:Atlas

项目地址:github.com/ariga/atlas,文档:atlasgo.io

Atlas 的定位不是“另一个只会执行 SQL 文件的 CLI”,而是 Schema-as-Code 工具。它同时支持两种工作流:

版本化工作流可以概括为:

# 根据 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 较复杂、使用 GORM 或 Ent、希望把数据库检查纳入 CI,或者已经遇到“迁移 SQL 能执行,但发布会锁表”的团队。

方案四:GORM AutoMigrate 和 Ent Automatic Migration

GORM AutoMigrate

db.AutoMigrate(&User{}, &Order{})

GORM 官方文档说明,AutoMigrate 会创建缺失的表、列、索引、约束,并在部分类型或可空性变化时修改已有列;为了保护数据,它不会删除已经不再使用的列。

这对本地开发非常方便,但它有几个天然限制:

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-firstGo 函数迁移自动生成 SQLMigration 文件可审查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 为例,不要一步删除旧列:

  1. 增加 display_name,保持可空或提供安全默认值。
  2. 应用同时写入旧列和新列。
  3. 回填历史数据,并确认新版本已经只读新列。
  4. 删除旧列,作为后续独立迁移。

这样旧版本应用和新版本应用可以短时间共存,适合滚动发布和灰度发布。Atlas 能帮助识别部分危险 DDL,但字段重命名和双写策略仍然需要业务代码配合。

3. 测试迁移,不只测试 Go 代码

最小检查集可以是:

# 对空数据库执行全部 up
make db-up

# 检查当前版本和迁移状态
make db-status

# 在临时数据库中验证 down,再重新 up
make db-down-up

测试应尽量使用和生产相同的大版本数据库。SQLite 上通过,不代表 PostgreSQL 或 MySQL 上也通过;事务、锁、索引构建和 ALTER TABLE 行为都可能不同。

最终选择

数据库迁移工具的差异,最终没有 SQL 变更本身的风险大。工具负责记录顺序、生成脚本和执行;是否会丢数据、锁表、破坏旧版本应用,仍然需要开发者按数据库和发布流程做判断。

参考资料


Edit page
Share this post:

Previous Post
MongoDB 也可以做很多事
Next Post
Rust 数据库迁移方案对比