55.3 回滚设计

一句话

回滚不是“把部署反向执行一遍”,而是让系统回到一个已知可用的状态;代码能退,数据、消息、缓存、外部副作用不一定能退——回滚能力要在发布前设计好,不是出事时祈祷。它和发布策略是一对:怎么上去见 55.1 发布策略,怎么下来是本节。

先破除四个误区

误区 现实
“回滚 = 重新部署上一版” 只退了代码。数据库 schema、缓存、消息、第三方调用留下的副作用还在,旧代码可能读不懂新数据
“能回滚代码就能回滚一切” 代码可逆,状态不可逆。schema 已 contract、消息已发出、缓存已写脏,都退不回去
“出事就回滚,越快越好” 有时 forward-fix 更省时间。判据是“回滚耗时 vs 修复耗时”,不是“回滚听起来更安全”
“回滚不用演练” 没演练过的回滚,会在最需要它的时候暴露权限、脚本、镜像保留、schema 兼容问题

回滚的四个层次

层 回滚动作 耗时 可逆性 前提
流量 权重归零、摘掉新实例 秒级 完全 旧实例还在、连接能被排空(见 47.3 路由与灰度)
功能 关特性开关 秒级 完全 开关有 kill switch 与安全默认值(见 55.2 特性开关)
制品 换回旧 digest 30 秒–5 分钟 完全 旧 digest 仍在、配置兼容(见 54.1 不可变制品)
数据/schema 恢复备份、反向迁移 小时–天 部分或不可逆 有备份、变更走 Expand-Contract(见 55.4 数据库变更管理 (Expand-Contract));旧代码要能读新数据(见 55.5 向后兼容)

🔑 回滚的速度由最慢的那一层决定,不是由最快的。流量 3 秒切回,但数据要恢复 3 小时,那这次事故的回滚时间就是 3 小时。

回滚还是前进修复

情况 选 理由
新版本错误率高、旧版本仍兼容当前数据 回滚 先止损,再排查
问题已定位、修复 + 验证 < 回滚耗时 forward-fix 回滚也要验证,来回折腾更慢
schema 已 contract、旧列已删 forward-fix(或先恢复列) 回滚会让旧代码直接崩
错误数据已写入 先停写,再修复/补偿 盲目回滚会把错误数据留在库里,且旧代码可能继续写坏
依赖/第三方故障 熔断、降级、切备用 回滚自己的版本解决不了别人的故障
安全漏洞/凭证泄露 回滚 + 轮换凭证 漏洞利用可能已留下后门,退版本不够

判断顺序:先问“有没有写到不可逆的状态”,再问“回滚要多久”,最后问“修复要多久”。

回滚时间花在哪

阶段 典型耗时 谁在花时间 怎么压缩
检测 3–10 分钟 告警延迟、阈值、看板(见 50.5 告警(分级 / 症状 vs 原因 / runbook / 降噪)) 症状告警、缩短窗口、自动化异常检测
决策 5–15 分钟 人在判断“回滚还是修” 预写决策树、on-call 有权直接回
执行 30 秒–5 分钟 权限、脚本、审批 一键回滚、预授权、避免临时找权限
验证 10–30 分钟 看指标、验证核心链路 预置回滚验证清单与看板

按上表,一次“顺利”的回滚也要 20–60 分钟,其中检测 + 决策合计 8–25 分钟,是占比最大的一段。所以优化回滚的重点不是“把部署脚本改快 10 秒”,而是“更早发现”和“不用开会就能决定”。

一个能跑的“回滚就绪检查器”

发布前问四个问题:旧制品还在吗?旧代码能读当前 schema 吗?旧配置还在吗?有没有它会不认识的开关?任何一条 FAIL,回滚方案就是假的。

package main

import (
	"fmt"
	"os"
)

// release 描述一个可部署版本。maxSchema 是这个版本能正确读写的最高 schema 版本。
type release struct {
	version       string
	digest        string
	maxSchema     int
	configVersion string
	flags         []string
}

