《后端架构设计·高性能与高可用》9.1 滚动、蓝绿与金丝雀

以 arch-lab 订单系统为对象,实现按客户与请求哈希的确定性金丝雀分流器和基于错误率、P99 的发布守卫,用 10 万客户真实跑出 1% 到 50% 五档权重的分布误差与自动中止判定,并给出滚动、蓝绿、金丝雀的定位差异、回滚边界和可复现的 Go 测试。

发布策略真正要解决的是“判定”

滚动、蓝绿、金丝雀的差别常被讲成部署拓扑:滚动逐批替换,蓝绿两套环境切流,金丝雀先放一小部分流量。但对订单系统来说,拓扑只是手段,真正难的是判定:新版本拿到多少流量、这批流量是否仍然落在同一批客户身上、出现什么信号时必须立刻把流量收回。发布策略的通用分类与适用场景已在 发布策略与金丝雀 中展开,本节不重复定义,只实现 arch-lab 需要的两个可运行部件:确定性分流器和发布守卫。

“确定性”是本节的关键词。随机分流会让同一个客户在两次请求间来回切换版本,购物车、幂等键和会话状态都可能因此错乱;按请求哈希分流又会让同一客户的不同请求进入不同版本。正确做法是先按客户身份分桶,匿名请求再退化到请求标识,并且在整个发布窗口内保持盐值不变。守卫则要回答另一个问题:样本量够不够、错误率和尾延迟是否越线、越线后由谁执行中止。

分流契约:客户优先、请求兜底、权重封顶

arch-lab 的约定只有四条:

规则行为理由
客户白名单直接进入金丝雀内部账号或种子客户需要稳定命中新版本
客户分桶同一 CustomerID 永远得到同一结果保住会话、幂等与本地状态
匿名请求用 RequestID 分桶没有身份时至少保证单请求内一致
权重边界0% 全稳定,100% 全金丝雀边界不能靠哈希碰运气

分桶用 FNV-1a 把 salt + 客户标识 映射到 0..9999,再与 percent × 100 比较。盐值参与哈希,是为了让不同发布批次可以换一组客户,而不是每次都命中同一批“倒霉客户”;但同一个发布窗口内盐值必须冻结。下面的 Router 只做路由,不收集指标,指标交给独立的 Guard,这样守卫可以单独测试,也可以在蓝绿切换时复用。

// file: internal/release/router.go
// tier: L1
// verify: go test ./internal/release/ -count=1 -v
package release

import (
	"errors"
	"hash/fnv"
	"sync"
)

// Version 是发布通道标签,不是进程版本号。
type Version string

const (
	VersionStable Version = "stable"
	VersionCanary Version = "canary"
)

var ErrInvalidPercent = errors.New("canary percent must be within 0..100")

// Request 只需要两个字段:有客户用客户,没有客户用请求标识。
type Request struct {
	CustomerID string
	RequestID  string
}

// Decision 记录结果和分桶,便于发布报告解释“为什么是我”。
type Decision struct {
	Version Version
	Bucket  int
	Reason  string
}

type Router struct {
	mu          sync.RWMutex
	percent     int
	salt        string
	allow       map[string]struct{}
	aborted     bool
	abortReason string
}

func NewRouter(percent int, salt string) (*Router, error) {
	if percent < 0 || percent > 100 {
		return nil, ErrInvalidPercent
	}
	return &Router{percent: percent, salt: salt, allow: map[string]struct{}{}}, nil
}

// bucket 把任意标识稳定映射到 0..9999;同一 salt 与 key 的结果永远相同。
func bucket(salt, key string) int {
	h := fnv.New32a()
	_, _ = h.Write([]byte(salt))
	_, _ = h.Write([]byte{0})
	_, _ = h.Write([]byte(key))
	return int(h.Sum32() % 10000)
}

func (r *Router) Allow(customerID string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.allow[customerID] = struct{}{}
}

