5% 的灰度放出了 100% 的故障
「回声」的结算服务每周迭代一次。这次改动把优惠券计算从“下单时锁定”改成“支付前重算”,发布计划是 1%、5%、25%、100%,每个台阶观察 30 分钟。发布负责人盯着 CPU 和内存曲线,两项都很平稳,流水线按计划把流量推到了 100%。
十二分钟后,支付成功率从 99.6% 掉到 81%(案例假设值)。告警终于响了,但此时所有用户都在受影响。回滚镜像用了四分钟,支付成功率恢复,期间累计失败请求按案例假设值约 17 万次,客服工单在半小时内涨了六倍。
事后看监控,5% 台阶时结算接口的错误率已经是 1.8%,而基线是 0.4%。问题是这个信号没有被任何人看到:发布负责人看的是整站错误率,5% 的流量把异常稀释成了 0.47%,低于 1% 的告警线;灰度平台只负责按时间推进,不负责判断“这个台阶是否健康”;回滚按钮在值班同学手里,但他不知道 5% 时已经出了问题。
这不是“灰度没用”,而是灰度只做了放量,没做分桶、观测和自动止损。灰度的价值不在于“先给小部分用户”,而在于“用一小部分用户换取一个可判断的信号,并且在信号变坏时自动停下来”。
灰度是按用户分桶,不是按机器轮询
灰度第一步是决定“谁进入新版本”。按机器轮询看起来简单:负载均衡把请求依次分给新老实例,比例自然接近。但它有两个问题:同一个用户会在新老版本之间跳来跳去,购物车、会话和缓存可能不兼容;比例也不稳定,实例重启或扩容就会漂移。
正确做法是按稳定标识分桶。用 user_id 或设备号做哈希,映射到 0 到 9999 的桶,桶号小于当前百分比才进入新版本。同一个用户在同一比例下始终落在同一侧,比例调整时只会有增量的用户进入,不会来回切换。
| 维度 | 错误做法 | 正确做法 | 不这样做的后果 |
|---|---|---|---|
| 分桶键 | 按机器轮询、按请求随机 | 按用户或设备稳定哈希 | 同一用户看到不同版本,状态错乱 |
| 放量顺序 | 直接 50%、100% | 0%、1%、5%、25%、100% 递增 | 问题一次性覆盖大部分用户 |
| 观察窗口 | 看固定时间,不看指标 | 每个台阶有最短观察时间和健康条件 | 指标还没稳定就进入下一台阶 |
| 开关 | 开关和发布绑在一起 | 开关独立于镜像,可单独关闭 | 回滚镜像时功能仍开着,或反之 |
| 回滚判定 | 靠人看、靠人喊 | 按错误率和最小样本量自动判断 | 发现时已经全量,损失扩大 |
| 样本量 | 只看百分比 | 同时看请求数和失败数 | 1% 流量下几十个请求就误判 |
0% 阶段不是多余的空转,它是开关的落点。发布前先把新代码部署到全部实例,但开关保持 0%,确认进程健康、依赖可用、配置正确;需要停止时把开关关回 0%,不需要重新部署镜像。0% 阶段还可以作为基线,记录同一批实例在旧逻辑下的错误率和延迟。
百分比必须严格递增,不能重复也不能倒退。重复的台阶没有意义,倒退的台阶会让已经进入新版本的用户又回到旧版本,缓存和会话可能因此错乱。计划必须从 0% 开始、到 100% 结束;中间可以按业务选择 1%、5%、10%、25%、50%,但每一步都要有明确的观察条件。
自动回滚需要两个参数:阈值和最小样本量。只有错误率严格高于阈值、并且样本数足够时才回滚。样本不足时即使错误率看起来很高也不能动,因为几十个请求里的几次失败可能只是噪声;样本足够时即使比例很低,也能从绝对失败数看出问题。
失败场景:指标没看,人喊才回滚
把那次事故按台阶展开,可以看到四个独立的缺口。
第一个缺口是没有 0% 基线。新代码部署后直接进入 1%,团队不知道同一批实例在旧逻辑下的错误率是多少。1% 时错误率 0.5%,看起来只比日常高一点,实际上已经是基线的 1.25 倍。
第二个缺口是观测口径错了。发布负责人看整站错误率,5% 的异常被 95% 的正常流量稀释。正确的做法是按发布批次、按新版本实例、按分桶用户分别看错误率,而不是看全站平均。
第三个缺口是平台只按时间推进。30 分钟一到,流水线自动进入下一台阶,没有检查“这个台阶是否健康”。时间到了不等于风险过去了,可能只是异常还没积累到告警线。
第四个缺口是回滚靠人。值班同学不知道 5% 时已经有异常,等他被 100% 的告警叫醒,已经过去了十二分钟。自动回滚不是不信任人,而是把“看到信号到采取行动”的延迟从分钟级压到秒级。
这四个缺口叠加,灰度就退化成了“分两次全量发布”:第一次给 5% 的用户,第二次给剩下的 95%,中间没有任何判断。真正需要的是每个台阶都能回答三个问题:这一批用户的错误率是多少、样本够不够、超过阈值时谁来关。
用 ValidatePlan、Advance 与 ShouldRollback 固定灰度
Plan 是一串 Stage,每个 Stage 有放量百分比和最短观察分钟数。ValidatePlan 检查计划是否从 0% 起步、是否严格递增、是否以 100% 结束;Advance 返回指定台阶并拒绝越界;ShouldRollback 用阈值和最小样本量判断是否自动回滚。
// file: internal/release/rollout.go
// tier: L1
// verify: go test ./internal/release/
package release
import "fmt"
// Stage 是灰度计划里的一个台阶。
// Percent 是放量比例,Minutes 是在该比例下至少观察的分钟数。
type Stage struct {
Percent int
Minutes int
}
// Plan 是完整的灰度计划。
type Plan struct {
Stages []Stage
}
// ValidatePlan 检查灰度计划:从 0% 起步,严格递增,最后到 100%。
func ValidatePlan(p Plan) error {
if len(p.Stages) == 0 {
return fmt.Errorf("release: rollout plan has no stages")
}
if p.Stages[0].Percent != 0 {
return fmt.Errorf("release: rollout must start at 0%%, got %d%%", p.Stages[0].Percent)
}
prev := -1
for i, s := range p.Stages {
if s.Percent < 0 || s.Percent > 100 {
return fmt.Errorf("release: stage %d percent %d out of range", i, s.Percent)
}
if s.Minutes < 0 {
return fmt.Errorf("release: stage %d minutes %d is negative", i, s.Minutes)
}
if i > 0 && s.Percent == prev {
return fmt.Errorf("release: stage %d repeats percent %d%%", i, s.Percent)
}
if s.Percent < prev {
return fmt.Errorf("release: stage %d moves backward from %d%% to %d%%", i, prev, s.Percent)
}
prev = s.Percent
}
if last := p.Stages[len(p.Stages)-1].Percent; last != 100 {
return fmt.Errorf("release: rollout must end at 100%%, got %d%%", last)
}
return nil
}
// Advance 返回计划里的第 idx 个阶段。索引越界或计划非法时返回错误。
func Advance(p Plan, idx int) (Stage, error) {
if err := ValidatePlan(p); err != nil {
return Stage{}, err
}
if idx < 0 || idx >= len(p.Stages) {
return Stage{}, fmt.Errorf("release: stage index %d out of range [0,%d)", idx, len(p.Stages))
}
return p.Stages[idx], nil
}
// Guard 是自动回滚的观测阈值。
// 样本数至少要达到 MinSamples,错误率严格高于 MaxErrorRate 才触发回滚。
type Guard struct {
MaxErrorRate float64
MinSamples int
}
// ShouldRollback 判断当前观测是否足以触发回滚。
// 样本不足时一律返回 false,避免小样本抖动造成误回滚。
func ShouldRollback(g Guard, samples int, errorRate float64) bool {
if samples < 0 || samples < g.MinSamples {
return false
}
return errorRate > g.MaxErrorRate
}
ValidatePlan 先检查空计划,再检查第一个台阶必须是 0%。prev 从 -1 开始,循环里同时检查范围、负分钟数、重复和倒退。重复和倒退分开报错,是因为它们的修复方式不同:重复通常是发布单复制粘贴,倒退通常是有人手工调整了顺序。
最后一个台阶必须是 100%。允许计划停在 50% 看似灵活,但会让“发布完成”没有明确状态;如果只是想长期保留一部分用户在新版本,那应该用开关或实验平台,而不是一个永远不结束的灰度计划。
Advance 每次调用都重新校验计划,然后检查索引范围。这样调用方即使拿到一个被修改过的计划,也不会在越界时读到零值 Stage 并把它当成“0% 阶段”。错误信息里带上范围,方便定位是配置错了还是循环变量写错了。
Guard 把阈值和样本量放在一起。ShouldRollback 先看样本数:小于 MinSamples 时直接返回 false,不做任何比例判断。样本足够后才比较错误率,并且用严格大于而不是大于等于,避免错误率刚好等于阈值时因为浮点误差反复触发。
样本数为负时也返回 false。负样本在真实系统里不应该出现,但上游统计任务出错、时间窗口重叠或计数器回绕都可能产生负值;把它当成“样本不足”比当成“样本很多”安全。
测试:小样本不触发,阈值严格大于
测试固定三组行为:计划必须单调且首尾正确;Advance 返回正确台阶并拒绝越界;ShouldRollback 在样本不足和刚好等于阈值时不触发。
// file: internal/release/rollout_test.go
// tier: L1
// verify: go test ./internal/release/
package release
import (
"strings"
"testing"
)
func TestValidatePlanAcceptsMonotonicStages(t *testing.T) {
p := Plan{Stages: []Stage{
{Percent: 0, Minutes: 0},
{Percent: 1, Minutes: 30},
{Percent: 5, Minutes: 60},
{Percent: 25, Minutes: 120},
{Percent: 100, Minutes: 0},
}}
if err := ValidatePlan(p); err != nil {
t.Fatalf("Validate() = %v, want nil", err)
}
}
func TestValidatePlanRejectsBadStages(t *testing.T) {
cases := []struct {
name string
plan Plan
want string
}{
{name: "empty", plan: Plan{}, want: "no stages"},
{name: "no zero stage", plan: Plan{Stages: []Stage{{Percent: 5, Minutes: 10}, {Percent: 100}}}, want: "start at 0%"},
{name: "no hundred stage", plan: Plan{Stages: []Stage{{Percent: 0}, {Percent: 50}}}, want: "end at 100%"},
{name: "repeated percent", plan: Plan{Stages: []Stage{{Percent: 0}, {Percent: 10}, {Percent: 10}, {Percent: 100}}}, want: "repeats"},
{name: "backward percent", plan: Plan{Stages: []Stage{{Percent: 0}, {Percent: 20}, {Percent: 10}, {Percent: 100}}}, want: "backward"},
{name: "out of range", plan: Plan{Stages: []Stage{{Percent: 0}, {Percent: 120}, {Percent: 100}}}, want: "out of range"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidatePlan(tc.plan)
if err == nil {
t.Fatal("Validate() = nil, want error")
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("Validate() = %q, want substring %q", err, tc.want)
}
})
}
}
func TestAdvanceReturnsStageAndRejectsOutOfRange(t *testing.T) {
p := Plan{Stages: []Stage{
{Percent: 0, Minutes: 0},
{Percent: 10, Minutes: 30},
{Percent: 100, Minutes: 60},
}}
got, err := Advance(p, 1)
if err != nil {
t.Fatal(err)
}
if got.Percent != 10 || got.Minutes != 30 {
t.Fatalf("Advance(1) = %+v, want {10 30}", got)
}
for _, idx := range []int{-1, 3} {
if _, err := Advance(p, idx); err == nil {
t.Fatalf("Advance(%d) = nil error, want out of range", idx)
}
}
if _, err := Advance(Plan{}, 0); err == nil {
t.Fatal("Advance on invalid plan = nil error")
}
}
func TestShouldRollbackNeedsEnoughSamples(t *testing.T) {
g := Guard{MaxErrorRate: 0.02, MinSamples: 500}
if ShouldRollback(g, 100, 0.50) {
t.Fatal("rollback fired with 100 samples, want false")
}
if ShouldRollback(g, 500, 0.02) {
t.Fatal("rollback fired at exactly the threshold, want false")
}
if !ShouldRollback(g, 500, 0.021) {
t.Fatal("rollback did not fire above the threshold")
}
if ShouldRollback(g, -1, 0.90) {
t.Fatal("rollback fired with negative samples, want false")
}
}
TestValidatePlanAcceptsMonotonicStages 用 0、1、5、25、100 五个台阶,覆盖了常见的放量节奏。0% 和 100% 的 Minutes 都是 0,表示开关落点和最终状态不需要额外观察时间,中间三个台阶分别观察 30、60、120 分钟。
TestValidatePlanRejectsBadStages 的每个子测试只破坏一个条件:空计划、没有 0%、没有 100%、重复、倒退、越界。错误信息用 strings.Contains 匹配关键词,避免测试和具体措辞绑死。
TestAdvanceReturnsStageAndRejectsOutOfRange 检查索引 1 返回 {10 30},并分别用 -1 和 3 验证越界。最后还验证非法计划会在索引检查之前被拒绝,防止调用方拿到一个看似合法的零值阶段。
TestShouldRollbackNeedsEnoughSamples 用 500 作为最小样本量:100 个样本时错误率 50% 也不回滚;500 个样本、错误率刚好 2% 不回滚;500 个样本、错误率 2.1% 回滚;负样本不回滚。这个测试把“样本不足不触发”和“严格大于阈值”两条边界钉死。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/release/ -count=1 -run 'TestValidatePlan|TestAdvance|TestShouldRollback' -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestValidatePlanAcceptsMonotonicStages
--- PASS: TestValidatePlanAcceptsMonotonicStages (0.00s)
=== RUN TestValidatePlanRejectsBadStages
=== RUN TestValidatePlanRejectsBadStages/empty
=== RUN TestValidatePlanRejectsBadStages/no_zero_stage
=== RUN TestValidatePlanRejectsBadStages/no_hundred_stage
=== RUN TestValidatePlanRejectsBadStages/repeated_percent
=== RUN TestValidatePlanRejectsBadStages/backward_percent
=== RUN TestValidatePlanRejectsBadStages/out_of_range
--- PASS: TestValidatePlanRejectsBadStages (0.00s)
--- PASS: TestValidatePlanRejectsBadStages/empty (0.00s)
--- PASS: TestValidatePlanRejectsBadStages/no_zero_stage (0.00s)
--- PASS: TestValidatePlanRejectsBadStages/no_hundred_stage (0.00s)
--- PASS: TestValidatePlanRejectsBadStages/repeated_percent (0.00s)
--- PASS: TestValidatePlanRejectsBadStages/backward_percent (0.00s)
--- PASS: TestValidatePlanRejectsBadStages/out_of_range (0.00s)
=== RUN TestAdvanceReturnsStageAndRejectsOutOfRange
--- PASS: TestAdvanceReturnsStageAndRejectsOutOfRange (0.00s)
=== RUN TestShouldRollbackNeedsEnoughSamples
--- PASS: TestShouldRollbackNeedsEnoughSamples (0.00s)
PASS
ok webapp/internal/release 0.235s
输出只包含灰度计划、阶段推进和回滚判断的测试,不包含同包的发布单元与数据兼容测试。0.235s 是一次本机运行的耗时,会随机器和负载变化。
小结
灰度不是“先给一小部分用户”,而是“用一小部分用户换一个可判断的信号”。分桶要按稳定标识,台阶要从 0% 严格递增到 100%,每个台阶要有观察窗口和健康条件,开关要能独立于镜像关闭,回滚要按阈值和最小样本量自动判断。
ValidatePlan 把计划的结构错误挡在发布前,Advance 保证调用方只能走到计划里的合法台阶,ShouldRollback 把“小样本噪声”和“真实异常”分开。它们不能替你选阈值,也不能保证分桶键选得对,但能让灰度流程从“按时间推进”变成“按信号推进”。
下一节处理更底层、更不可逆的变更:数据库列和表的兼容、双写窗口与最终切换。
阅读导航:上一节:10.1 功能上线的最小发布单元 · 下一节:10.3 数据兼容与双写窗口 。
常见错误与边界
- 按机器或请求轮询做灰度。同一用户会在新老版本之间跳变,缓存和会话容易错乱。
- 没有 0% 阶段。新代码一上线就直接吃流量,既没有基线,也没有独立的关闭落点。
- 把开关和发布绑死。回滚镜像时功能可能还开着,或者关功能必须重新发版。
- 只看整站错误率。低比例灰度的异常会被大部分正常流量稀释,告警永远不响。
- 按时间自动推进,不看指标。时间到了不等于风险过去了,异常可能只是还没积累到阈值。
- 回滚没有最小样本量。几十个请求里的几次失败就触发回滚,灰度永远走不完。
- 用大于等于比较阈值。错误率刚好等于阈值时反复触发,回滚和恢复来回抖动。
- 计划允许停在中间比例。没有明确的完成状态,后续发布和清理都会失去依据。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。