type runtime struct {
	current       release
	schemaVersion int
	retentionDays map[string]int // digest → 还能保留几天
	configs       map[string]bool
	flags         map[string]bool
	windowDays    int
}

type check struct {
	name, result, detail string
}

func readiness(rt runtime, target release) ([]check, bool) {
	var cs []check
	add := func(name, result, detail string) { cs = append(cs, check{name, result, detail}) }

	if days, ok := rt.retentionDays[target.digest]; !ok {
		add("artifact", "FAIL", "目标制品已不存在(无法回滚到它)")
	} else if days < rt.windowDays {
		add("artifact", "FAIL", fmt.Sprintf("剩余保留 %d 天 < 回滚窗口 %d 天", days, rt.windowDays))
	} else {
		add("artifact", "PASS", fmt.Sprintf("保留 %d 天(窗口 %d 天)", days, rt.windowDays))
	}

	if target.maxSchema < rt.schemaVersion {
		add("schema", "FAIL", fmt.Sprintf("%s 最高支持 schema %d,当前 %d(已 contract,旧列已删)",
			target.version, target.maxSchema, rt.schemaVersion))
	} else {
		add("schema", "PASS", fmt.Sprintf("%s 支持 schema %d,当前 %d", target.version, target.maxSchema, rt.schemaVersion))
	}

	if rt.configs[target.configVersion] {
		add("config", "PASS", target.configVersion+" 存在")
	} else {
		add("config", "FAIL", target.configVersion+" 已被清理")
	}

	var unknown []string
	for _, f := range target.flags {
		if !rt.flags[f] {
			unknown = append(unknown, f)
		}
	}
	if len(unknown) > 0 {
		add("flags", "WARN", fmt.Sprintf("目标版本不识别:%v(旧开关会残留)", unknown))
	} else {
		add("flags", "PASS", "无未知开关")
	}

	goOn := true
	for _, c := range cs {
		if c.result == "FAIL" {
			goOn = false
		}
	}
	return cs, goOn
}

func run(title string, rt runtime, target release) bool {
	fmt.Println(title)
	fmt.Printf("%-10s %-6s %s\n", "CHECK", "RESULT", "DETAIL")
	cs, ok := readiness(rt, target)
	for _, c := range cs {
		fmt.Printf("%-10s %-6s %s\n", c.name, c.result, c.detail)
	}
	if ok {
		fmt.Println("verdict    GO     可以回滚;回滚后 10 分钟内验证核心指标")
	} else {
		fmt.Println("verdict    NO-GO  回滚会失败或制造新故障;改走 forward-fix,或先恢复被删的结构")
	}
	fmt.Println()
	return ok
}

func main() {
	rt := runtime{
		current:       release{"v1.3.0", "sha256:aaa", 42, "cfg-42", []string{"checkout.v2"}},
		schemaVersion: 42,
		retentionDays: map[string]int{"sha256:aaa": 60, "sha256:bbb": 45, "sha256:ccc": 5, "sha256:ddd": 45},
		configs:       map[string]bool{"cfg-42": true, "cfg-41": true, "cfg-old": false},
		flags:         map[string]bool{"checkout.v2": true, "new-search": false},
		windowDays:    30,
	}

	// A:回滚到 contract 之前的版本——schema 已经删了旧列,旧代码读不了
	a := release{"v1.2.0", "sha256:bbb", 41, "cfg-41", nil}
	// B:回滚到同 schema 的 hotfix——结构没变,可以回
	b := release{"v1.3.1", "sha256:ddd", 42, "cfg-42", []string{"checkout.v2", "dark-mode"}}

	okA := run("回滚就绪检查:prod,当前 v1.3.0 → 目标 v1.2.0(跨 contract)", rt, a)
	okB := run("回滚就绪检查:prod,当前 v1.3.0 → 目标 v1.3.1(同 schema)", rt, b)

	fmt.Printf("A 可回滚=%v,B 可回滚=%v\n", okA, okB)
	if !okA || !okB {
		os.Exit(1)
	}
}