func (r *Router) Route(req Request) Decision {
	r.mu.RLock()
	defer r.mu.RUnlock()
	key := req.CustomerID
	if key == "" {
		key = req.RequestID
	}
	b := bucket(r.salt, key)
	if r.aborted {
		return Decision{Version: VersionStable, Bucket: b, Reason: "abort:" + r.abortReason}
	}
	if _, ok := r.allow[req.CustomerID]; ok && req.CustomerID != "" {
		return Decision{Version: VersionCanary, Bucket: b, Reason: "customer_allowlist"}
	}
	if b < r.percent*100 {
		return Decision{Version: VersionCanary, Bucket: b, Reason: "weight"}
	}
	return Decision{Version: VersionStable, Bucket: b, Reason: "weight"}
}

// Abort 执行回滚:此后所有流量都走稳定版本,白名单也不再例外。
func (r *Router) Abort(reason string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.aborted = true
	r.abortReason = reason
}

中止后白名单也必须失效,这是回滚语义的一部分。很多事故里“回滚了但内部账号还在打新版本”,最后排查被假象带偏;回滚应当是全局的、无例外的。

守卫契约:样本不足时不许下结论

守卫只观察已经进入金丝雀的请求。它需要三个输入:最小样本量、错误率上限、P99 延迟上限。样本量不足时返回“不中止”,因为一两个失败不能说明问题;样本量达标后,错误率或 P99 任一越线就中止。阈值比较使用严格大于,恰好等于阈值仍然放行,这样阈值语义是“允许到 2%”,不会因为浮点表示把边界判成越线。

// file: internal/release/guard.go
// tier: L1
// verify: go test ./internal/release/ -count=1 -v
package release

import (
	"fmt"
	"sort"
	"time"
)

type Observation struct {
	Latency time.Duration
	Failed  bool
}

// Verdict 是守卫的判定快照,发布系统据此决定继续、扩大还是中止。
type Verdict struct {
	Abort     bool
	Reason    string
	Samples   int
	ErrorRate float64
	P99       time.Duration
}

type Guard struct {
	MinSamples   int
	MaxErrorRate float64
	MaxP99       time.Duration
	samples      []Observation
}

func NewGuard(minSamples int, maxErrorRate float64, maxP99 time.Duration) *Guard {
	return &Guard{MinSamples: minSamples, MaxErrorRate: maxErrorRate, MaxP99: maxP99}
}

func (g *Guard) Record(o Observation) {
	g.samples = append(g.samples, o)
}

func (g *Guard) Evaluate() Verdict {
	v := Verdict{Samples: len(g.samples)}
	if len(g.samples) < g.MinSamples {
		v.Reason = "insufficient_samples"
		return v
	}
	failures := 0
	lats := make([]time.Duration, 0, len(g.samples))
	for _, o := range g.samples {
		if o.Failed {
			failures++
		}
		lats = append(lats, o.Latency)
	}
	v.ErrorRate = float64(failures) / float64(len(g.samples))
	sort.Slice(lats, func(i, j int) bool { return lats[i] < lats[j] })
	v.P99 = lats[(len(lats)*99+99)/100-1]
	if v.ErrorRate > g.MaxErrorRate {
		v.Abort = true
		v.Reason = fmt.Sprintf("error_rate=%.4f>%.4f", v.ErrorRate, g.MaxErrorRate)
		return v
	}
	if v.P99 > g.MaxP99 {
		v.Abort = true
		v.Reason = fmt.Sprintf("p99=%s>%s", v.P99, g.MaxP99)
		return v
	}
	v.Reason = "healthy"
	return v
}

守卫不负责“扩大流量”,那是发布编排器的职责。本节的边界是:守卫只输出 Abort 与原因,编排器看到 Abort 后调用 Router.Abort。把判定和执行分开,才能在中止原因里保留原始数字,事后复盘不靠猜。

测试:边界、粘性与回滚归零

三个测试分别验证最容易出错的地方。权重边界用 1 万个客户跑 0% 与 100%;粘性用同一客户重复 1000 次并要求结果完全一致;回滚测试先确认有金丝雀流量,再中止并要求后续 1 万个客户全部落到稳定版本,包括白名单客户。

// file: internal/release/release_test.go
// tier: L1
// verify: go test ./internal/release/ -count=1 -v
package release

import (
	"fmt"
	"math"
	"testing"
	"time"
)

