数据库迁移不翻车:golang-migrate 实战,143 个 DDL 有序执行(第97篇-E83)

上一篇 讲了 Agent 怎么测试。但还有一个更基础的问题:数据库 schema 怎么变?

手动跑 SQL 总有人忘了加列、忘了写 down、忘了更新 CI。版本号撞车。dirty 状态没人管。DeepFlux 的答案是:golang-migrate + embed.FS + CI 链守卫 + 哨兵探针。这篇拆解 143 个迁移(000001~000143)是怎么做到不翻车的。

为什么手动 SQL 会翻车

问题 后果
忘记写 down 回滚不了,只能手工恢复
版本号撞车 两个 PR 用同一个序号,合并后一个被跳过
漏跑迁移 生产 schema 版本落后,运行期离奇报错
dirty 状态 上次迁移中途崩溃,账本显示 dirty,后续迁移全部拒绝
账本脱钩 schema_migrations 显示版本 91,但迁移 52 的内容实际缺失

六个问题,DeepFlux 的迁移基础设施全有对应的防线。

(一)golang-migrate 基础:embed + 版本号

核心库

github.com/golang-migrate/migrate/v4 是 Go 生态最成熟的数据库迁移库。核心概念:

  • 版本号:uint 整数,golang-migrate 接受任意单调递增
  • up/down 配对:每个版本两个 SQL 文件,一个前进一个后退
  • schema_migrations 表:自动维护,记录当前版本号 + dirty 标记
  • dirty 标记:执行前写 true,成功后写 false。中途崩溃 → dirty=true → 拒绝执行

embed.FS 嵌入

DeepFlux 用 embed.FS 把迁移文件编译进二进制,运行时零外部依赖:

dbmigrate/migrate.go:22-34

go 复制代码
//go:embed migrations/postgres
var migrationsFS embed.FS

func newM(db *sql.DB) (*migrate.Migrate, error) {
    src, err := iofs.New(migrationsFS, "migrations/postgres")
    // ...
    drv, err := postgres.WithInstance(db, &postgres.Config{})
    // ...
    return migrate.NewWithInstance("iofs", src, "postgres", drv)
}

//go:embed 指令把 migrations/postgres/ 目录下所有文件打包进二进制。不需要在部署机器上放 SQL 文件,一个二进制就够了。

(二)6 位零填充顺序号:可读可审计

DeepFlux 的迁移文件名格式:NNNNNN_feature.{up,down}.sql

migrate_test.go:65-80

go 复制代码
var seqFilenameRE = regexp.MustCompile(`^\d{6}_.+\.(up|down)\.sql$`)

func TestMigrationFilenameFormat(t *testing.T) {
    entries, _ := fs.ReadDir(migrationsFS, "migrations/postgres")
    for _, e := range entries {
        if !seqFilenameRE.MatchString(e.Name()) {
            t.Errorf("迁移文件名非 6 位零填充序号格式: %s", e.Name())
        }
    }
}

当前 143 个迁移:

erlang 复制代码
000001_initial_schema.up.sql      000001_initial_schema.down.sql
000002_comments.up.sql            000002_comments.down.sql
...
000052_llm_providers.up.sql       000052_llm_providers.down.sql
...
000143_backfill_api_keys_user_id.up.sql  000143_backfill_api_keys_user_id.down.sql

为什么是 6 位零填充?排序1, 2, 10 按字符串排序是 1, 10, 2------乱序。000001, 000002, 000010 排序正确。

版本号重基线历史

docs/deployment/MIGRATION_REBASELINE.md 记录了两次重基线:

  1. 2026-06-10:6 位顺序号 → 14 位时间戳(避多人撞号)
  2. 2026-06-14:时间戳 → 6 位零填充顺序号(恢复可读性,撞号改由 PR 级 CI 链守卫兜底)

时间戳方案"天然不撞号,但不可读、无法一眼判断顺序/数量/是否有空洞"。单仓库(非多团队并行)下,顺序号 + CI 守卫是更优解。

(三)CI 链完整性守卫:连续、单头、配对

migrate_test.go:31-60TestEmbeddedMigrationsContiguous 是 PR 级防线:

go 复制代码
func TestEmbeddedMigrationsContiguous(t *testing.T) {
    src, _ := iofs.New(migrationsFS, "migrations/postgres")
    versions := collectVersions(t, src)

    // 连续无空洞:第 i 个版本号必须等于 i+1
    for i, v := range versions {
        if want := uint(i + 1); v != want {
            t.Fatalf("版本非连续: 第 %d 个=%d, 期望=%d", i+1, v, want)
        }
        assertReadable(t, src, v) // 同时验证 up/down 文件都存在
    }

    // 单头:链尾 == 文件版本数 == Head
    last := versions[len(versions)-1]
    if last != expectedHead {
        t.Fatalf("链尾版本=%d, 期望 Head=%d(新增迁移后同步 migrate.go 的 Head)", last, expectedHead)
    }
}