本机运行结果(退出码 1,因为 A 是 NO-GO):

回滚就绪检查:prod,当前 v1.3.0 → 目标 v1.2.0(跨 contract)
CHECK      RESULT DETAIL
artifact   PASS   保留 45 天(窗口 30 天)
schema     FAIL   v1.2.0 最高支持 schema 41,当前 42(已 contract,旧列已删)
config     PASS   cfg-41 存在
flags      PASS   无未知开关
verdict    NO-GO  回滚会失败或制造新故障;改走 forward-fix,或先恢复被删的结构

回滚就绪检查:prod,当前 v1.3.0 → 目标 v1.3.1(同 schema)
CHECK      RESULT DETAIL
artifact   PASS   保留 45 天(窗口 30 天)
schema     PASS   v1.3.1 支持 schema 42,当前 42
config     PASS   cfg-42 存在
flags      WARN   目标版本不识别:[dark-mode](旧开关会残留)
verdict    GO     可以回滚;回滚后 10 分钟内验证核心指标

A 可回滚=false,B 可回滚=true

这张表最有价值的一行是 schema FAIL:制品还在、配置也在,但数据库已经删了旧列,旧版本回去就是 500。回滚方案不能只写“保留最近 3 个镜像”,还要写“schema 的哪个阶段之后不可回”。

回滚窗口:每种资源都要留够

资源 保留多久 依据
制品 digest ≥ 最大回滚窗口(如 30 天) 见 54.1 不可变制品 的保留策略
配置版本 ≥ 30 天,且能一键回滚 配置中心版本化(48.1 配置中心)
数据库备份 按 RPO/RTO 定,至少覆盖回滚窗口 见 36.2 备份策略;备份没演练过等于没有
schema 的 expand 阶段 至少留到“旧代码完全下线” contract 之后再回滚,代价是恢复列 + 回填
特性开关 kill switch 长期保留,release 开关全量后两周删 见 55.2 特性开关

回滚演练:不演练就等于没有

演练不是“把发布再跑一遍”,而是在受控条件下验证回滚路径:

项 要求
频率 每季度至少一次;重大发布前单独演练
范围 随机抽一个服务,从“新版本在线”回滚到“上一个 digest”
记录 检测时间、决策时间、执行时间、验证时间,四段分别记
验证 核心接口成功率、P99、错误日志、数据一致性抽查
失败处理 演练失败就是缺陷,进 backlog,不允许“下次注意”

经验值(不是精确统计):首次演练最常见的三个失败原因是权限不足(on-call 没有回滚权限)、脚本过期(回滚脚本引用了已删除的配置)、schema 不兼容(回滚后旧代码读不了新列)。这三个都不是“技术难题”,是没演练。

回滚之后要清理什么

对象 要做什么 不做的后果
缓存 清掉新版本写入的缓存键,或等 TTL 旧代码读到新格式缓存,继续报错
消息 检查重复消费、死信、延迟消息 回滚后消息重放,重复扣款/重复发货
流量 确认灰度为 0、连接已排空 旧连接仍打新实例,回滚“看起来没生效”
开关 关掉临时打开的开关 下次发布时行为与预期不符
数据 确认错误写入已停止,必要时补偿 回滚只停了代码,脏数据还在扩散

生产落地:指名 → 可抄 → 可验 → 可退

指名:K8s 原生用 kubectl rollout undo(保留 revisionHistoryLimit 个 ReplicaSet);渐进式发布用 Argo Rollouts(支持 abort/undo 与分析式回滚);GitOps 用 Argo CD 的 history rollback 或 git revert;功能级用开关平台。数据库回滚不是“执行 down 脚本”,见 55.4 数据库变更管理 (Expand-Contract)。