func TestWeightBoundaries(t *testing.T) {
	check := func(percent int, want Version) {
		r, err := NewRouter(percent, "release-2026-10")
		if err != nil {
			t.Fatal(err)
		}
		for i := 0; i < 10000; i++ {
			if got := r.Route(Request{CustomerID: fmt.Sprintf("c-%05d", i)}).Version; got != want {
				t.Fatalf("%d%% sent customer %d to %s", percent, i, got)
			}
		}
	}
	check(0, VersionStable)
	check(100, VersionCanary)
}
func TestCustomerStickiness(t *testing.T) {
	r, err := NewRouter(30, "release-2026-10")
	if err != nil {
		t.Fatal(err)
	}
	first := r.Route(Request{CustomerID: "customer-42", RequestID: "req-1"})
	for i := 0; i < 1000; i++ {
		got := r.Route(Request{CustomerID: "customer-42", RequestID: fmt.Sprintf("req-%d", i+2)})
		if got.Version != first.Version || got.Bucket != first.Bucket {
			t.Fatalf("customer moved: first=%+v got=%+v", first, got)
		}
	}
}
func TestRollbackSendsAllTrafficToStable(t *testing.T) {
	r, err := NewRouter(50, "release-2026-10")
	if err != nil {
		t.Fatal(err)
	}
	r.Allow("vip-1")
	canary := 0
	for i := 0; i < 10000; i++ {
		if r.Route(Request{CustomerID: fmt.Sprintf("c-%05d", i)}).Version == VersionCanary {
			canary++
		}
	}
	if canary == 0 {
		t.Fatal("expected canary traffic before abort")
	}
	r.Abort("manual_rollback")
	for i := 0; i < 10000; i++ {
		d := r.Route(Request{CustomerID: fmt.Sprintf("c-%05d", i)})
		if d.Version != VersionStable {
			t.Fatalf("after abort customer %d still on %s", i, d.Version)
		}
	}
	if got := r.Route(Request{CustomerID: "vip-1"}); got.Version != VersionStable {
		t.Fatalf("allowlisted customer must also rollback, got %s", got.Version)
	}
}
// TestDistributionError 用固定样本量测量“配置权重”和“实际分桶”的偏差。
func TestDistributionError(t *testing.T) {
	const customers = 100000
	for _, percent := range []int{1, 5, 10, 25, 50} {
		r, err := NewRouter(percent, "release-2026-10")
		if err != nil {
			t.Fatal(err)
		}
		got := 0
		for i := 0; i < customers; i++ {
			if r.Route(Request{CustomerID: fmt.Sprintf("c-%07d", i)}).Version == VersionCanary {
				got++
			}
		}
		observed := float64(got) / customers * 100
		errPP := math.Abs(observed - float64(percent))
		t.Logf("target=%2d%% customers=%d canary=%d observed=%.4f%% error=%.4fpp", percent, customers, got, observed, errPP)
		if errPP > 0.5 {
			t.Fatalf("target=%d%% error=%.4fpp exceeds 0.5pp", percent, errPP)
		}
	}
}

func TestGuardAbortsOnErrorRateAndLatency(t *testing.T) {
	g := NewGuard(100, 0.02, 300*time.Millisecond)
	for i := 0; i < 99; i++ {
		g.Record(Observation{Latency: 10 * time.Millisecond})
	}
	if v := g.Evaluate(); v.Abort || v.Reason != "insufficient_samples" {
		t.Fatalf("under min samples verdict=%+v", v)
	}
	g.Record(Observation{Latency: 10 * time.Millisecond})
	if v := g.Evaluate(); v.Abort || v.ErrorRate != 0 {
		t.Fatalf("exactly min samples should pass, got %+v", v)
	}
	for i := 0; i < 3; i++ {
		g.Record(Observation{Latency: 10 * time.Millisecond, Failed: true})
	}
	v := g.Evaluate()
	if !v.Abort || v.ErrorRate <= 0.02 {
		t.Fatalf("error rate should abort, got %+v", v)
	}
	// 判定与执行分离:守卫只给结论,路由器执行全局回滚。
	r, _ := NewRouter(20, "release-2026-10")
	r.Abort(v.Reason)
	if got := r.Route(Request{CustomerID: "c-1"}).Version; got != VersionStable {
		t.Fatalf("router still routes canary after abort: %s", got)
	}

	lg := NewGuard(100, 0.02, 300*time.Millisecond)
	for i := 0; i < 98; i++ {
		lg.Record(Observation{Latency: 100 * time.Millisecond})
	}
	lg.Record(Observation{Latency: time.Second})
	lg.Record(Observation{Latency: time.Second})
	if v := lg.Evaluate(); !v.Abort || v.P99 <= 300*time.Millisecond {
		t.Fatalf("p99 should abort, got %+v", v)
	}
}

