《TypeScript编程实战》18.3 发布后验证与回滚

本节把「部署完成」之后的那段时间当成发布本身的一部分:先给出冒烟、合成监控、真实用户指标、业务指标四层验证的具体做法,讲清指标为什么必须带版本标签才能对比新旧;再把回滚拆成镜像、流量、开关、配置、数据五类对象,给出各自的耗时与代价,并说明数据库不可回滚时该向前修复还是恢复备份;最后落到自动回退门禁与无责复盘。读完后你能给发布配上一套可验证、可止损的收尾流程。

本节目标:把「部署完成」之后的那段时间当成发布本身的一部分。读完后你能写出跑在流水线里的冒烟测试,能给指标加上版本标签从而对比新旧两版,能把回滚拆成镜像、流量、开关、配置、数据五类对象并说清各自的耗时,能判断数据库不可回滚时该向前修复还是恢复备份,并知道自动回退门禁该配在哪一步。

18.3 发布后验证与回滚

前面两节把新版本构建出来、迁移好数据库、按比例放出了流量。但 deploy 命令返回 0 只说明「对象已经提交给编排系统」,不说明「服务是对的」。真正决定这次发布成败的,是接下来的 30 分钟。

发布失败有三种形态

先分清要防的是什么,否则验证手段会配错:

形态表现被发现的时间典型例子
立刻炸启动失败、探针不过、5xx 飙升秒级依赖漏装、环境变量缺失
慢慢烂内存泄漏、连接泄漏、慢查询累积小时到天缓存没设 TTL、事务没提交
悄悄错接口正常但数据错了天到周时区处理、金额精度、分页漏行

第一种靠探针和冒烟就能拦住;第二种必须靠观测窗口,且窗口要足够长;第三种最贵——它不会触发任何告警,通常是财务或客服先发现的。验证手段必须按这三种形态分别设计,只做冒烟测试等于只防住了第一种。

四层验证:从秒级到天级

层次检查什么运行时机发现哪类问题
冒烟测试关键路径能不能跑通部署后立刻立刻炸
合成监控关键路径持续可用每 1–5 分钟立刻炸 + 部分悄悄错
真实用户指标错误率、延迟按版本切片持续慢慢烂
业务指标转化、支付、数据质量持续悄悄错

四层是递进关系,缺一层就有一类问题没人管。下面逐层落地。

第一层:冒烟测试

冒烟测试的核心是快、只测关键路径、且必须有负向用例。它跑在部署之后、宣布成功之前:

const BASE = process.env.SMOKE_BASE_URL ?? 'http://127.0.0.1:3000'

async function expectOk(path: string, init?: RequestInit): Promise<Response> {
  const res = await fetch(`${BASE}${path}`, init)
  if (!res.ok) throw new Error(`${path} 返回 ${res.status}`)
  return res
}

const checks: Array<[string, () => Promise<unknown>]> = [
  ['healthz 存活', () => expectOk('/healthz')],
  ['readyz 就绪', () => expectOk('/readyz')],
  ['登录可用', async () => {
    const res = await expectOk('/api/login', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ email: 'smoke@example.com', password: process.env.SMOKE_PASS }),
    })
    const body = (await res.json()) as { token?: string }
    if (!body.token) throw new Error('登录未返回 token')
  }],
  ['鉴权生效', async () => {
    const res = await fetch(`${BASE}/api/me`)
    if (res.status !== 401) throw new Error(`未带 token 应返回 401,实际 ${res.status}`)
  }],
]

async function main() {
  const failed: string[] = []
  for (const [name, run] of checks) {
    try {
      await run()
      console.log(`PASS ${name}`)
    } catch (err) {
      failed.push(name)
      console.error(`FAIL ${name} - ${(err as Error).message}`)
    }
  }
  if (failed.length > 0) {
    console.error(`冒烟失败 ${failed.length} 项`)
    process.exit(1)
  }
  console.log(`冒烟通过 ${checks.length} 项`)
}

void main()

三个细节值得单独说:

  • 鉴权生效 是负向用例,价值高于所有正向用例。正向测试只能证明服务活着,负向测试才能证明安全逻辑没被关掉。一次配置误改把鉴权中间件摘掉,正向用例全部通过,只有它会把发布拦下来。
  • 断言要检查内容而不是只看状态码。/api/login 返回 200 但 body 里没有 token,说明上游依赖坏了但错误被吞掉了。
  • 退出码必须是 1。冒烟脚本最常见的失败模式是「打印了 FAIL 但进程退出码为 0」,流水线照样宣布成功。

第二层:合成监控

冒烟测试只在部署时跑一次,合成监控是把同一批检查按固定频率持续跑。两者的区别在于:冒烟回答「这次发布对不对」,合成监控回答「服务现在还好吗」。

关键路径按重要性排序,通常不超过 5 条:登录、下单、支付回调、核心查询、外部集成。合成监控的流量要走独立的测试账号,且这些账号要能被监控系统识别出来,避免污染真实业务指标。

合成监控抓不到的盲区要说清楚:它只覆盖你写下的路径。用户真实走的路径比这多得多,所以它不能替代第三层。

第三层:真实用户指标必须按版本切片

这一层最常见的错误是指标没有版本维度。部署了新版本,错误率从 0.8% 涨到 0.9%——是新版本造成的,还是流量本身波动?没有版本标签就永远说不清,只能靠时间对齐猜。

最省事的做法是给进程注册全局默认标签:

import { Counter, Histogram, register } from 'prom-client'

register.setDefaultLabels({ version: process.env.APP_VERSION ?? 'dev' })

export const httpRequests = new Counter({
  name: 'http_requests_total',
  help: 'HTTP 请求总数',
  labelNames: ['method', 'route', 'status'],
})

export const httpDuration = new Histogram({
  name: 'http_request_duration_seconds',
  help: 'HTTP 请求耗时',
  labelNames: ['route'],
  buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
})

APP_VERSION 由构建时注入(就是上一节的 commit SHA),于是所有指标自动带上版本。查询时按 version 聚合,就能直接对比:

sum by (version) (rate(http_requests_total{status=~"5.."}[5m]))
  / sum by (version) (rate(http_requests_total[5m]))

这张图是灰度判定的基础。没有它,18.2 数据库迁移与灰度发布 里的放量门禁就没有输入。

三个必须加版本标签的指标类别:错误率(按状态码分)、延迟分位数(p95/p99)、资源占用(内存、连接池活跃数)。链路追踪的版本维度同样重要,做法见 17.1 OpenTelemetry 追踪 。

第四层:业务指标与数据质量

技术指标全绿但业务指标掉了,这依然是失败的发布。要盯的守卫指标按业务不同而不同,但通用的一组是:

类别指标异常含义
转化下单转化率、支付成功率前端或校验逻辑回归
写入量每分钟新增订单 / 事件数消息丢投、任务没跑
队列待处理消息数、死信数消费者崩溃或变慢
数据质量空值率、唯一键冲突数迁移或回填出错
外部集成第三方调用成功率凭据过期、限流

「写入量突然掉一半」是典型的悄悄错:接口全部 200,用户却在抱怨「提交了没反应」。队列与死信的监控见 9.2 重试、幂等与死信 。

回滚:先想清楚回滚什么

「回滚」不是一个动作,而是五个不同的动作。把对象分清楚,才知道出问题该按哪个按钮:

回滚对象手段耗时代价
镜像切回旧 tag / digest1–5 分钟需重新拉起实例
流量网关权重切回旧组秒级需旧组仍在线
功能开关关掉开关毫秒级需代码里已有开关
配置回退配置版本并重载秒级到分钟可能需重启
数据恢复备份 / 反向迁移分钟到小时可能丢数据