可抄(本机无集群,以下命令未验证):

# 1) 看历史,确认要回到哪一版
kubectl -n prod rollout history deploy/order

# 2) 回滚到上一个 revision(或指定 revision)
kubectl -n prod rollout undo deploy/order
kubectl -n prod rollout undo deploy/order --to-revision=3

# 3) 等收敛,别只看命令返回
kubectl -n prod rollout status deploy/order --timeout=180s

# 4) Argo Rollouts:中止并回滚
kubectl argo rollouts abort order -n prod
kubectl argo rollouts undo order -n prod

可验:本节检查器真跑过,输出如上;把它接进发布流水线,NO-GO 时不允许发布(或必须人工签字)。线上演练至少验证四件事:回滚命令有权限执行、回滚后 10 分钟内核心指标恢复、旧 digest 能拉到、回滚后没有因缓存/消息产生二次故障。

可退:回滚本身也可能失败,事故响应与复盘流程见 56.5 事故响应、82.4 发布事故(待补)。预案要写“回滚失败怎么办”:① 保留现场(不要连续操作);② 切到备用集群/只读降级;③ 用 forward-fix 发一个最小修复;④ 数据类问题先停写再恢复。回滚不是终点,恢复服务才是。

反模式

反模式 后果
只准备“上一版镜像”,不检查 schema 兼容 回滚后旧代码读新表,直接 500
回滚脚本需要临时申请权限 事故中卡在权限上,MTTR 翻倍
用 down 迁移脚本做数据回滚 down 往往删列/删数据,回滚变成二次事故
回滚后不清缓存/不排空连接 指标不恢复,误判“回滚无效”
没有回滚验证清单 回滚后不知道看什么,靠感觉判断
所有事故都回滚 已定位的小问题 forward-fix 更省时间,来回折腾扩大窗口
回滚窗口只有 7 天 一周后的问题只能带病修
回滚不演练 第一次使用就是真事故

自检

Q1 为什么“回滚 = 重新部署上一版”是错的?

▸ 答案:部署只改变“跑哪份代码”,不改变系统状态。数据库 schema 可能已经改了、缓存里是新格式、消息已经发出、第三方已经产生副作用。旧代码回到一个它不认识的状态,可能比新版本故障更严重。回滚要按层设计:流量、功能、制品、数据,每层的可逆性不同。

Q2 什么情况下应该 forward-fix 而不是回滚?

▸ 答案:三种典型:① 问题已定位且修复+验证的耗时短于回滚;② schema 已 contract,回滚会让旧代码崩溃;③ 错误数据已写入,回滚不能撤销数据,必须先停写再补偿。判据是“回滚耗时 vs 修复耗时 + 风险”,不是“回滚听起来更安全”。

Q3 回滚就绪检查里,为什么 schema 检查比制品检查更关键?

▸ 答案:制品保留是“有没有”的问题,容易发现也容易补;schema 兼容是“能不能读”的问题,一旦 contract 删了旧列,旧代码必然读不了,而且恢复列要回填、要时间。制品缺失可以重新构建/找回,schema 的不兼容只能在发布前避免。

Q4 一次回滚演练应该记录哪些时间?为什么?

▸ 答案:检测、决策、执行、验证四段分别记。因为它们对应不同的改进手段:检测慢 → 改告警;决策慢 → 预写决策树、授权 on-call;执行慢 → 一键脚本、预授权;验证慢 → 预置看板与清单。只记总时间,无法知道该改哪一段。

Q5 回滚后指标没有恢复,最先查什么?

▸ 答案:先确认“回滚是否真的生效”:流量是否还有打到新实例(连接未排空、灰度权重未归零)、Pod 是否真的换成了旧 digest、配置是否也回滚了。再查状态类问题:缓存里是否有新格式数据、消息是否重放、数据库是否已被写入不兼容数据。最后才怀疑“旧版本本身也有问题”。

关联