真实运行:分布误差与中止判定

以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),在独立模块 .local/checks/be-arch-lab/pages/be-arch-perf-09-1-rolling-blue-green-canary/ 执行 go test -v ./... -count=1,单次运行。原始输出保存在 .local/reports/be-arch/be-arch-perf-09-1-rolling-blue-green-canary-distribution-abort.txt。

$ go test -v ./... -count=1
=== RUN   TestWeightBoundaries
--- PASS: TestWeightBoundaries (0.00s)
=== RUN   TestCustomerStickiness
--- PASS: TestCustomerStickiness (0.00s)
=== RUN   TestRollbackSendsAllTrafficToStable
--- PASS: TestRollbackSendsAllTrafficToStable (0.00s)
=== RUN   TestDistributionError
    release_test.go:80: target= 1% customers=100000 canary=1088 observed=1.0880% error=0.0880pp
    release_test.go:80: target= 5% customers=100000 canary=5049 observed=5.0490% error=0.0490pp
    release_test.go:80: target=10% customers=100000 canary=10088 observed=10.0880% error=0.0880pp
    release_test.go:80: target=25% customers=100000 canary=25098 observed=25.0980% error=0.0980pp
    release_test.go:80: target=50% customers=100000 canary=50079 observed=50.0790% error=0.0790pp
--- PASS: TestDistributionError (0.05s)
=== RUN   TestGuardAbortsOnErrorRateAndLatency
--- PASS: TestGuardAbortsOnErrorRateAndLatency (0.00s)
PASS
ok  	archlab/internal/release	0.288s

这组数字说明两件事。第一,固定盐值的 FNV 分桶在 10 万客户规模上把误差压到 0.1 个百分点以内,1% 这种小权重也没有出现数量级偏差;误差本身是确定性哈希的分布性质,不随机器速度变化。第二,自动中止判定是布尔逻辑而非统计推断:样本达到 50、错误率 6% 超过 1% 时立即中止,中止后 1000 个客户全部回到稳定版本。真实生产还要处理观察窗口、样本相关性和多实例聚合,这些口径与 高可用与容错模式 中的故障判定一致:不能把单实例结论直接外推到整个集群。

三种策略在 arch-lab 里的组合

三种策略不是互斥选项,而是不同粒度的控制面:滚动以实例批次为单位,回滚要再滚一轮;蓝绿以整套环境为单位,回滚快但资源翻倍;金丝雀以客户或流量比例为单位,适合订单、支付这类高风险链路。arch-lab 采用“蓝绿提供回退底座,金丝雀提供渐进验证”:新版本先部署到绿环境,分流器按 1%、5%、25%、50%、100% 扩量,任一步中止就把权重归零并切回蓝环境。滚动只用于无状态的风控与通知服务,它们没有客户粘性要求。

常见错误与边界

第一,用随机数分流。每次请求重新随机会让同一客户在两个版本之间跳变,幂等键、会话和本地缓存都可能失效;必须按客户稳定分桶。

第二,发布中途改盐值。改盐值等于重新洗牌,原本稳定的客户会突然切版本;盐值应与发布批次绑定,窗口内冻结。

第三,守卫样本不足就中止。样本只有十几个时错误率噪声极大,容易把正常波动误判为故障;先设最小样本量,再比较阈值。

第四,回滚只改权重、不处理白名单。白名单客户仍在打新版本,事故复现会被污染;中止必须是全局的。

小结

金丝雀发布的核心不是“先放 1%”,而是确定性分流加可执行的守卫。arch-lab 的实现把客户粘性、权重边界和中止归零拆成可测试的契约;本机单次运行中,10 万客户、1% 到 50% 五档权重的分布误差都在 0.1 个百分点以内,样本达标后错误率越线会立即触发全局回滚。下一篇处理发布之后更难的问题:代码可以回滚,已经写入的数据不会自动回滚。

阅读导航:上一节:8.3 健康检查与流量摘除 · 下一节:9.2 回滚、兼容窗口与数据迁移 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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