发布策略真正要解决的是“判定”
滚动、蓝绿、金丝雀的差别常被讲成部署拓扑:滚动逐批替换,蓝绿两套环境切流,金丝雀先放一小部分流量。但对订单系统来说,拓扑只是手段,真正难的是判定:新版本拿到多少流量、这批流量是否仍然落在同一批客户身上、出现什么信号时必须立刻把流量收回。发布策略的通用分类与适用场景已在 发布策略与金丝雀 中展开,本节不重复定义,只实现 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 回滚、兼容窗口与数据迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。