数据库迁移真正棘手的部分,往往不是把历史数据复制到新库,而是在生产服务持续处理请求时,让新旧存储长期保持一致。Google 财务工程团队在迁移到 Spanner 时,需要为 30 多个 DAO 补齐转换器、双写分支、错误处理和单元测试。逐个手工修改不仅耗时,还容易在时间戳、空值和字段映射上产生细微偏差。
他们采用的办法,是先把迁移模式标准化,再通过 Antigravity CLI 的无头模式批量生成代码,并让构建系统负责验证和纠错。这里的重点不是“让 AI 自由改代码”,而是把 AI 放进一个输入明确、输出受限、失败可反馈的工程闭环。
三阶段迁移,解决三类不同问题
一次不中断服务的迁移可以拆成三个阶段:
- 历史回填:把旧存储中的已有记录复制到 Spanner,同时维护主外键和记录间的引用关系。
- 双写与双读:修改 DAO,使迁移窗口内的新增和更新同时进入旧存储与 Spanner;必要时从两边读取并比较结果。
- 接口级一致性验证:拦截或回放 RPC 流量,确认每次写入都在两个存储中产生等价结果。
这三个阶段不能互相替代。历史回填只覆盖过去的数据,双写负责迁移期间的新流量,而 API 校验用于发现单元测试不容易覆盖的序列化、默认值和调用链问题。
还要注意,所谓“双写”并不天然等于跨库原子事务。旧库成功而 Spanner 失败,或者 Spanner 已提交但调用方超时,都会形成不一致。因此 DAO 至少要记录两侧结果、保留可重试标识,并建立补偿或对账任务。金融数据场景下,静默忽略次库失败通常不可接受。
先固定 MutationConverter,再让自动化扩展
自动化改造能够稳定运行的前提,是把开放式设计问题收敛成确定的接口。源实践把领域模型到 Spanner Schema 的翻译隔离到 MutationConverter,DAO 不直接散落表名和列映射。
下面是可以据此改造的 Go 示例。运行前需要把模块路径、模型字段和表名替换为项目中的实际定义,并安装 Cloud Spanner Go SDK:
package migration
import (
"errors"
"cloud.google.com/go/spanner"
)
type TransferAmount struct {
TransferID string
AmountCents int64
Currency string
}
type TransferAmountMutationConverter interface {
ToInsertMutation(*TransferAmount) (*spanner.Mutation, error)
ToUpdateMutation(*TransferAmount) (*spanner.Mutation, error)
}
type transferAmountConverter struct {
tableName string
}
func NewTransferAmountConverter(tableName string) TransferAmountMutationConverter {
return &transferAmountConverter{tableName: tableName}
}
func (c *transferAmountConverter) ToInsertMutation(entity *TransferAmount) (*spanner.Mutation, error) {
if entity == nil {
return nil, errors.New("entity cannot be nil")
}
columns := []string{
"TransferId",
"AmountCents",
"CurrencyCode",
"LastModifiedTimestamp",
}
values := []any{
entity.TransferID,
entity.AmountCents,
entity.Currency,
spanner.CommitTimestamp,
}
return spanner.Insert(c.tableName, columns, values), nil
}
func (c *transferAmountConverter) ToUpdateMutation(entity *TransferAmount) (*spanner.Mutation, error) {
if entity == nil {
return nil, errors.New("entity cannot be nil")
}
return spanner.Update(c.tableName,
[]string{"TransferId", "AmountCents", "CurrencyCode", "LastModifiedTimestamp"},
[]any{entity.TransferID, entity.AmountCents, entity.Currency, spanner.CommitTimestamp},
), nil
}
这个边界带来三个直接收益:字段映射可以独立测试;DAO 只负责调用顺序和错误策略;自动化工具面对的是重复、结构化的任务,而不是每次重新理解整套业务设计。
在制定转换规范时,应明确以下容易产生歧义的规则:
- 可空字段使用
nil、Spanner nullable 类型,还是领域层包装类型。 - 时间字段来自业务时间、注入的
FakeTimeSource,还是spanner.CommitTimestamp。 - 插入、更新和 upsert 分别使用哪种 Mutation。
- 金额是否统一使用最小货币单位,禁止经过浮点数转换。
- 旧库默认值与 Spanner 默认值不同时,以哪一侧为准。
把无头 CLI 接入“生成、测试、修复”循环
IDE 聊天适合探索单个文件,但 30 个 DAO 的迁移更需要可重复执行的批处理。Antigravity CLI 的 -p 无头模式可以从脚本接收提示词,不依赖人工终端交互。
下面给出一个可改造的最小 Bash 工作流。示例假设可执行文件名为 antigravity,并使用 go test;如果项目使用内部构建系统,应替换 TEST_CMD 和代码收集逻辑。
#!/usr/bin/env bash
set -euo pipefail
DAO_NAME="${1:?usage: ./migrate-dao.sh <dao-name>}"
MAX_ATTEMPTS="${MAX_ATTEMPTS:-3}"
TEST_CMD="${TEST_CMD:-go test ./...}"
PROMPT_FILE="prompts/spanner-dual-write.md"
LOG_FILE=".migration-${DAO_NAME}.log"
[[ -f "$PROMPT_FILE" ]] || {
echo "missing prompt template: $PROMPT_FILE" >&2
exit 1
}
for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
context=$(rg -n --glob '*.go' "$DAO_NAME|type .*DAO|MutationConverter" . || true)
failures=$(test -f "$LOG_FILE" && tail -n 200 "$LOG_FILE" || true)
prompt=$(printf '%s\n\nTarget DAO: %s\n\nRepository context:\n%s\n\nPrevious test failures:\n%s\n' \
"$(<"$PROMPT_FILE")" "$DAO_NAME" "$context" "$failures")
antigravity -p "$prompt"
if bash -lc "$TEST_CMD" 2>&1 | tee "$LOG_FILE"; then
echo "migration verified for ${DAO_NAME}"
exit 0
fi
done
echo "migration failed after ${MAX_ATTEMPTS} attempts" >&2
exit 1
提示词文件也应进入版本控制。它不应只写“为这个 DAO 添加双写”,而要列出允许修改的目录、转换器接口、错误处理策略、测试要求和禁止事项。例如要求保留旧库为主写路径、禁止吞掉 Spanner 错误、必须注入假时间源,并为 nil、时间戳和可空字段生成测试。
测试失败后,可以把编译错误和断言输出反馈给下一轮生成,但必须限制重试次数。无限自修复循环可能反复扩大修改范围,也会掩盖真正的架构问题。每个 DAO 最好形成独立变更,方便人工审查和回滚。
一致性校验不能只看“测试通过”
单元测试应确认两个写入客户端都收到预期调用,Mutation 的表名、列顺序和值完全符合约定,并通过假时间源消除不稳定结果。不过,单元测试只能验证代码路径,还需要在预生产环境执行端到端对账。
对账时不要直接比较数据库驱动返回的原始对象。提交时间戳、空值表示和数值类型可能存在合法差异。更稳妥的方式是先定义规范化规则,再比较业务等价值;只有明确要求逐字节一致的字段,才进行字节级比较。同时记录请求 ID、业务主键、两侧版本和差异原因,以便重放与修复。
落地时的检查清单
开始批量生成前,先手工完成一两个代表性 DAO,并让团队确认其接口和测试模式。之后再把它们作为自动化参考样本。
上线前至少确认:历史回填支持断点续传;双写失败可观测且可重试;写入具有幂等键;提示词和生成规则进入版本控制;每个变更都经过编译、单测和人工审查;预生产环境持续执行新旧库对账;切流和回滚条件有明确指标。
Antigravity CLI 在这套方案中承担的是规模化代码变换,而不是迁移正确性的最终裁判。真正的安全边界仍然来自稳定的转换接口、可重复的构建测试、线上可观测性,以及能够定位和修复差异的对账机制。