《TypeScript编程实战》18.2 数据库迁移与灰度发布

本节讲清「代码能随时回滚、数据库不能」这个不对称性带来的全部约束:先用 expand-contract 把破坏性结构变更拆成向前兼容的多步,再给出大表回填的分批限速写法与迁移在流水线中的正确位置,最后系统对比蓝绿、金丝雀、滚动与功能开关四种灰度形态,并给出灰度期的指标判定与契约兼容要求。读完后你能设计一条可灰度、可回滚的发布路径。

本节目标:讲清「代码能随时回滚、数据库不能」这个不对称性带来的全部约束。读完后你能用 expand-contract 把破坏性结构变更拆成向前兼容的多步,能写出不锁表、不炸 WAL 的大表回填脚本,能说清蓝绿、金丝雀、滚动、功能开关四种灰度各自适合什么场景,并知道灰度期必须同时兼容新旧两版代码。

18.2 数据库迁移与灰度发布

上一节我们把代码变成了不可变镜像,随时可以用旧 digest 覆盖新 digest。但线上还有一份有状态、且新旧版本共享的东西——数据库。代码回滚只要几十秒,表结构回滚可能要恢复一整份备份。这一节要处理的,正是这个不对称性带来的全部麻烦。

为什么迁移比代码发布难

先把这个不对称性说透,后面的所有规则都是它的推论:

维度代码变更结构变更
可逆性换镜像即可删列后数据不可恢复
生效范围单实例,随发布推进全库共享,立即对所有版本生效
灰度能力可按流量比例无法只对 5% 用户改表结构
回滚成本秒级分钟到小时,可能需要备份恢复

第二行是核心:迁移是一次性的、全局的,而代码是渐进的、局部的。灰度期间新旧两个版本同时读写同一张表,所以迁移产生的任何结构状态,都必须能被两个版本同时正确使用。

铁律:先迁移、后发布,且向前兼容

由此得到两条不可协商的规则:

  1. 顺序必须是「先迁移,再部署新代码」。反过来做,新代码在迁移完成前会访问不存在的列。
  2. 每一次迁移都必须向前兼容,即旧代码在新结构下仍然能正常工作。兼容窗口的长度 = 灰度时长 + 回滚窗口 + 一个安全余量。

这两条合起来就是 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 INDEXCREATE 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 发布后验证与回滚 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes