上一篇 讲了 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 记录了两次重基线:
- 2026-06-10:6 位顺序号 → 14 位时间戳(避多人撞号)
- 2026-06-14:时间戳 → 6 位零填充顺序号(恢复可读性,撞号改由 PR 级 CI 链守卫兜底)
时间戳方案"天然不撞号,但不可读、无法一眼判断顺序/数量/是否有空洞"。单仓库(非多团队并行)下,顺序号 + CI 守卫是更优解。
(三)CI 链完整性守卫:连续、单头、配对
migrate_test.go:31-60 的 TestEmbeddedMigrationsContiguous 是 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 连续,不能跳号
- 单头 :链尾版本号 == 迁移文件数 ==
migrate.go的const Head - up/down 配对:每个版本的 up 和 down 文件都存在且可读
新增迁移后只需要做两件事:取当前最大序号+1 创建 up/down 文件,同步 migrate.go 的 const 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 的执行机制:
- 执行迁移前,设置
dirty=true - 执行 SQL
- 成功后设置
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 的迁移基础设施用六道防线保证数据库变更不翻车:
- embed.FS:迁移文件编译进二进制,零外部依赖
- 6 位零填充顺序号:可读可审计,排序正确
- CI 链完整性守卫:PR 阶段自动拦截不连续/撞号/缺 down
- dirty 状态检测:迁移中途崩溃后拒绝继续,需人工 force 修复
- 哨兵探针:启动期抽查关键对象存在性,防账本漂移
- 迁移与服务解耦:deploy 独立跑 migrate up,服务只做 Check 校验
结果是:143 个 DDL 有序执行,一条坏迁移不会让服务中断,账本漂移在启动期就被发现而不是运行期离奇报错。
下一篇(E98)讲 Eino 的另一面------Adk 怎么跑,Agent 的完整生命周期。