变更安全体系:灰度判别指标、自动回滚与错误预算门禁 原创
本文是 SRE 实战系列第 19 篇。前 18 篇一直在讲"坏掉之后怎么办":怎么发现、怎么被告警、怎么被处理、怎么被记录。这一篇换个方向,讲在它坏掉之前,能不能拦住。
全文约 1.4 万字(含可直接抄的配置和脚本)。从一次"回滚成功但故障没停"的发布开头,拆出变更安全的五个闸门:分级、灰度、判别、回滚、门禁。中间给出能直接跑的 Argo Rollouts 配置、Prometheus 判别查询、错误预算门禁脚本,以及一套数据库与代码的兼容顺序(expand → migrate → contract)。最后是一份可勾选的发布自检清单和四阶段落地路线。
这一篇的立场:发布不是"把代码推上去",是把一个假设推到真实流量里做实验。变更安全就是实验设计。
一、一次"回滚成功"的故障
一个典型的样本:某电商平台在促销前一周做了次发布,改动看起来非常小。
改动只有三行:订单服务在创建订单后,多写一个 channel_code 字段给数据团队做归因分析。代码评审 20 分钟通过,灰度 5% 跑了 12 分钟,错误率曲线平得像一条尺子。全量。
21:40,支付成功率从 99.94% 掉到 91%。
值班同学动作很标准:看曲线、确认影响、5 分钟内 argocd app rollback。21:46,回滚完成,Pod 全部是新版本。
故障没有停止。
21:52 还是 91%,22:10 还是 91%。曲线没有任何变化。值班同学开始怀疑回滚没生效,查了三次镜像 tag、六次副本状态,全是对的。
真正的根因在 22:35 才被找到:那个 channel_code 字段写进了下游对账系统的 MySQL 表,而对账系统的表结构没有这个字段——线上写的是 INSERT ... (a, b, channel_code),MySQL 直接报 Unknown column。回滚订单服务的代码有用吗?没用。因为订单服务此刻的流量已经不是旧代码在跑了——同时上线的还有对账服务的新版本,它读的字段顺序变了。
一场 55 分钟的 P1,来自一次三行的改动。复盘记录里被反复提到的四句话,恰好对应变更安全的四个失效点:
- "5% 灰度跑 12 分钟没问题就全量了" ——决策依据是"看着没问题",没有判别指标,也没有统计显著性
- "回滚了呀" ——回滚只回滚了应用代码,数据库的写入已经发生了,回滚不是撤销
- "同一批上线两个服务很正常" ——两个服务之间是隐式契约(表结构 + 字段顺序),没有任何一处检查它
- "那天错误预算还有 60%" ——预算和发布完全没关系,发布系统读不到它
这四个点,对应本文五道闸门里的四道。第五道是"分级"——反过来问:凭什么有些改动要走完整灰度,而另一些被当成"小改动"直接发?
二、先算一笔账:变更到底是不是头号故障源
这是全文唯一一段"理论",但必须讲,因为不讲清这笔账,后面所有的机制都会在业务压力下被砍掉。
2.1 一个不需要争论的数字
Google SRE 多年的公开复盘给出过一个反复出现的比例:生产故障中有 70% 左右由变更引入。这个数字在不同公司之间会浮动,但公开材料里很少低于 50%。对多数团队来说,把自己最近一年的 P1/P2 复盘按变更 / 非变更归因一遍(变更含配置、依赖升级、Schema 变更),得到的量级通常也在同一区间——这一步值得做,因为它决定了后面所有投入的性价比。
于是有了一个推论,这个推论是变更安全体系的全部经济学基础:
如果 70% 的故障来自变更,那么在不改变发布机制的前提下,把可用性从 99.9% 提到 99.95%,靠"多招人、更快的响应"是做不到的。 响应速度压缩的只是 MTTD 和 MTTR,而故障的发生概率没有变。
用第 3 篇的算式写出来更清楚。设单位时间发布次数 N,单次发布引入故障的条件概率 p(取决于变更质量与验证强度),单次故障平均影响时长 D:
停机时间 ≈ N × p × D三个变量,三条路:
| 变量 | 优化手段 | 现实评价 |
|---|---|---|
N(发布次数) | 少发版 | 业务不会同意,且与"快速交付"直接冲突;压不住 |
p(故障概率) | 灰度、判别、评审、测试 | 唯一可以系统性下降的变量,本文的主战场 |
D(单次时长) | 自动回滚、告警速度 | 第 6、13 篇已经压过一轮;收益大但天花板明显 |
所以变更安全 = 把 p 从 0.1 降到 0.01。降一个数量级,比任何一次"提升响应速度"的努力都值。
2.2 灰度为什么能降 p:一个最小够用的概率模型
很多人对灰度的理解是"先放一点点流量,看看有没有报错"。这不算错,但它解释不了两个现象:为什么 1% 灰度没抓到问题,以及为什么 30% 灰度能抓到。
做个粗略但够用的建模。假设一次发布引入的缺陷只影响一部分请求(比如只在某个租户、某种参数组合、某个机型上触发),设受影响的请求占比为 r(就是第 21 篇会讲的缺陷流量占比)。在灰度 s 的流量下,跑时间 t,期间总请求量为 Q,那么灰度阶段能观察到缺陷请求的期望次数是:
E[缺陷样本] = Q × s × r这就是全部秘密。按四类典型缺陷算一遍量级:
| 场景 | 灰度流量 s | 缺陷流量占比 r | 灰度 10 分钟请求量 Q | 期望缺陷样本 | 结论 |
|---|---|---|---|---|---|
| 10% 用户支付失败 | 5% | 0.10 | 50 万 | 2500 | 一定抓得到 |
| 单一渠道用户超时 | 5% | 0.01 | 50 万 | 250 | 抓得到 |
| 某个 iOS 版本崩溃 | 5% | 0.001 | 50 万 | 25 | 勉强,需要看错误率而非绝对数 |
| 只用某个冷门参数组合才崩 | 5% | 0.00001 | 50 万 | 0.25 | 抓不到,再多跑两天也抓不到 |
第四行是最容易误解的一行:灰度不是万能的,它只能抓"有一定流量占比"的缺陷。 对极低占比的缺陷,灰度的作用是"缩小爆炸半径"而不是"提前发现"——也就是 100% 用户受影响变成 5% 用户受影响,D 变小了,但 p 没变。
这个模型给实践带来三个直接结论:
- 灰度比例要按"期望缺陷样本数"定,不是按百分比习惯定。 低流量服务需要更高的灰度比例,或者更长的观察时间,才能凑够样本
- 判别指标要用比率,不要用计数。 25 个错误在 50 万请求里,绝对数看不出来,比率能看出来
- 对"缺陷流量占比极低"的那类风险,灰度帮不上忙,要靠后面的"演练"和"韧性"兜。 这是第 20 篇的主题
2.3 顺带说清一件事:灰度不等于金丝雀,也不等于蓝绿
三者在中文语境里常被混用,它们解决的问题不一样:
| 机制 | 流量切换方式 | 回滚代价 | 适合 | 不适合 |
|---|---|---|---|---|
| 蓝绿 | 整组切换,一次 100% | 切回去,秒级 | 强依赖前端资源一致性、必须整体一致的场景 | 资源翻倍;切完才知道好坏,等于零灰度 |
| 金丝雀(本文的"灰度") | 按比例逐步放量 | 把权重调回 0,10 秒级 | 绝大多数无状态服务 | 有状态服务、需要严格顺序的场景 |
| 功能开关(Feature Flag) | 不切流量,切代码路径 | 关开关,秒级 | 长时间、按用户/租户维度放量 | 不隔离:同一份代码里所有路径都在跑 |
最后一行是重点。功能开关不是灰度:开关打开的那一刻,所有实例上的新路径同时开始执行,它省掉的是"部署",不是"爆炸半径"。这两者要并用:用灰度控制"新代码有多少实例在跑",用开关控制"新代码里有多少路径被走到"。
三、闸门一:变更分级——不是所有变更都配一样的保险
先解决"凭什么有些改动走完整流程"这个问题。答案是:按不可逆性和影响面分级,不按代码行数分级。
3.1 四级变更
| 级别 | 判定 | 例子 | 要求 |
|---|---|---|---|
| C1 低 | 只改静态内容、日志、注释;或纯内部 dev 环境 | 改文案、加日志字段、dev 环境调参 | 自动发布,无门禁 |
| C2 中 | 只改单个无状态服务的代码,无 Schema 变更,无依赖升级 | 修 bug、加接口字段(向后兼容) | 灰度 + 判别指标 + 自动回滚 |
| C3 高 | 涉及 Schema、依赖大版本、跨服务契约、资源规格、Ingress/网关规则 | 加字段、升级 K8s 大版本、改超时值、改副本数下限 | C2 全部 + 双人评审 + 分阶段 + 业务低峰窗口 |
| C4 极高 | 不可回滚的操作 | 数据删除、不可逆迁移、证书替换、切主、下线服务 | C3 全部 + 演练过一次 + 明确的中止条件 + 至少两人在场 |
这张表最重要的不是前三行,是第四行。绝大多数严重事故的共同特征不是"回滚慢了",而是根本没法回滚。
三类真实的不可逆事故,形状各不相同:
- 一条
UPDATE orders SET status='paid'的修数据脚本写错了WHERE条件 → 12 万单被改成已支付(不可逆) - 一次 Redis 集群扩容用错了 slot 迁移方式 → 缓存全量失效,回滚后依然雪崩(回滚了但没用)
- 证书替换时新旧 CA 的 trust store 没同时装 → 客户端连不上,而证书是有有效期的,不能"切回去用旧的"(回滚窗口已过)
所以对 C4 有一句硬话:
凡是"不能回滚"的操作,必须被当成事故预案来对待,而不是当成一次发布。 具体说:写预案、算爆炸半径、在预发环境用真实数据量演练一次、定明确的中止阈值("影响超 100 单立即停")。
3.2 分级怎么自动化:从改动里读出来
不要靠人填表。级别可以从 diff 和部署清单里推出来,实际落地中大部分变更(经验值 80% 左右)都能自动判定,剩下的是"脚本说不清、需要人看一眼"的:
#!/usr/bin/env bash
# classify-change.sh —— 从 git diff + 清单文件推断变更级别
# 输出 C1 / C2 / C3 / C4,写入 CI 变量 CHANGE_CLASS
set -euo pipefail
BASE="${BASE_REF:-origin/main}"
FILES=$(git diff --name-only "$BASE"...HEAD)
LEVEL=C2 # 默认中,宁可错杀
up() { # up 3 → 把级别抬到 C3(只升不降)
[ "${LEVEL#C}" -lt "$1" ] && LEVEL="C$1" || true
}
# Schema / 迁移脚本 → C3(不可逆的删除类 → C4)
if echo "$FILES" | grep -qE '(^|/)migrations?/.*\.sql$'; then
up 3
if echo "$FILES" | grep -qiE 'drop |truncate |delete from|alter column .* not null'; then
up 4
fi
fi
# 依赖大版本、基础设施清单、网关规则 → C3
echo "$FILES" | grep -qE '(go\.mod|package\.json|requirements\.txt)$' && up 3
echo "$FILES" | grep -qE '(deploy/|helm/|kustomize/|\.yaml$)' && up 3
# 纯文档、纯注释、测试文件 → C1
if ! echo "$FILES" | grep -vqE '(\.md$|_test\.go$|\.spec\.ts$|^docs/)'; then
LEVEL=C1
fi
# 不可逆动作黑名单(关键词 + 危险目录)
echo "$FILES" | grep -qiE '(purge|drop-|rotate-ca|failover|promote-primary)' && up 4
echo "CHANGE_CLASS=${LEVEL}"分级结果要进两个地方:PR 的标签(评审人一眼看到要不要双人签字)和发布流水线的分支(决定走哪条发布通道)。级别不进流水线,就只是一张贴在墙上的纸。
四、闸门二:灰度拓扑——先决定"按什么切流量"
这是最容易做错的地方。默认动作通常是往 ingress 上挂一个权重,但在 ingress 上做权重的灰度,只能保护 ingress 之后的链路;在服务网格里做权重的灰度,才能保护到调用方。
4.1 四种流量切分位置
① DNS / 全局负载 client ─→ [DNS 权重] ─→ 两个集群
② 网关 / Ingress client ─→ [Gateway 权重] ─→ 两个 Service
③ 服务网格 / Sidecar caller ─→ [VirtualService 权重] ─→ 两个 subset
④ 应用内开关 caller ─→ 同一 Pod ─→ [代码里的 flag]| 位置 | 能覆盖的场景 | 关键局限 |
|---|---|---|
| ① DNS / 全局 | 多集群、异地发布的兜底 | 受 TTL 和客户端 DNS 缓存影响,实际比例严重失真("配了 5%,实际 40%"多半是这个原因) |
| ② 网关 | 南北向流量(外部用户进服务) | 看不见东西向:A 服务调 B 服务的流量完全不受控 |
| ③ 网格 | 南北 + 东西向全覆盖,可按 header/租户切 | 需要 mesh;sidecar 有资源与延迟成本;不自带业务判别 |
| ④ 应用内 | 按用户 ID / 租户 / 参数切,粒度最细 | 不隔离故障域(同一个 Pod 里两条路径都跑) |
选型边界,一句话:
- 单体应用、只有一层网关 → ②够用,别再上 mesh
- 微服务、核心链路跨 3 层以上调用 → 必须 ③,否则入口灰度、内部下游早就全量了
- 需要对特定租户/内部员工先放量 → ③ + ④ 组合,别只靠 ④
4.2 用 Argo Rollouts 做一层可控灰度
下面这份配置解决四个实际问题:比例放量、按 header 预发、自动判别、失败自动回滚。可以整段抄。
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: order-api
spec:
replicas: 20
revisionHistoryLimit: 5
selector:
matchLabels: { app: order-api }
strategy:
canary:
# 1) 稳定版 Service 与灰度 Service 分离:两者都可独立寻址
stableService: order-api-stable
canaryService: order-api-canary
trafficRouting:
nginx:
stableIngress: order-api
dynamicStableScale: true # 灰度期间自动扩 Stable,避免总容量腰斩
# 2) 分步放量:前两档用"等一批真实请求",后面按时间
steps:
- setWeight: 5
- pause: { duration: 5m } # 观察 5m,判别指标用 AnalysisRun
- setWeight: 20
- pause: { duration: 10m }
- setWeight: 50
- pause: { duration: 10m }
- setWeight: 100
analysis:
templates:
- templateName: order-api-success-rate
startingStep: 1 # 5% 那一档就开始判,别等到 50%
args:
- name: service
value: order-api
# 3) 回滚:失败自动,不等人
abortScaleDownDelaySeconds: 30
scaleDownDelaySeconds: 60判别模板是灰度的心脏。判错了,回滚就是空转:
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: order-api-success-rate
spec:
args:
- name: service
metrics:
# 指标一:错误率差值(灰度 vs 稳定),这是主判别
- name: success-rate-delta
interval: 1m
count: 5 # 5 次 × 1m = 5 分钟窗口
successCondition: result <= 0.5 # 灰度比稳定最多多 0.5 个百分点
failureLimit: 2 # 连续 2 次越界就失败(容许一次噪声)
provider:
prometheus:
address: http://prometheus.monitoring:9090
query: |
(
sum(rate(http_requests_total{service="{{args.service}}",version="canary",code=~"5.."}[1m]))
/ clamp_min(sum(rate(http_requests_total{service="{{args.service}}",version="canary"}[1m])), 1)
-
sum(rate(http_requests_total{service="{{args.service}}",version="stable",code=~"5.."}[1m]))
/ clamp_min(sum(rate(http_requests_total{service="{{args.service}}",version="stable"}[1m])), 1)
) * 100
# 指标二:P99 延迟,按倍数比而不是绝对阈值
- name: p99-ratio
interval: 1m
count: 5
successCondition: result <= 1.2
failureLimit: 2
provider:
prometheus:
address: http://prometheus.monitoring:9090
query: |
histogram_quantile(0.99,
sum(rate(http_request_duration_seconds_bucket{service="{{args.service}}",version="canary"}[5m])) by (le)
)
/
histogram_quantile(0.99,
sum(rate(http_request_duration_seconds_bucket{service="{{args.service}}",version="stable"}[5m])) by (le)
)三个细节,是踩了坑才知道的:
- 别用绝对阈值("错误率 > 1% 就回滚")。稳定版本来就有 0.3% 错误,灰度版 0.9% 也不算异常;反过来稳定版 0.05% 时,灰度 0.6% 就该警惕了。用差值或比值。
failureLimit: 2而不是 1。 单点噪声(一次 Prometheus 抖动、一次慢查询)会让灰度无故中止,团队很快就会把自动中止关掉——这比不做判别更糟。clamp_min(..., 1)防除零。 灰度刚起来的那一分钟分母是 0,NaN会让判别永远不成功,卡在 5% 一晚上。
4.3 判别指标怎么选:三层,不要混
这是全文最容易被做成"感觉活儿"的部分。可操作的做法是把指标分三层,各层职责不同,任何一层报警都能中止灰度,但处理方式不一样。
第一层:即时否决指标(Veto,秒级到分钟级)
只要越界就立刻回滚,不辩论。它们是"确定性的坏",不需要统计判断:
# 1) 5xx 比率差值(见上)
# 2) 灰度实例 CrashLoopBackOff / 就绪率
sum(kube_pod_container_status_ready{namespace="prod",pod=~"order-api-.*-canary.*"})
/ count(kube_pod_container_status_ready{namespace="prod",pod=~"order-api-.*-canary.*"}) < 0.9
# 3) 灰度实例 OOMKilled 次数(重启原因)
increase(kube_pod_container_status_last_terminated_reason{reason="OOMKilled",pod=~"order-api-.*"}[5m]) > 0
# 4) 业务黄金指标:下单成功率(绝对值,不看对比)
sum(rate(order_created_total{result="success"}[1m]))
/ sum(rate(order_created_total[1m])) < 0.98第二层:趋势判别指标(Compare,5-15 分钟窗口)
用差值/比值和稳定版比,需要一点统计耐心:P99 延迟、下游错误率、DB 慢查询数、goroutine/连接数是否线性增长。
第三层:健康指标(Guard,全程观察,一般不中止)
CPU/内存、GC 时间、线程池队列长度。它们常常"先坏但还没出事",只能作为回滚决策的加分项而不是触发项——否则 JIT 预热期的 CPU 尖刺会让所有 Java 服务被误回滚。
| 层 | 判据形式 | 典型信号 | 触发动作 |
|---|---|---|---|
| 即时否决 | 绝对阈值 | 就绪率 < 90%、OOM、5xx 差值 > 0.5pp | 立即自动回滚 |
| 趋势判别 | 比值/差值 + 窗口 | P99 × 1.2、下游错误率上升 | 暂停放量,等人工确认 |
| 健康观察 | 无阈值 | 内存曲线斜率、GC 时间 | 记录,进发布报告 |
一句话总结选指标的规矩:
判别指标必须满足两个条件:与这次改动有关,且在灰度流量下能被统计出来。 不满足第一条的是"监控大盘"而不是"判别指标";不满足第二条的(比如依赖极低频请求的指标)在任何灰度比例下都是
NaN。
五、闸门三:回滚——它不是"撤销键"
回到开头那个案例。值班同学做对了一切,唯独对一件事有误解:回滚不等于撤销。
5.1 回滚能撤销什么,不能撤销什么
| 东西 | 回滚能否撤销 | 说明 |
|---|---|---|
| 应用代码(镜像) | ✅ 完美撤销 | 前提是镜像还在、清单还能解析 |
| 配置(ConfigMap/环境变量) | ✅ 撤销(如果一起回滚) | 常被漏:只回滚了镜像,配置还是新的 |
| 已发出的消息/事件 | ❌ 不能 | 消费方已经处理了 |
| 已写入的数据 | ❌ 不能 | 只能"补偿",不能"撤销" |
| Schema 变更 | ⚠️ 技术上能,实践上不能 | 加字段可逆,但回滚后旧代码看不懂新数据 |
| 缓存 / 连接池状态 | ❌ 不能 | 回滚后新实例要重新预热,可能引发二次雪崩 |
| 已下发的客户端版本 | ❌ 不能 | App/前端已缓存旧资源 |
所以回滚的正确心理模型是:
回滚 = 停止继续产生新的坏影响。已经产生的坏影响,需要另外的补偿动作。
这一句话解释了大量"回滚了但故障没停"的现场。回到那个案例:代码回滚停止了"写入错误字段",但对账服务读的是新版本的表结构,那部分影响不会自己消失。
5.2 回滚会失败的四条路径(以及对策)
路径一:回滚后依赖不兼容
新版本写了旧代码看不懂的数据,或者依赖了新配置项。
对策:双向兼容是发布的强制要求,不是建议。 第 8 节展开。
路径二:回滚引发二次雪崩
最典型的:新版本把缓存 key 改了 → 回滚后旧版本读不到缓存 → 缓存击穿 → DB 被打挂。或者回滚的瞬间所有实例同时重建,冷启动叠加。
对策:
# 1) 分批回滚,不要一把切
spec:
strategy:
canary:
abortScaleDownDelaySeconds: 30
# 2) 回滚期间打开限流与降级(熔断器阈值临时收紧 50%)
# 3) 缓存 key 变更必须双写,且保留旧 key 一个发布周期的 TTL路径三:回滚目标不存在
镜像被覆盖、Helm chart 版本被删、Git branch 被 force push、ArgoCD 的 revision 已被 GC。这一条在紧急时刻极其致命,因为它没有第二个选择。
对策:三条硬规定。
# 规定 1:镜像 tag 不可变,禁止 latest / 禁止覆盖已发布 tag
# 规定 2:保留最近 N 个可回滚版本,N 按业务节奏定(参考量级:N=10 覆盖约 3 天的发布)
# Argo CD:revisionHistoryLimit >= N
# Harbor:tag immutable rule
# 规定 3:每次发布自动记录一个"回滚点"清单,回滚时按清单执行第三条规定落地成脚本长这样,发布时自动生成、回滚时照着逐行执行:
#!/usr/bin/env bash
# rollback-point.sh —— 发布前记录回滚点,输出到 deploy/history/<ts>.env
set -euo pipefail
TS=$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p deploy/history
cat > "deploy/history/${TS}.env" <<EOF
APP=order-api
IMAGE=$(kubectl -n prod get deploy order-api -o jsonpath='{.spec.template.spec.containers[0].image}')
CONFIG_HASH=$(kubectl -n prod get cm order-api-config -o jsonpath='{.metadata.resourceVersion}')
SECRET_HASH=$(kubectl -n prod get secret order-api-secret -o jsonpath='{.metadata.resourceVersion}')
FEATURE_FLAGS=$(curl -s "https://ff.internal/api/flags?service=order-api" | jq -c '.flags')
MIGRATION_VERSION=$(psql "$DB_URL" -tAc "select version from schema_migrations order by version desc limit 1")
ROLLED_BY=${USER}
EOF
echo "回滚点已记录:deploy/history/${TS}.env"路径四:没人敢回滚
这是组织问题,但技术上有解:回滚权限必须和发布权限一样低。 只有发布人能回滚的团队,会在半夜等一个正在睡觉的人。一条被验证过的边界是:值班人可以无审批回滚任何 C2/C3 变更;C4 变更才要求发起人在场。
5.3 最难的部分:判断"该回滚 vs 该继续"
灰度期间指标抖动时,回滚是一个不可逆、且决策成本很高的动作。下面这个判定顺序可以照着走,30 秒内出结论:
1. 用户正在受影响吗?(SLI 破了没有)
├─ 没破 → 不回滚,继续观察;记录异常,检查是不是噪声/预热
└─ 破了 ↓
2. 影响是"新版本独有"吗?(对比灰度 vs 稳定,差值为正)
├─ 不是(两边一样坏)→ 不回滚,问题在别处,去排下游/基础设施
└─ 是 ↓
3. 影响在扩大吗?
├─ 在扩大 → 立即回滚,别算成本
└─ 平稳 ↓
4. 有没有更小的止血动作?(限流、降载、开关关掉新路径、扩副本)
├─ 有 → 先做小的,5 分钟看反应
└─ 没有 → 回滚第 4 步经常被跳过。有些场景下回滚的代价比故障本身更大(回滚会触发缓存重建,比错误率高一点点更严重)。这时候正确的动作可能是"把灰度维持在 5% 不再放量",而不是回滚。
六、闸门四:数据库与代码的兼容顺序
这是本文技术含量最高的一节,也是"变更安全"最容易被漏掉的一半。前面那个案例的根本原因就在这里。
6.1 铁律:Schema 与代码必须双向兼容
任何一个时刻,线上都可能同时存在 N-1 版和 N 版代码。Schema 必须让两个版本都能正常工作。
这不是"良好实践",而是分布式发布的事实:只要存在多个副本、只要在做灰度、只要不是停机发布,N-1 和 N 就同时活着。所以:
- 新代码必须能读旧数据(旧数据没有新字段)
- 旧代码必须能读新数据(新数据有新字段,旧代码忽略它)
- 新代码必须能在旧 Schema 上跑(否则部署顺序就锁死了)
6.2 expand → migrate → contract
把一次"删列"或"改类型"的变更拆成三个发布,这是唯一安全的做法:
| 阶段 | 发布内容 | 此时 N-1 代码 | 此时 N 代码 | 什么时候可以进下一步 |
|---|---|---|---|---|
| 1. expand | 加新列(可空 / 有默认值),不动老列;双写开始 | 正常(不写新列) | 正常(双写) | 所有实例都是 N 版,双写稳定 ≥ 24h |
| 2. migrate | 回填历史数据;读切到新列 | 正常(读老列) | 正常(读新列) | 数据一致率 100%,对账脚本通过 |
| 3. contract | 删老列、删双写代码 | —— | 正常 | 需再等一个发布周期 |
关键在第一步和第三步必须分开发布。规范写法是把这三步落成三条独立的迁移文件,禁止合并:
migrations/
├── 20261110_01_expand_add_channel_code.sql # 只 ADD COLUMN,可空
├── 20261117_02_backfill_channel_code.sql # 分批 UPDATE,限速
└── 20261124_03_contract_drop_legacy_channel.sql # DROP COLUMN(C4 级!)6.3 三个必须遵守的细节
① 加列必须可空或带默认值,别加 NOT NULL 无默认
-- ❌ 7.0 之前会锁表 + 回填失败;即使 8.0 也是全表 rewrite
ALTER TABLE orders ADD COLUMN channel_code VARCHAR(32) NOT NULL;
-- ✅ 可空 + 单独回填
ALTER TABLE orders ADD COLUMN channel_code VARCHAR(32) NULL, ALGORITHM=INSTANT;ALGORITHM=INSTANT 是 MySQL 8.0 的关键(加列在大多数情况下 O(1) 元数据操作);MySQL 5.7 只能用 pt-online-schema-change / gh-ost。第 23 篇会专门讲大表 DDL 的窗口和工具选型。
② 回填必须分批、限速、可中断
-- 分批回填,每批 1000 行,批间 sleep;失败可从 last_id 续跑
UPDATE orders SET channel_code = 'legacy'
WHERE id > ? AND id <= ? AND channel_code IS NULL
LIMIT 1000;回填脚本的三条要求:按主键区间切(不要 LIMIT OFFSET,越往后越慢)、可续跑(记录 last_id)、监控主从延迟(超过阈值自动暂停)。主从延迟这个指标怎么定"可容忍",第 23 篇给。
③ 兼容窗口要有明确的到期日
双写代码最危险的状态是"永久双写"。它会让下一批人看不懂为什么这里要写两个字段,然后其中一个人"清理"了它,事故就发生了。
// 双写处必须带到期日与移除条件,这个注释是被 CI 检查的
// TODO(schema-migration): 移除条件 = schema_migrations >= 20261124
// DEADLINE: 2026-12-24
func (r *orderRepo) Create(ctx context.Context, o *Order) error {
return r.tx(ctx, func(tx *sql.Tx) error {
// 新列 + 老列同时写,直到 contract 阶段
_, err := tx.ExecContext(ctx,
`INSERT INTO orders (id, amount, channel_code, channel) VALUES (?,?,?,?)`,
o.ID, o.Amount, o.ChannelCode, o.ChannelCode)
return err
})
}6.4 用 CI 把契约钉住
上面这些写进规范没用,得让违反它的 PR 变红。三条检查,成本很低:
# .github/workflows/schema-guard.yml(节选)
- name: 禁止一次迁移里混合 ADD 与 DROP
run: |
for f in $(git diff --name-only origin/main...HEAD -- migrations/); do
if grep -qiE 'drop (column|table)' "$f" && grep -qiE 'add (column|table)' "$f"; then
echo "::error file=$f::expand 与 contract 不能在同一次迁移里(见变更规范 §6.2)"
exit 1
fi
done
- name: 新增 NOT NULL 列必须带 DEFAULT
run: ./scripts/check-not-null-default.sh
- name: 回滚点必须已记录
run: ./scripts/check-rollback-point.sh第一条检查看着很土,拦住的却是最贵的一类错误:"一次迁移里又加又删"这种组合一旦合并,回滚点就不存在了。这类问题不会因为人更谨慎而消失,只能靠流程把犯错的路径堵上。
七、闸门五:错误预算门禁——让"能不能发"变成一个数字
第 3 篇讲了错误预算怎么算,第 10 篇讲了预算用完之后怎么应急。这里补上缺的那一环:发布系统怎么读到预算,并据此改变行为。
7.1 三个阈值,三种行为
| 剩余预算 | 状态 | 发布策略 | 谁能破例 |
|---|---|---|---|
| > 50% | 🟢 正常 | 按正常节奏发布,C3 也可在工作时间发 | 不需要 |
| 20% ~ 50% | 🟡 谨慎 | 只允许 C2 及以下;C3 需 SRE 复核;禁止在业务高峰发布 | 服务 owner |
| 5% ~ 20% | 🟠 冻结 | 只允许修复性发布(hotfix);新功能一律等预算恢复 | SRE 负责人 |
| < 5% 或已烧穿 | 🔴 熔断 | 所有非修复性发布停止;同时启动第 10 篇的应急处置 | CTO 级 |
关键在于"修复性发布"怎么定义,否则这条规定会被"这次是修复"绕过去。定义要机器可判定:
一次发布是"修复性"的,当且仅当它的 PR 关联了一个 P0/P1/P2 事故工单,且变更范围只涉及该事故涉及的模块。
7.2 门禁脚本:10 秒内给出 allow / deny
#!/usr/bin/env bash
# slo-gate.sh —— 发布门禁:查询服务剩余错误预算,决定是否放行
# 用法:./slo-gate.sh order-api C3
set -euo pipefail
SERVICE="${1:?service required}"
CHANGE_CLASS="${2:-C2}"
PROM="${PROM_URL:-http://prometheus.monitoring:9090}"
# Sloth 生成的指标:剩余错误预算比例(1 = 满,0 = 烧穿)
QUERY="slo:period_error_budget_remaining:ratio{slo=\"${SERVICE}-availability\"}"
BUDGET=$(curl -sf --get "$PROM/api/v1/query" \
--data-urlencode "query=${QUERY}" \
| jq -r '.data.result[0].value[1] // "NaN"')
if [ "$BUDGET" = "NaN" ]; then
echo "❌ 取不到 ${SERVICE} 的错误预算,可能是 SLO 未配置。" >&2
echo " 放行规则:C1 允许;C2+ 拒绝(不允许在无预算观测的情况下发布)" >&2
[ "$CHANGE_CLASS" = "C1" ] && exit 0 || exit 1
fi
PCT=$(awk -v b="$BUDGET" 'BEGIN{printf "%.1f", b*100}')
echo "服务 ${SERVICE} 剩余错误预算:${PCT}%(变更级别 ${CHANGE_CLASS})"
# 判定:级别越高,要求越严
case "$CHANGE_CLASS" in
C1) exit 0 ;;
C2) awk -v p="$PCT" 'BEGIN{exit !(p > 5)}' && exit 0 || { echo "⛔ 预算低于 5%,仅允许修复性发布"; exit 1; } ;;
C3) awk -v p="$PCT" 'BEGIN{exit !(p > 20)}' && exit 0 || { echo "⛔ 预算低于 20%,C3 变更需 SRE 复核"; exit 1; } ;;
C4) awk -v p="$PCT" 'BEGIN{exit !(p > 50)}' && exit 0 || { echo "⛔ 预算低于 50%,禁止不可逆变更"; exit 1; } ;;
*) echo "未知变更级别 $CHANGE_CLASS"; exit 1 ;;
esac接进流水线只需要一行:
# .cnb.yml 片段
- name: 🚦 SLO 发布门禁
script: |
./scripts/classify-change.sh >> "$CNB_ENV_FILE" # 输出 CHANGE_CLASS
source "$CNB_ENV_FILE"
./scripts/slo-gate.sh order-api "$CHANGE_CLASS"7.3 三个必须处理的反面案例
门禁最容易死在三个地方,讲清楚怎么防:
① 预算曲线是锯齿状,别用瞬时值
月初预算是满的,月中一次事故烧掉 40%,月底又"恢复"到 60%(因为窗口滚动)。这意味着同一个服务在月底会突然放行所有发布,而实际上它刚刚不健康。
对策:门禁同时看两个窗口。5 分钟窗口用于"正在恶化"信号,30 天窗口用于"本周期余额":
# 组合判据:30d 余额 > 20% AND 1h 燃烧率 < 2(最近一小时没有在快速烧)
slo:period_error_budget_remaining:ratio{slo="order-api-availability"} > 0.2
and
slo:current_burn_rate:ratio{slo="order-api-availability"} < 2② "无 SLO 的服务"不能默认放行
上线三个月还没定 SLO 的服务,一定存在。如果门禁对它们放行,所有服务的 SLO 都会"被遗忘"。可行的规则是:无 SLO ⇒ 按 C3 处理 ⇒ 需要 SRE 复核。配 SLO 的动机于是从"规范要求"变成"不发版发不出去",覆盖速度会明显加快。
③ 门禁不能有"一键跳过"的权限
一旦有了"紧急跳过"按钮,它每周会被按 5 次,然后这条门禁就死了。正确做法是破例要走事故工单,自动在周报里曝光。压力放在流程上,不放在按钮上。
八、把这些接起来:一次发布的全流程
讲了五个闸门,现在把它们串成一条线。一次 C3 发布从提交到关单,完整形态大致如下(时间取自一次 21:40 的发布):
T-24h ── PR 提交
├─ classify-change.sh → C3(改了 migrations/)
├─ 双人评审(C3 要求)
└─ schema-guard 检查:expand 与 contract 不同发布 ✅
T-12h ── 预发验证
├─ 迁移在真实数据量副本上跑一遍(耗时、锁情况)
├─ 回滚脚本演练一次(C3 必做)
└─ 判别指标在预发能取到数(否则灰度是瞎的)
T-0 ── 发布窗口(业务低峰)
├─ slo-gate.sh → allow(预算 68%)
├─ rollback-point.sh → 记录镜像/配置/flag/迁移版本
├─ 5% 灰度 5m → AnalysisRun 判 success-rate-delta、p99-ratio
├─ 20% 灰度 10m → 同上 + 业务指标(下单成功率)
├─ 50% 灰度 10m → 同上
└─ 100% 全量,观察 30m 后关单
T+30m ── 发布结束,写发布记录
├─ 判别指标全程曲线截图归档
├─ 回滚点保留(N=10 个)
└─ 双写代码登记到期日(DEADLINE: 2026-12-24)每一步都有"卡住就停"的条件。这套流程全跑完,一次 C3 发布约 2 小时——比"一把梭"要慢,但它的有效 MTTR 接近 0,因为问题在 5% 那一档就被拦下了。
九、度量:变更安全做得好不好,看这五个数
做完机制,一定要有指标,否则半年后没人记得为什么这么麻烦。五个数,全部从现有数据源算,不需要新建系统:
| 指标 | 定义 | 目标 | 为什么选它 |
|---|---|---|---|
| 变更失败率(CFR) | 导致回滚或事故的发布数 / 总发布数 | < 15% | DORA 四指标之一,直接反映 p |
| 回滚成功率 | 回滚后 15 分钟内 SLI 恢复的数量 / 回滚总数 | > 90% | 检验"回滚真的能撤销"这个假设 |
| 灰度拦截率 | 在 < 50% 流量时被自动中止的发布数 / 总发布数 | 观察值,不设目标 | 太低说明判别指标不灵敏;太高说明变更质量差 |
| 变质发布时长 | 从首个异常信号到回滚完成的中位数 | < 10 分钟 | 这才是"回滚快"的真实体感 |
| 预算外发布数 | 在预算 < 20% 时发生的 C3+ 发布数 | 0 | 门禁是否真的在起作用 |
灰度拦截率这个指标特别值得说:它不设目标值,因为理想值取决于变更质量本身。但它是极好的"机制健康度"指标:
- 长期 = 0 → 判别指标形同虚设,或自动中止被关掉了
- 突然飙到 40% → 或者是变更质量崩了,或者是有人把阈值调严了却没说
一个可参考的落地量级是 10% 上下——每 9~10 次发布拦下 1 次。这个数字本身说明不了好坏,需要和"被拦下的发布会造成什么后果"一起看:拦下的多半是判别器先于用户发现了问题。
十、发布自检清单(可直接勾选)
这一份是给发布人用的,20 项,5 分钟内能过完。它替代了原来挂在选题清单里的 W3《上线前 SRE Checklist》的发布部分。
分级与准备
- 变更级别已由
classify-change.sh判定,且与发布人判断一致 - C3+:已有第二人评审,且第二人清楚回滚怎么做
- C4:已在预发用真实数据量演练过一次,中止阈值写清楚了
- 发布窗口不在业务高峰(对 C3+,看第 21 篇的业务日历)
回滚能力(最容易被跳过的一段)
- 回滚点已记录(镜像 / 配置 / flag / 迁移版本)
- 回滚目标确实存在(镜像 tag 未被覆盖,chart 版本还在)
- 回滚脚本/命令写进了发布单,不停留在"口头知道怎么做"
- 能明确说出这次发布不能回滚的部分是什么
数据与契约
- 无 Schema 变更;或已按 expand → migrate → contract 拆分
- 新代码能读旧数据,旧代码能读新数据
- 双写代码带到期日,且已被 CI 检查
- 跨服务契约变更已通知所有调用方(含那些"很久没动过"的)
灰度与判别
- 判别指标在预发能取到真实数据(不是
NaN) - 判别用差值/比值,不是绝对阈值
- 每一档灰度的放量比例和观察时长有依据(按期望缺陷样本数算)
- 自动中止的
failureLimit> 1(容忍噪声) - 有人盯着前 30 分钟(不是"发完就下班")
门禁与协同
slo-gate.sh通过,发布单上写明了当前剩余预算- 数据库迁移的锁情况已在副本上验证(不锁表 / 锁 < 1s)
- 回填脚本分批、可续跑、带主从延迟保护
- 上游/下游团队知道这次发布(尤其是有契约变更时)
十一、从零开始的四阶段路线
不要一次上齐五个闸门,会崩。按这个顺序,每阶段都有独立收益。
阶段一(第 1~2 周):先让回滚变得可能
- 镜像 tag 加不可变策略(Harbor immutable rule)
- 确认
revisionHistoryLimit≥ 10,Argo CD 保留可回滚版本 - 写
rollback-point.sh,每次发布自动记录 - 定一条规矩:值班人可无审批回滚任何 C2/C3 变更
验收:随便挑一次上周的发布,能在 3 分钟内完成回滚,且不需要问任何人。
阶段二(第 3~6 周):让灰度有判别能力
- 选一个核心服务上 Argo Rollouts(别选最边缘的,也先别选最核心的)
- 写两个判别指标:5xx 差值 + P99 比值,
failureLimit: 2 - 用
classify-change.sh自动分级,接进 PR 标签 - 记录第一批"被灰度拦下"的发布,在公司群里讲一次
验收:至少有一次发布是被自动中止的,且复盘结论认可这次中止是对的。
阶段三(第 2~4 月):把数据变更关起来
- 全量梳理有 Schema 变更的服务,列出待拆分的迁移
- 上线
schema-guard三条 CI 检查 - 定"双写必须带到期日"的规范,配 lint
- 推广 expand → migrate → contract,先做 3 个真实案例
验收:连续两个月没有因 Schema 变更导致的故障。
阶段四(第 4~6 月):接上错误预算
- 核心服务的 SLO 已覆盖(借用第 3、4、5 篇的现成方案)
slo-gate.sh进发布流水线,先只告警不拦截,跑一个月- 收集"本会被拦下"的发布,跟业务方对齐阈值
- 正式启用拦截,破例必须走工单并进周报
验收:预算低于 20% 时,发布确实被挡住了,且没有引发业务方的强烈反弹。
十二、六个真实的坑
坑一:"灰度 5% 跑了 12 分钟没问题"
这是开头那个夜晚的原话。5% × 12 分钟 = 约 50 万请求,如果缺陷流量占比 1e-5,期望样本是 5 个——肉眼不可能在错误率曲线上看到 5 个错误。灰度的有效性由 Q × s × r 决定,凭"感觉没问题"判断,等于随机放行。
坑二:只回滚镜像,忘回滚配置
典型形状:ConfigMap 里一个超时值从 3s 改成 300ms,镜像回滚了,配置没回滚,故障照旧。这类现场在复盘里出现频率很高,因为配置和应用是两条独立的变更链路。回滚点必须包含配置的 resourceVersion,回滚脚本必须校验它。 上一节 rollback-point.sh 里那两行 CONFIG_HASH、SECRET_HASH 就是为此而写。
坑三:把功能开关当灰度用
"新逻辑在开关后面,出问题关掉就行"——这句话在"新逻辑会写数据"时是错的。开关能停止产生新的坏数据,不能消除已写入的。开关只适用于"可撤销"的操作。
坑四:判别指标选了大盘指标
用"整个服务集群的错误率"判别灰度,5% 的灰度流量只占整体的 5%,它坏了也淹没在噪声里。判别指标必须按 version=canary 这个维度切出来,这需要在指标上打版本标签——这件事必须在上灰度之前就做好。
坑五:C4 变更走了 C2 的流程
一次证书轮换被当成日常配置变更,审了 20 分钟就发了。轮换失败后新旧证书都不能用(因为 trust store 只装了一个),整整 8 分钟全站 HTTPS 失败。C4 的定义不是"看起来危险",而是"回滚不可行",按这个标准重新洗一遍所有操作类型,会发现 C4 比想象的多。
坑六:门禁挡住了紧急修复
预算 3% 时线上有 P1,需要发 hotfix,门禁拒绝。团队第一次遇到时直接改了脚本跳过门禁,从此门禁形同虚设。正确做法是在门禁里内置"修复性发布"通道:关联了 P0/P1 工单的发布,预算判定降一级(不是跳过)。门禁必须为最紧急的时刻设计,否则它一定在最紧急的时刻被拆掉。
十三、和前面几篇的关系
- 第 3、4、5 篇(SLO / Sloth / Pyrra):那三篇把错误预算算了出来,本篇把预算接进了发布决策。没有本篇,预算只是一个好看的仪表;没有那三篇,本篇的门禁无数据可依
- 第 6 篇(告警治理):告警治理让"该叫的人被叫醒",本篇让"叫醒的次数变少"。两者是同一个 KPI(
N × p × D)的两端:一个压D,一个压p - 第 10 篇(错误预算用完怎么办):那篇讲的是预算烧穿之后的应急处置,本篇的第五节讲的是在烧穿之前把门关上。两篇合起来才是完整的预算生命周期
- 第 11 篇(CD 流水线优化):那篇把发布从 30 分钟压到 5 分钟,本篇给这 5 分钟加上了判别和门禁。速度和安全不是对立的——慢的是排查和回滚,不是流水线
- 第 13 篇(告警分级):本篇的"即时否决指标"应该复用第 13 篇的五级词汇,否则灰度自动生成的告警会和人工告警用两套语言
- 第 17 篇(面板设计):灰度观察窗口需要一个专用面板(stable vs canary 双线对比),它就是第 17 篇"7 个原则"里"对比优于绝对值"的直接应用
- 第 18 篇(Runbook 体系):本篇的发布流程应该有一份对应 Runbook,"回滚失败"是最该有 Runbook 的场景之一
- 第 20 篇(混沌工程与 Game Day):那篇会直接引用本篇的灰度和回滚步骤——演练剧本里的"回滚"就是本篇第五节那套。没演练过的回滚步骤,等于没有回滚步骤
- 第 23 篇(数据库 SRE):本篇第六节讲了 Schema 与代码的顺序,那篇会讲大表 DDL 的工具选型、主从延迟阈值和备份恢复演练
写在最后
如果这篇只能带走一句:
发布不是把代码推上去,是把一个假设推到真实流量里做实验。变更安全,就是实验设计。
一旦这么看,很多争论会自动消失。"要不要灰度"变成"这个假设需要在多大流量、多长时间内被证伪";"要不要自动回滚"变成"实验的失败判据是什么";"能不能发"变成"预算够不够支付这次实验可能的代价"。
如果只能带走一个动作:
打开最近一次发布记录,问三个问题:它坏了怎么发现?怎么回滚?回滚能撤销什么、不能撤销什么?
第三个问题通常答不上来。而它,正是开头那 55 分钟的真正来处。
本文是 SRE 实战系列第 19 篇,也是第二循环的第一篇。它接的是 W9 那个一直空着的选题:某次全站降级的 6 小时——熔断、限流、回滚的决策时间线。那篇要讲"事故里怎么决策",这篇讲"决策之前怎么让事故不发生"。下一篇(第 20 篇)把这一篇的灰度和回滚步骤拿去撞一次墙。