Go 基础体系 · 第 61/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go 数据库迁移:golang-migrate、Atlas、回滚与零停机变更
本文以 Go 1.26.4、golang-migrate v4.19.0、Atlas v0.36.0 和 PostgreSQL 17 为基准。版本是本文采用的稳定版本;迁移工具升级必须独立评审并在副本数据上演练。工具能排序、加锁、记录版本和执行 SQL,却不能自动证明变更无锁、无数据丢失或可在旧应用上运行。
数据库 schema 是同时被旧实例、新实例、后台任务、报表和运维工具读取的共享契约。可靠迁移必须回答:当前版本是什么,谁可以执行,失败停在哪里,应用版本怎样兼容,数据如何恢复,以及变更在真实规模下会持锁多久。
1. 迁移系统的状态机
版本迁移不是“启动时跑几条 SQL”。golang-migrate 按有序版本读取 source,通过 database driver 获取锁、查询当前版本、执行 up/down 并记录状态。若一次迁移中途失败,版本可能标记 dirty,后续自动执行会停止,要求人工核对数据库实际状态。
未执行 ──up──► 执行中 ──成功──► version=N, dirty=false
│
└─失败──────► version=N, dirty=true
Atlas 的版本化迁移目录还包含校验和文件,用于发现历史文件被修改。无论工具,已经在环境执行的迁移都应视为不可变事实;修正生产问题应新增前向迁移,而不是重写历史让不同环境拥有同一编号的不同内容。
2. 目录、编号与不可变历史
使用单调递增且不重复的编号。多人并行开发时可用 UTC 时间戳或集中分配序号;合并前解决冲突。名称描述动作,不使用含糊的 fix。
db/migrations/
├── 202608310901_create_articles.up.sql
├── 202608310901_create_articles.down.sql
├── 202608311015_add_publish_status.up.sql
├── 202608311015_add_publish_status.down.sql
└── atlas.sum
golang-migrate 的 up/down 文件成对并不表示 down 永远安全。Atlas 目录由工具计算校验和后应提交。应用、迁移和回填程序最好在同一发布制品中记录 Git 提交,事故时才能还原“哪份 SQL 在哪个环境执行”。
3. 第一条可回滚的 schema 迁移
建表迁移应显式约束、索引和时区类型。up 使用 IF NOT EXISTS 看似可重入,却可能掩盖已有对象定义不同;受控版本迁移通常让冲突明确失败。
-- 202608310901_create_articles.up.sql
CREATE TABLE articles (
article_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id bigint NOT NULL,
slug text NOT NULL,
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
status text NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'archived')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, slug)
);
CREATE INDEX articles_tenant_created_idx
ON articles (tenant_id, created_at DESC, article_id DESC);
-- 202608310901_create_articles.down.sql
DROP TABLE articles;
这个 down 会删除全部文章,只适合尚无重要数据的开发环境或明确灾难恢复流程。生产回退应用时通常保留向后兼容的表,后续再前向清理,而不是立即执行破坏性 down。
4. golang-migrate CLI 与版本读取
CLI 适合 CI 和独立发布 Job。DSN 放在秘密变量,不能写入 shell history 或日志。生产先读取版本,再执行有限步数或目标版本,并记录输出。
migrate -version
migrate -path db/migrations -database "$DATABASE_URL" version
migrate -path db/migrations -database "$DATABASE_URL" up 1
migrate -path db/migrations -database "$DATABASE_URL" goto 202608311015
up 无参数会执行所有待迁移版本,变更积累较多时风险集中。goto 可能向上或向下执行,不应由不受控输入触发。CI 命令启用总体超时,平台终止后仍要查询数据库版本和活动会话,不能假设客户端退出等于服务端已回滚。
5. 在 Go 中嵌入迁移资源
Go 程序可用 embed.FS 打包 SQL,再通过 iofs source 执行。适合专门的迁移命令,不建议每个应用副本启动时争抢执行。
package migration
import (
"context"
"embed"
"errors"
"fmt"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/source/iofs"
)
//go:embed sql/*.sql
var files embed.FS
func Up(ctx context.Context, databaseURL string) error {
source, err := iofs.New(files, "sql")
if err != nil {
return fmt.Errorf("open embedded migrations: %w", err)
}
m, err := migrate.NewWithSourceInstance("iofs", source, databaseURL)
if err != nil {
return fmt.Errorf("create migrator: %w", err)
}
defer func() { _, _ = m.Close() }()
done := make(chan error, 1)
go func() { done <- m.Up() }()
select {
case err := <-done:
if err == nil || errors.Is(err, migrate.ErrNoChange) {
return nil
}
return fmt.Errorf("apply migrations: %w", err)
case <-ctx.Done():
return fmt.Errorf("wait for migrations: %w", ctx.Err())
}
}
这里 context 只能停止等待,未必取消 database driver 正在执行的 DDL;goroutine 使用容量 1 的 channel,完成后不会因无人接收而泄漏。生产应同时设置 PostgreSQL lock_timeout、statement_timeout 和平台 Job deadline。Close 的 source/database 错误在工具退出诊断中可单独记录,业务库函数则应显式返回需要处理的关闭错误。
6. Atlas 声明式与版本化工作流
Atlas 可以比较期望 schema 与开发数据库生成 diff,也能维护版本化迁移目录。生产应执行经过评审的版本目录,而不是临时对线上库自动 diff 后立即应用。
env "local" {
src = "file://schema.hcl"
dev = "docker://postgres/17/dev?search_path=public"
migration {
dir = "file://db/migrations"
}
lint {
destructive {
error = true
}
}
}
atlas migrate diff add_publish_status --env local
atlas migrate lint --env local --latest 1
atlas migrate hash --dir file://db/migrations
atlas migrate apply --dir file://db/migrations --url "$DATABASE_URL"
dev database 是计算差异的临时环境,版本要与生产一致。lint 能发现部分破坏性或兼容性问题,但不知道表真实大小、请求模式和业务恢复能力,不能替代评审与演练。
7. 迁移锁与多实例竞争
迁移工具通常借助数据库 advisory lock、锁表或 driver 机制保证同一数据库一次只有一个执行者。锁只防止两个迁移器并发,不防止应用查询与 DDL 互相阻塞,也不保证跨多个数据库的原子发布。
应用启动自动迁移会让滚动发布中的所有副本竞争:新实例可能等锁导致健康检查失败,旧实例又可能在新 schema 上运行。生产更适合独立、单实例、可审计 Job,成功后再部署依赖新结构的代码。Job 身份与应用身份分离,只有迁移身份拥有 DDL 权限。
锁等待要设置上限。失败时记录阻塞 PID、被阻塞语句和持锁事务,先终止根因长事务或调整发布窗口,不要无限增大 timeout。
8. 事务性 DDL 与 dirty 状态
PostgreSQL 大量 DDL 可在事务中回滚,但 CREATE INDEX CONCURRENTLY 不能在普通事务块中执行;不同数据库差异更大。迁移工具是否把整个文件包在事务中也取决于 driver 和文件指令,必须验证。
dirty 不等于“数据库什么都没改”。修复步骤是:停止自动重试;读取版本表和失败日志;检查目标表、索引、约束及未完成对象;判断语句是否提交;写修复 SQL;确认 schema 达到某个已知版本;最后才使用 force 调整元数据。
migrate -path db/migrations -database "$DATABASE_URL" version
migrate -path db/migrations -database "$DATABASE_URL" force 202608311015
force 不执行 SQL,只改版本/dirty 标记。未经核对直接 force 会让工具相信不存在的 schema,后续错误更难恢复。所有人工步骤应进入事故记录并补成可重复迁移。
9. expand/contract 零停机协议
跨滚动发布的安全变更分阶段完成。以把 status 拆为 publish_status 为例:
- Expand:新增 nullable
publish_status,旧列仍保留。 - 部署兼容应用:新写入同时维护两列,读取仍能回退旧列。
- 分批回填历史数据,并监控复制延迟和错误。
- 切换读取到新列,验证所有实例和任务。
- 添加约束/默认值,停止写旧列。
- 等待回滚窗口和旧版本完全退出后删除旧列。
阶段间可能跨多个发布。双写本身也可能不一致,最好由单条 SQL 或数据库 trigger 暂时保证,并明确移除日期。一次迁移同时改名列和部署新代码只在能原子停机发布时才安全。
10. 在线索引、约束与锁预算
普通 CREATE INDEX 在大表上可能长时间阻塞写。PostgreSQL 的 CONCURRENTLY 降低阻塞,但扫描更多次、耗时更长,失败可能留下 invalid index。
SET lock_timeout = '2s';
SET statement_timeout = '30min';
CREATE INDEX CONCURRENTLY orders_pending_idx
ON orders (tenant_id, created_at, order_id)
WHERE status = 'pending';
上线前用接近生产规模的数据测量时间、WAL、CPU、I/O 与复制延迟。失败后查询 pg_index.indisvalid 并按手册清理或重建。增加 CHECK 可先 NOT VALID,随后 VALIDATE CONSTRAINT;增加 NOT NULL 可借助已验证约束分阶段完成,具体方式按 PostgreSQL 版本验证。
11. 数据回填不是一条巨型 UPDATE
大表一次 UPDATE 会产生大量 WAL、长事务、膨胀和复制延迟,也让失败重试困难。回填程序按稳定主键分批、每批独立提交、记录进度,并让更新幂等。
WITH batch AS (
SELECT article_id
FROM articles
WHERE publish_status IS NULL
ORDER BY article_id
LIMIT 1000
FOR UPDATE SKIP LOCKED
)
UPDATE articles AS a
SET publish_status = CASE WHEN a.status = 'published' THEN 'published' ELSE 'draft' END
FROM batch
WHERE a.article_id = batch.article_id;
多 worker 使用 SKIP LOCKED 前要确认允许非顺序处理和最终扫描。每批设置 timeout,控制速率,监控剩余行、失败、数据库负载和 replica lag。回填代码使用与应用相同的字段语义,但独立部署、可暂停、可从游标恢复。
12. 回滚的四种含义
回滚至少有四类:回滚应用二进制、执行 down schema、从备份恢复数据、写前向修复迁移。它们风险不同,不能用一个按钮代替。
新增兼容列时通常只回滚应用并保留列;删除列或不可逆转换后,down 无法找回数据,只能从备份/归档恢复或前向重建。恢复备份又会覆盖故障后产生的新写入,需要点时间恢复和数据合并方案。
发布前应写决策点:迁移开始后多久仍可取消;何种指标触发停止;应用回滚是否兼容;备份恢复目标 RPO/RTO;由谁批准破坏性 down。真正的恢复能力来自定期演练,而不是存在 .down.sql 文件。
13. 失败模式与现场诊断
迁移卡住先查数据库活动、锁链、事务年龄和客户端状态。DDL 完成但 Job 超时,先读版本表和 schema,不能直接重跑。磁盘/WAL 暴涨时暂停回填或索引,确认 replica 是否还能追赶。权限错误要修正迁移角色,不给应用永久超级权限。
常见根因还包括历史文件被修改、不同分支复用版本号、生产数据库存在手工漂移、旧应用不兼容新约束、默认值重写大表、连接代理不支持会话锁。诊断材料至少保存工具版本、命令、迁移校验和、数据库版本、当前 version/dirty、失败 SQL、锁图和时间线,DSN 中的凭据必须删除。
14. CI、测试与静态检查
CI 需要两条路径:从空库执行全部 up,验证新环境;从上一发布 schema 执行增量,验证真实升级。随后启动旧/新应用契约测试,检查兼容窗口。可逆迁移在临时数据上执行 up/down/up;破坏性迁移明确标注不执行自动 down。
migrate -path db/migrations -database "$TEST_DATABASE_URL" up
go test -run Integration -count=1 ./...
atlas migrate lint --dir file://db/migrations --dev-url "$DEV_DATABASE_URL"
atlas migrate validate --dir file://db/migrations
go test -race ./...
SQL linter和 Atlas lint 是补充。测试要放入 NULL、重复键、长文本、旧状态和足够行数,验证约束与回填;仅对空表跑迁移无法暴露锁时间和数据转换错误。
升级路径测试应保存一份只含结构和匿名边界数据的上一版本快照,恢复后先启动旧应用做基线,再应用迁移,同时并发执行代表性读写,确认没有超出预算的阻塞。迁移完成后启动新应用,比较关键查询结果、行数、约束、索引有效性和序列值。测试结束必须销毁临时数据库,快照不得包含生产个人数据。
回填测试还要刻意在中途终止进程,再从已记录游标恢复,证明重复执行不会重复计费、覆盖新写入或跳过行。对时间较长的步骤记录每批耗时和剩余量,据此估算生产窗口,而不是按测试数据量线性猜测。
15. 安全、审计与供应链
迁移账户拥有高权限,应只在受控 Job 临时取得凭据,限制目标主机、数据库和有效期。命令输出不打印 URL;备份加密并限制访问;迁移日志记录操作者、制品摘要、审批、开始结束时间和结果。
固定 golang-migrate/Atlas 版本与镜像摘要,验证下载来源。禁止从未审查分支对生产执行本地文件。SQL 中不嵌真实客户数据和秘密;需要种子数据时使用稳定标识与幂等语句,并把测试种子和生产配置分离。
16. 生产发布与性能清单
发布前确认迁移版本唯一且历史校验未变;在生产统计副本演练;估算表大小、锁、WAL、磁盘和复制延迟;验证旧新应用兼容;准备停止条件、应用回滚、前向修复和备份恢复步骤。发布时先执行 expand,观察数据库,再部署应用;contract 延后到回滚窗口结束。
迁移 Job 只运行一个实例,设置连接、锁、语句和总体 timeout;监控锁等待、活跃事务、CPU、I/O、WAL、磁盘与 replica lag。完成后核对 version/dirty、对象定义、应用错误率和关键查询计划。任何人工修正都写成后续迁移并进入版本控制。这样迁移才是一套可审计、可停止、可恢复的生产协议,而不是寄希望于 SQL 在上线窗口一次成功。
恢复演练应真的创建隔离数据库,从最近全量备份和增量归档恢复到指定时间点,再运行一致性查询并测量 RPO、RTO。仅检查“备份任务成功”无法证明文件可读、密钥可用、归档连续或应用能连接。演练记录由数据库负责人和应用负责人共同确认,并把发现的问题转成有期限的修复项。
发布后保留观察窗口,禁止立即执行 contract。观察项包括旧版本实例是否归零、未知字段/列错误、写入双轨差异、回填积压和副本延迟。达到预先定义的稳定条件后,下一次独立发布才删除旧结构;若指标恶化,停止后续阶段并按决策表回滚应用或执行前向修复。
系列导航与关联阅读
- 系列入口:Go 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go XORM 与 Bun:常见 ORM 方案、查询风格和选型比较
- 下一篇:Go Redis 与 go-redis:连接、数据结构、Pipeline 和事务
- 延伸:Go database/sql 基础:连接池、事务、Context 与 NULL
- 延伸:Go GORM 完整指南:模型、查询、事务、关联与性能边界
- 延伸:Go Ent 实战:Schema、代码生成、关系、事务与隐私策略
- 延伸:Go sqlc 实战:从 SQL 生成类型安全的数据访问代码
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论