本节目标:讲清「代码能随时回滚、数据库不能」这个不对称性带来的全部约束。读完后你能用 expand-contract 把破坏性结构变更拆成向前兼容的多步,能写出不锁表、不炸 WAL 的大表回填脚本,能说清蓝绿、金丝雀、滚动、功能开关四种灰度各自适合什么场景,并知道灰度期必须同时兼容新旧两版代码。
18.2 数据库迁移与灰度发布
上一节我们把代码变成了不可变镜像,随时可以用旧 digest 覆盖新 digest。但线上还有一份有状态、且新旧版本共享的东西——数据库。代码回滚只要几十秒,表结构回滚可能要恢复一整份备份。这一节要处理的,正是这个不对称性带来的全部麻烦。
为什么迁移比代码发布难
先把这个不对称性说透,后面的所有规则都是它的推论:
| 维度 | 代码变更 | 结构变更 |
|---|---|---|
| 可逆性 | 换镜像即可 | 删列后数据不可恢复 |
| 生效范围 | 单实例,随发布推进 | 全库共享,立即对所有版本生效 |
| 灰度能力 | 可按流量比例 | 无法只对 5% 用户改表结构 |
| 回滚成本 | 秒级 | 分钟到小时,可能需要备份恢复 |
第二行是核心:迁移是一次性的、全局的,而代码是渐进的、局部的。灰度期间新旧两个版本同时读写同一张表,所以迁移产生的任何结构状态,都必须能被两个版本同时正确使用。
铁律:先迁移、后发布,且向前兼容
由此得到两条不可协商的规则:
- 顺序必须是「先迁移,再部署新代码」。反过来做,新代码在迁移完成前会访问不存在的列。
- 每一次迁移都必须向前兼容,即旧代码在新结构下仍然能正常工作。兼容窗口的长度 = 灰度时长 + 回滚窗口 + 一个安全余量。
这两条合起来就是 expand-contract 模式,我们在 7.3 迁移、事务与连接池 里已经见过它的四步骨架,这里关注它在发布流程里的落地:
| 阶段 | 结构动作 | 代码动作 | 可回滚性 |
|---|---|---|---|
| Expand | 加新列 / 新表,可空 | 不变 | 完全可回滚 |
| Backfill | 分批搬历史数据 | 不变 | 完全可回滚 |
| Migrate | 不变 | 双写新旧、读新 | 回滚代码即可 |
| Contract | 删旧列 / 加约束 | 去掉双写 | 不可回滚 |
只有最后一步是不可逆的,所以它必须单独一次发布,且在它之前确认没有回滚需求。经验做法是把它推迟到下一次迭代,中间隔一个完整的观测周期。
三类不兼容变更的兼容写法
把常见的破坏性变更逐个拆成兼容步骤:
| 变更 | 危险写法 | 兼容写法 |
|---|---|---|
| 重命名列 | ALTER TABLE ... RENAME COLUMN | 加新列 → 回填 → 双写 → 读新 → 删旧列 |
| 改列类型 | ALTER COLUMN ... TYPE | 加新类型的影子列 → 回填转换 → 双写 → 切换 → 删旧列 |
| 删列 | DROP COLUMN | 先停止读写该列并发布 → 观察一个周期 → 再删 |
| 加非空列 | ADD COLUMN ... NOT NULL DEFAULT fn() | 先加可空列 → 回填 → 加 CHECK (col IS NOT NULL) NOT VALID → VALIDATE CONSTRAINT |
| 大表加索引 | CREATE INDEX | CREATE INDEX CONCURRENTLY,且必须放在事务外 |
| 加外键 | ADD FOREIGN KEY | 先 NOT VALID,回填后再 VALIDATE CONSTRAINT |
「先加约束 NOT VALID、数据补齐后再 VALIDATE」这个套路值得记住:NOT VALID 只对新写入生效,不扫描存量数据,因此拿锁是瞬时的;后续的 VALIDATE CONSTRAINT 只取 SHARE UPDATE EXCLUSIVE 锁,不阻塞读写。它把「加约束」这件原本要全表扫描的事拆成了两步。
回填:大表怎么搬数据
回填是整条链路里最容易出事故的一步。一个 UPDATE users SET ... 打在千万行表上,会带来三个后果:长事务持有旧快照导致膨胀、WAL 暴涨打满磁盘、以及复制延迟飙升。
正确写法是主键游标分批 + 批间休眠:
const BATCH = 1000
async function backfillNames() {
let cursor = 0
for (;;) {
const rows = await prisma.$queryRaw<{ id: number }[]>`
SELECT id FROM users
WHERE first_name IS NULL AND id > ${cursor}
ORDER BY id
LIMIT ${BATCH}
`
if (rows.length === 0) break
cursor = rows[rows.length - 1].id
const ids = rows.map((r) => r.id)
await prisma.$executeRaw`
UPDATE users
SET first_name = split_part(name, ' ', 1),
last_name = NULLIF(split_part(name, ' ', 2), '')
WHERE id = ANY(${ids})
`
await new Promise((r) => setTimeout(r, 100))
}
}
四个关键点:
- 游标用
id > cursor而不是OFFSET。OFFSET 1000000需要扫描并丢弃前一百万行,越翻越慢,且期间有新写入时会漏行或重复。 - 每批都要提交(循环外的
$executeRaw各自是独立事务)。一批一个短事务,锁持有时间以毫秒计。 - 批间休眠给了复制和 autovacuum 喘息的机会。休眠时长按复制延迟动态调整更稳妥。
- 可重入:条件是
first_name IS NULL,脚本中断后重跑会自动从断点继续,不会重复处理。
分批与限速的更多细节见 零停机数据库迁移策略 与 数据库迁移实践 。
迁移在流水线里的位置
迁移必须是独立的一步,不能混在应用启动里,也不能和部署绑成一个不可分割的动作。三条理由:迁移失败时你不希望新代码已经上线;迁移需要更高权限的凭据;以及迁移需要能单独重跑。
migrate:
needs: verify
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 22, cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
migrate deploy 的语义是「把未应用的迁移按序执行完」,它不比对 schema、不生成文件、不交互提示。生产环境只允许出现这一条命令,migrate dev 是开发期工具,在 CI 里跑它会生成新迁移甚至重置数据库。
配套的两条纪律:
- 已应用的迁移文件禁止修改。迁移的校验和会写进
_prisma_migrations表,改了文件就会在下次部署时校验失败。要修正只能追加一个新迁移。 - 迁移与应用部署是两个 job,
migrate成功后deploy才启动。GitHub Actions 侧的完整写法见 数据库迁移流水线 ,通用形态见 迁移流水线 。
灰度发布的四种形态
迁移解决的是「新旧版本能不能共存」,灰度解决的是「怎么把流量一点点交出去」。四种主流形态的取舍:
| 形态 | 流量粒度 | 回滚速度 | 资源成本 | 适用场景 |
|---|---|---|---|---|
| 蓝绿 | 全量切换 | 秒级(切回旧组) | 2 倍 | 需要原子切换、可接受双倍资源 |
| 金丝雀 | 按比例(1%→5%→50%) | 秒级 | 1.x 倍 | 常规发布,需观察真实指标 |
| 滚动 | 按实例批次 | 分钟级(需回滚整个 Deployment) | 1.x 倍 | 无状态服务,默认选择 |
| 功能开关 | 按用户 / 租户 / 请求 | 毫秒级(关开关) | 1 倍 | 逻辑级灰度,无需重新部署 |
它们不是互斥的:常见组合是「滚动更新 + 金丝雀比例 + 功能开关控制具体功能」。选择的关键是问一句「出问题时我要在多长时间内止损」,答案决定粒度。四种策略的对比与实现见 蓝绿与金丝雀部署 与 部署策略 。
功能开关:最小粒度的灰度
功能开关把「发布」和「放开」解耦:代码先上,功能后开。这是唯一能做到毫秒级回滚的形态,因为它根本不涉及重新部署。
type FlagName = 'checkout.v2' | 'new-pricing'
interface FlagRule {
rollout: number // 0-100,放量百分比
allow: string[] // 强制放行的 cohort
}
const rules: Record<FlagName, FlagRule> = {
'checkout.v2': { rollout: 5, allow: ['internal'] },
'new-pricing': { rollout: 0, allow: ['beta-testers'] },
}
// FNV-1a:同一用户在同一开关上的分桶结果稳定,不会忽开忽关
function bucket(flag: FlagName, userId: string): number {
let h = 2166136261
for (const ch of `${flag}:${userId}`) h = Math.imul(h ^ ch.charCodeAt(0), 16777619)
return (h >>> 0) % 100
}
function isEnabled(flag: FlagName, ctx: { userId: string; cohort?: string }): boolean {
const rule = rules[flag]
if (ctx.cohort && rule.allow.includes(ctx.cohort)) return true
return bucket(flag, ctx.userId) < rule.rollout
}
分桶必须确定性:用 Math.random() 会让同一用户在刷新后一会儿看到新界面一会儿看到旧的,既毁体验也让指标失真。用 flag:userId 做哈希键还能保证「加了新开关不影响老开关的分桶结果」。
开关最大的风险是只增不减。半年后没人知道哪个开关还有用,代码里全是 if (flag) 分支。纪律是:开关上线时就登记一个清理日期,全量放量后一个迭代内必须删掉分支。管理平台的形态见 功能开关实践
与 功能开关与发布
。
流量切分:在网关或编排层做
功能开关控制的是「代码里的分支」,流量切分控制的是「请求发给哪个版本」。最简单的是在网关按权重分发:
upstream api {
server api-v1:3000 weight=95;
server api-v2:3000 weight=5;
}
编排层的做法是部署两个 Deployment,用 Service 的标签选择器控制哪一组接收流量。相比之下,网关按权重切分的粒度更细(可以按 Header、Cookie、用户 ID 分流),而 Deployment 级别的切换更粗但更简单。基于 GitOps 的渐进式交付见 GitOps 与 Argo CD 与 CI/CD 与 GitOps 实践 。
灰度期:必须同时兼容两版
这是最容易被低估的一段。灰度期间,新旧两版代码同时在跑,它们共享数据库、缓存、消息队列和对外 API。任何一处不兼容都会在流量切到某个比例时突然爆发。
| 共享资源 | 兼容要求 | 破坏方式 |
|---|---|---|
| 数据库 | 新结构对旧代码可用 | 删了旧代码还在读的列 |
| 缓存 | 键名或值格式带版本 | 新代码写入的结构旧代码解析失败 |
| 消息队列 | 新增字段可选、旧字段不删 | 消费者不认识新消息类型 |
| 对外 API | 只加字段不改语义 | 客户端拿到未知枚举值崩溃 |
第三条尤其隐蔽:队列里的消息是跨版本的,新版本投递的消息会被旧版本消费者读到。所以消息协议只能做加法,且消费端必须对未知字段宽容。契约演进与兼容性的完整讨论见 16.3 契约版本演进与兼容 ,消费端幂等见 9.2 重试、幂等与死信 。
缓存那条同样常见:键里不带版本号,新代码写进去的值旧代码读出来类型对不上。给键加版本前缀(v2:user:{id})是最便宜的解法,做法见 8.1 缓存层次与键设计
。
灰度判定:看什么指标决定是否继续放量
灰度不是「等十分钟没炸就继续」,而是有明确判据的决策。至少要同时盯三组信号,且对照组是旧版本:
| 指标 | 采集方式 | 放量阈值示例 |
|---|---|---|
| 错误率 | 新版本 5xx / 总请求 | 相对旧版增幅 < 10% |
| 延迟 | p95 / p99 | 相对旧版增幅 < 20% |
| 业务指标 | 下单转化、支付成功率 | 不低于旧版 |
| 资源 | CPU、内存、连接池占用 | 不高于旧版的 1.3 倍 |
| 日志 | 新增错误类型计数 | 无新增 ERROR 模式 |
第三条是很多团队漏掉的:技术指标全绿但转化率掉了 5%,这依然是失败的发布。三组信号都要接进同一块看板,指标与告警的搭建见 17.2 指标与告警 。
放量门禁:把判据写成脚本
「等十分钟没炸就继续」不是判据。把上面那张表写成代码,接进流水线做自动晋级或自动回退:
interface Window {
requests: number
errors: number
p95: number
}
interface GateResult {
pass: boolean
reason: string
}
function compare(baseline: Window, canary: Window): GateResult {
if (canary.requests < 1000) {
return { pass: false, reason: '样本量不足,继续观察' }
}
const errBase = baseline.errors / baseline.requests
const errCanary = canary.errors / canary.requests
if (errCanary - errBase > 0.005) {
return { pass: false, reason: `错误率上升 ${((errCanary - errBase) * 100).toFixed(2)}pp` }
}
if (errCanary > errBase * 1.1) {
return { pass: false, reason: `错误率相对增幅 ${((errCanary / errBase - 1) * 100).toFixed(1)}%` }
}
if (canary.p95 > baseline.p95 * 1.2) {
return { pass: false, reason: `p95 劣化 ${((canary.p95 / baseline.p95 - 1) * 100).toFixed(1)}%` }
}
return { pass: true, reason: 'ok' }
}
三个设计要点:
- 双阈值(绝对差 + 相对增幅):低错误率基线(0.01%)下相对增幅轻易就超过 10%,但实际只多了几个请求,绝对差能挡住这类噪音;反过来基线已经 5% 时,相对增幅更灵敏。两条取「任一触发即失败」。
- 样本量下限:金丝雀只有几十个请求时任何比较都不显著,
requests < 1000一律不判定。 - 自动回退优先于自动晋级:判定失败就直接切回旧版本,而不是「发条告警通知人工来看」。人在深夜不一定在。
常见坑
ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block:ORM 的migrate默认把迁移包在事务里。必须把这类语句拆成单独迁移并显式跳过事务包装。ERROR: column "x" does not exist:代码先于迁移上线。回看本节铁律第一条——顺序反了。- 回填把 WAL 打满:单条
UPDATE更新全表,事务日志体积等于整表大小。分批是唯一解。 P2022/P2021(列或表不存在):Prisma Client 是旧版而数据库是新版(或反之)。生产环境的 Client 必须与迁移一起晋级。- 开关全量后忘了清理:半年后没人敢删,代码里全是死分支。登记清理日期。
- 只按实例数灰度不看用户:滚动发布下,同一个用户可能这一秒落在新版本、下一秒落在旧版本。要按用户粘性分流,必须用功能开关而不是网关权重。
- 迁移用生产库直接试:迁移必须在生产数据的副本上验证过耗时。一条在 10 万行表上 200ms 的 DDL,在 5000 万行表上可能是 40 分钟。
小结
这一节围绕「代码可回滚、数据库不可回滚」这一个不对称性展开:
- 迁移必须向前兼容,顺序是先迁移后发布;expand-contract 的扩展、回填、切换三步可回滚,收缩一步不可逆,必须单独发布并隔一个观测周期。
- 破坏性变更拆成兼容步骤:重命名走「加列—回填—双写—读新—删旧」,加约束走「
NOT VALID再VALIDATE」,加索引用CONCURRENTLY且必须出事务。 - 回填用主键游标分批 + 批间休眠,不用
OFFSET,每批独立提交,脚本可重入。 - 迁移是独立的流水线步骤,生产只跑
migrate deploy,已应用的迁移文件禁止修改。 - 四种灰度形态各有所长,选择依据是「出问题要多快止损」;功能开关能做到毫秒级回滚,但必须登记清理日期。
- 灰度期新旧两版共享数据库、缓存、队列与 API,四处都要向前兼容;判定放量要看错误率、延迟与业务指标三组信号。
现在新版本已经能小比例见用户了。但「看起来正常」不等于「真的正常」——下一节讲发布后的验证怎么做实,以及当验证失败时,回滚到底该回滚什么。
阅读导航:上一节:18.1 Docker 与 CI/CD 流水线 · 下一节:18.3 发布后验证与回滚 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。