三道断言:

  1. 连续无空洞:从 1 起 +1 连续,不能跳号
  2. 单头 :链尾版本号 == 迁移文件数 == migrate.goconst Head
  3. up/down 配对:每个版本的 up 和 down 文件都存在且可读

新增迁移后只需要做两件事:取当前最大序号+1 创建 up/down 文件,同步 migrate.goconst Head。CI 自动拦截所有不一致。

(四)dirty 状态:失败即停止,不静默跳过

migrate.go:40-60

go 复制代码
func Up(db *sql.DB) error {
    m, _ := newM(db)
    defer m.Close()

    // 先检查 dirty 状态
    if v, dirty, _ := m.Version(); dirty {
        return fmt.Errorf(
            "dbmigrate: schema is dirty at version %d --- "+
            "manual repair required (run `deepflux migrate force <version>` "+
            "after fixing the failed migration)", v)
    }

    if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
        return fmt.Errorf("dbmigrate: up: %w", err)
    }
    // ...
}

golang-migrate 的执行机制:

  1. 执行迁移前,设置 dirty=true
  2. 执行 SQL
  3. 成功后设置 dirty=false

如果步骤 2 中途崩溃(比如 DDL 语法错误),dirty=true 留在表中。下次执行 Up 时,golang-migrate 拒绝继续------它不知道上次的 SQL 执行到哪了。

修复方式:人工确认后 migrate force <version>(只改版本号,不执行 SQL)。

(五)哨兵探针:防账本漂移

migrate.go:126-166 的哨兵探针是最容易被忽略的防线。

问题背景(2026-07-14 dev 库实锤):schema_migrations 显示版本 91,但迁移 52、55、66 的实际内容缺失(手工 force 或库间拷贝导致账本与 schema 脱钩)。此前没有任何守卫,服务带着残缺 schema 静默启动,运行期才以离奇报错暴露。

go 复制代码
type sentinel struct {
    table  string
    column string // 空 = 仅探表存在性
    origin string // 来源迁移(报错定位用)
}

var sentinels = []sentinel{
    {table: "sessions", origin: "000001_initial_schema"},
    {table: "llm_providers", origin: "000052_llm_providers"},
    {table: "llm_providers", column: "enc_api_keys", origin: "000066_llm_provider_keys"},
    {table: "llm_providers", column: "enabled", origin: "000110_provider_enabled_tenant_default_reasoning"},
    {table: "agent_configs", column: "self_evolve_enabled", origin: "000089_agent_config_self_evolve"},
    {table: "memory_profile_history", origin: "000091_soul_evolve"},
}

verifySentinels 在版本校验通过后抽查这些对象是否真实存在。不是全量 schema diff(过度设计),而是抽样覆盖曾实际漂移的迁移 + 链首尾各时代。缺一个即 fail-fast。

哨兵探针也有自己的腐化守卫:TestSentinels_MatchEmbeddedMigrations 检查每个哨兵的 origin 迁移文件确实提到该表/列名。

(六)迁移与服务解耦:deploy 跑,服务只 Check

这是 DeepFlux 迁移设计中最关键的架构决策。

migrate.go:78-106

go 复制代码
// Check 在服务启动期校验 schema 已迁移到 Head 且非 dirty------只读操作,不执行
// 任何 DDL。迁移本身由部署脚本的 `migrate up` 独立完成(与服务启动解耦):
// 一条有语法错误的迁移只会让部署脚本的 up 步骤失败、旧服务继续服务,绝不会
// 在服务启动路径上中断业务。
func Check(db *sql.DB) error {
    m, _ := newM(db)
    defer m.Close()
    v, dirty, _ := m.Version()
    // ...
    if dirty {
        return fmt.Errorf("schema 处于 dirty 状态(version=%d)", v)
    }
    if v < Head {
        return fmt.Errorf("schema 版本过旧(当前=%d 期望=%d)", v, Head)
    }
    // ...
}

部署流程(deploy/deploy.sh:453-454):

bash 复制代码
# 步骤 4:数据库迁移(服务在线;用新版 migrate,schema 须向后兼容以支持回滚)
run_migrate "$DEPLOY_BIN_DIR/.new/migrate"

