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、配置是否也回滚了。再查状态类问题:缓存里是否有新格式数据、消息是否重放、数据库是否已被写入不兼容数据。最后才怀疑“旧版本本身也有问题”。