前四行都是「换掉代码或配置」,第五行完全不同——它动的是数据。绝大多数发布事故的处置难点都在这一行:代码回滚不等于数据回滚。

三种回滚速度

按可用手段从慢到快排列,慢手段是快手段失效时的兜底:

# 1) 查看发布历史,确认当前与上一版本
kubectl rollout history deployment/api

# 2) 回滚到上一个修订版本
kubectl rollout undo deployment/api

# 3) 回滚到指定修订版本
kubectl rollout undo deployment/api --to-revision=42

# 4) 观察回滚进度,确认新实例全部就绪
kubectl rollout status deployment/api --timeout=120s

rollout undo 是分钟级手段:它要重新调度 Pod、等探针通过、再逐步接流量。如果实例启动慢(比如要加载大模型或预热缓存),这个时间会被显著拉长。

秒级手段是流量切换。前提是旧版本实例仍在运行(蓝绿部署天然满足,滚动部署则需要把上一版多留一组)。毫秒级手段是关功能开关——这也是为什么值得为高风险功能专门加一个开关:它不是优雅的架构,而是最便宜的事故闸门。

一条实战经验:把最快的那个手段当成首选。出事故时先关开关止损,再慢慢查原因;而不是一边看着错误率飙升一边等 rollout undo 跑完。

回滚不是可选项,要演练

「我们有回滚流程」和「回滚真的能用」是两件事。常见的失效原因:上一版镜像被仓库清理策略删了、数据库迁移已经让旧版本不兼容、回滚所需的配置凭据过期、或者根本没人知道该执行哪条命令。

所以回滚必须定期演练,且演练要动真格:在预发环境把线上版本真的切回上一版,记录耗时,验证旧版本在新数据库结构下确实能跑。演练频率建议每月一次,并在每次涉及结构变更的发布前额外做一次。

演练暴露出来的耗时数字要写进发布清单——你要知道止损需要几分钟,这个数字决定了你能承受多高的发布频率。

数据库不可回滚时:向前修复

当事故涉及已经收缩(删列、删表)的结构变更,或者已经写入了错误数据,回滚代码也救不回来。此时的选择:

场景处置理由
结构已收缩,旧代码读不到新列向前修复回滚代码只会让错误更严重
错误数据已写入且影响业务向前修复 + 数据订正脚本备份恢复会丢掉之后的所有正常写入
迁移刚开始,尚无业务写入回滚 + 恢复备份代价最小
数据损坏且订正逻辑不可靠恢复备份宁可丢一段时间数据也不能留着错数据

判断的核心是**「丢失的时间窗」有多大**。备份恢复的代价 = 从备份点到现在的所有写入,这个窗口在白天可能是几分钟的高价值交易,在凌晨可能无所谓。

向前修复的纪律有三条:修复本身走正常的流水线(不要手动改生产)、订正脚本必须先在副本上跑通并估算影响行数、订正前后都要有对账查询。备份与恢复的完整做法见 数据库备份恢复策略 与 备份与灾难恢复 。

自动回退:把门禁接进流水线

人可能不在、可能反应慢,所以关键判定要自动化。流水线里部署、冒烟、回退的顺序应该像这样:

      - name: deploy
        run: |
          kubectl set image deployment/api api=ghcr.io/${{ github.repository }}:${{ github.sha }}
          kubectl rollout status deployment/api --timeout=180s
      - name: smoke
        run: pnpm run smoke
      - name: rollback-on-failure
        if: failure()
        run: |
          kubectl rollout undo deployment/api
          kubectl rollout status deployment/api --timeout=120s

三个要点:

  • rollout status 必须带超时。不带超时的话,一次卡住的发布会让流水线挂到天亮。
  • if: failure() 覆盖前面所有步骤,包括 deploy 本身失败。不要只挂在 smoke 上。
  • 回退动作自己也要有超时和日志。回退失败是最高优先级的告警——此时系统处于「新旧混合」的不可预测状态。