迁移在服务重启之前独立执行。好处:

  • 一条有语法错误的迁移只让部署失败,旧服务继续服务
  • 服务启动路径不做任何 DDL,不会因为迁移问题中断业务
  • 向后兼容的迁移可以在服务在线时执行

(七)按月分区自愈

dbmigrate/partitions.go:19-37

go 复制代码
var monthlyPartitionParents = []string{"events", "tool_audit"}

func EnsureMonthlyPartitions(ctx context.Context, db *sql.DB) error {
    now := time.Now().UTC()
    for _, parent := range monthlyPartitionParents {
        for off := 0; off <= 1; off++ {
            start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, off, 0)
            end := start.AddDate(0, 1, 0)
            name := fmt.Sprintf("%s_%04d_%02d", parent, start.Year(), int(start.Month()))
            stmt := fmt.Sprintf(
                `CREATE TABLE IF NOT EXISTS %s PARTITION OF %s FOR VALUES FROM ('%s'::timestamptz) TO ('%s'::timestamptz)`,
                name, parent, start.Format(time.RFC3339), end.Format(time.RFC3339))
            db.ExecContext(ctx, stmt)
        }
    }
}

这是 cron 未部署时的第二道保险:服务启动时自动创建当月和次月分区。CREATE TABLE IF NOT EXISTS 保证了幂等性。

(八)迁移类型一览

143 个迁移覆盖了多种操作类型:

类型 示例 迁移号
建表 CREATE TABLE tenants 000001
加列 ALTER TABLE llm_providers ADD COLUMN enabled 000110
加索引 CREATE INDEX idx_kb_chunks_index_tsv 000115
启用 RLS ALTER TABLE agent_configs ENABLE ROW LEVEL SECURITY 000045
数据回填 UPDATE api_keys SET user_id = ... 000143
修改默认值 ALTER TABLE tenants ALTER COLUMN llm_routing SET DEFAULT 000110
加注释 COMMENT ON COLUMN 000088
加扩展 CREATE EXTENSION IF NOT EXISTS "uuid-ossp" 000001

(九)测试策略

migrate_test.go 包含四个测试,构成完整的迁移链防线:

测试 守卫内容
TestEmbeddedMigrationsContiguous 连续无空洞、单头、up/down 配对、Head 对齐
TestMigrationFilenameFormat 文件名格式(NNNNNN_feature.{up,down}.sql
TestSentinels_MatchEmbeddedMigrations 哨兵列表与嵌入迁移同步(防腐化)
collectVersions / assertReadable 辅助函数,验证文件可读

小结

DeepFlux 的迁移基础设施用六道防线保证数据库变更不翻车:

  1. embed.FS:迁移文件编译进二进制,零外部依赖
  2. 6 位零填充顺序号:可读可审计,排序正确
  3. CI 链完整性守卫:PR 阶段自动拦截不连续/撞号/缺 down
  4. dirty 状态检测:迁移中途崩溃后拒绝继续,需人工 force 修复
  5. 哨兵探针:启动期抽查关键对象存在性,防账本漂移
  6. 迁移与服务解耦:deploy 独立跑 migrate up,服务只做 Check 校验

结果是:143 个 DDL 有序执行,一条坏迁移不会让服务中断,账本漂移在启动期就被发现而不是运行期离奇报错。

下一篇(E98)讲 Eino 的另一面------Adk 怎么跑,Agent 的完整生命周期。

相关推荐
小四的小六21 分钟前
AI 生成代码翻车实录:数据库回填脚本上线后数据错乱,我的完整复盘
aigc·openai·ai编程
Lambert28125 分钟前
Agent 的 App Store 来了:写一个 SKILL.md,Java Agent 立刻多一项新技能
aigc·ai编程
用户330144867632 小时前
mheap:全局堆管理器
go
慕易8352 小时前
我把 LangGraph 官方 Demo 扩成了生产级多 Agent 系统,这 3 个"反直觉"设计救了整个项目
agent
今日无bug2 小时前
MCP 入门实战:Tool 和 LLM 解耦?跨进程跨语言调用工具原来是这么回事
llm·agent·mcp
星火10242 小时前
【从 0 到 1 动手造 Agent】02、确定性铁笼 LangGraph
人工智能·后端·agent
用户330144867632 小时前
10. Large 对象分配:直接路径
go
yinchnag2 小时前
CRTP:奇异递归模板模式在 Go 模块系统中的实现
设计模式·go
星火10242 小时前
【从 0 到 1 动手造 Agent】03、给 Agent 装上操作系统——MemGPT/Letta 内存分层与自我演化
人工智能·后端·agent