《互联网产品的服务端架构·产品、流量与基础能力》10.2 灰度、开关与回滚

灰度 5% 时错误率已经从 0.4% 翻到 1.8%,却没人看指标,一路放到 100% 才发现。文章把灰度拆成按用户分桶的递增台阶、独立于发布的开关和带最小样本量的回滚阈值,用 Go 实现计划校验、阶段推进与自动回滚判断,并给出表驱动测试与一次真实的运行输出。

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% 阶段。新代码一上线就直接吃流量,既没有基线,也没有独立的关闭落点。
  • 把开关和发布绑死。回滚镜像时功能可能还开着,或者关功能必须重新发版。
  • 只看整站错误率。低比例灰度的异常会被大部分正常流量稀释,告警永远不响。
  • 按时间自动推进,不看指标。时间到了不等于风险过去了,异常可能只是还没积累到阈值。
  • 回滚没有最小样本量。几十个请求里的几次失败就触发回滚,灰度永远走不完。
  • 用大于等于比较阈值。错误率刚好等于阈值时反复触发,回滚和恢复来回抖动。
  • 计划允许停在中间比例。没有明确的完成状态,后续发布和清理都会失去依据。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查