自动回退只处理「立刻炸」这一形态。第二、三种形态(慢慢烂、悄悄错)无法在一次流水线里判定,只能靠灰度期的人工或半自动判定,也就是上一节的放量门禁。

发布节奏与无责复盘

验证与回滚做扎实之后,发布频率就可以提高——因为每次发布的风险成本降低了,而风险成本才是限制发布频率的真正原因,不是技术能力。

衡量发布质量的四个指标(DORA)值得接进看板:

指标含义改善方向
部署频率单位时间上线次数批次变小
变更前置时间提交到上线的耗时流水线提速
变更失败率需要回滚或热修的发布占比验证前移
恢复时长 MTTR从故障到恢复的时间回滚自动化

每一次回滚都值得复盘,但复盘要无责:问「哪个环节的防线漏了」而不是「谁按错了按钮」。一份有效的复盘至少回答四件事——时间线(几点几分发生了什么)、影响面(多少用户、多少请求)、根因(哪个变更引入的)、以及哪一道本应拦住它的防线失效了。最后一项最有价值:事故几乎从不是单一原因,而是「三道防线同时没生效」。事件响应的流程见 事件响应实践 与 告警设计与事件响应 。

常见坑

  • 冒烟脚本退出码为 0:打印了 FAIL 但没 process.exit(1),流水线照样报成功。这是最隐蔽的一类,值得单独写一条断言测试。
  • 只有正向冒烟用例:服务活着但鉴权、限流、CSRF 全被关掉,一样通过。
  • 指标没有版本标签:事故时无法区分「新版本的问题」与「流量波动」,只能盲猜。
  • 上一版镜像被清理:仓库的保留策略删了旧 tag,回滚时拉不到镜像。保留策略至少要覆盖最近 N 个可回滚版本。
  • 回滚后忘了回滚迁移:代码回到旧版但结构已是新版,若新结构对旧代码不兼容,回滚本身就成了第二次事故。这正是上一节强调「向前兼容」的原因。
  • rollout status 不带超时:发布卡住时流水线永久挂起。
  • 自动回退没有告警:静默回退会让团队以为「一切正常」,掩盖了真实的缺陷率。
  • 把回滚当成失败:回滚是设计好的安全机制,用得越多说明防线越有效。真正该担心的是「该回滚时不敢回滚」。

小结

这一节把「部署之后」补成了发布流程的一部分:

  • 发布失败有三种形态——立刻炸、慢慢烂、悄悄错,验证手段必须分别设计,只做冒烟等于只防住了第一种。
  • 四层验证递进:冒烟测关键路径(必须有负向用例)、合成监控持续跑、真实用户指标按版本切片、业务指标守转化与数据质量。
  • 指标必须带版本标签,否则无法区分新旧版本的差异,灰度判定也就失去输入。
  • 回滚是五个不同动作:镜像、流量、开关、配置、数据。前四个换代码,第五个动数据,代价完全不同;最快的开关是首选止损手段。
  • 回滚要演练,且要知道止损需要几分钟——这个数字决定你能承受多高的发布频率。
  • 数据库不可回滚时判断「丢失的时间窗」:窗口小就恢复备份,窗口大就向前修复,且修复本身也要走流水线。
  • 自动回退只覆盖立刻炸,慢慢烂与悄悄错靠灰度期门禁;每次回滚都做无责复盘,重点看哪道防线失效了。

至此,从脚手架到上线、从契约到回滚,这本书的交付链路走完了一整圈。回到最初那个问题——TypeScript 的价值不只是「编译期少写错」,而是把类型这份约束贯穿到构建、部署、迁移与回滚的每一个环节:镜像 tag 是 commit SHA、指标带版本标签、迁移向前兼容、契约向后兼容,本质上都是同一件事——让系统的每一个状态都可标识、可比对、可回退。

阅读导航:上一节:18.2 数据库迁移与灰度发布 · 全书目录:学习路径与章节总览 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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