《后端架构设计·演进与治理》5.1 配置分层、版本与灰度发布

从 arch-lab 订单系统一次库存预占参数被直接改环境变量的事故切入,拆开默认、环境、实例三层配置的职责与合并优先级,给出带来源追溯的合并、配置版本号与按实例分桶的灰度发布 Go 实现,并附 1 万个合成实例的分桶与 50% 分流实测输出。

一次改错默认值的发布

arch-lab 订单系统把库存预占参数 reservation.ttl_ms 从 3000 调到 5000,想减少高峰期误释放。运维直接在环境变量里改了值,滚动重启后 40 个实例同时生效。预占回收变慢,和支付回调的 8 秒超时叠在一起,同一笔库存被重复释放。

复盘发现三个问题并不是同一个问题。默认值写在代码里,环境值写在部署清单里,实例值临时用环境变量覆盖,但三者没有明确的优先级;这次变更没有版本号,回滚只能靠人记得改回去;灰度只存在于评审会的口头约定里,实际是全员同时生效。

配置治理要先回答“这个值属于哪一层”,再回答“这次变更怎么被识别、怎么被逐步放量”。配置与密钥的环境边界 已经讨论过哪些值不该进代码仓库,本节把重点放在三层合并、版本号和灰度路由上。

三层配置是三种变更权限

层谁维护变化频率允许放什么典型错误
默认层服务开发随代码发布安全、可运行的兜底值把生产地址写进默认值
环境层平台/部署每次部署集群地址、资源上限用环境名当分支逻辑
实例层运行时配置分钟级灰度参数、租户覆盖无审计的临时覆盖

默认层必须让服务在没有任何外部文件时也能启动,否则本地开发和故障兜底都会失效。环境层描述“这个环境长什么样”,例如数据库地址和线程池大小。实例层才是运行时治理的入口,它变化最快,也最需要版本、审计和过期机制。三层混在一起时,一次线上问题往往无法回答“这个值到底来自哪里”,所以合并结果必须带来源。

合并顺序不能由参数顺序决定。调用方可能先传环境层再传默认层,也可能反过来;如果实现按切片顺序覆盖,同一份配置会得到不同结果。正确做法是给层定义固定优先级,重复层直接报错,而不是让后一个悄悄覆盖前一个。密钥则完全不应进入这条普通配置链路,应走独立的密钥管理。

版本号是配置的身份证

每次发布给配置一个单调递增的版本号,候选版本必须严格高于稳定版本。版本号不是装饰,它承担三个职责:让实例能判断收到的是新包还是旧包;让回滚能指向一个确定的内容;让审计日志能回答“当时生效的是哪一版”。

复用版本号是最隐蔽的错误。两个不同内容使用同一个版本号时,订阅端无法区分“重复投递”和“内容被改写”,只能二选一:要么接受覆盖,破坏幂等;要么拒绝,导致一部分实例永远拿不到新值。本节的实现把“同版本不同内容”定义为冲突并拒绝,宁可让发布失败,也不让版本号失去意义。

版本还要和灰度绑定。稳定包与候选包同时存在,实例按分桶决定读哪一个;一旦候选包达到放量条件,它就成为新的稳定包,版本继续向前。金丝雀发布 的实例分桶也是同一思路:灰度不是修改同一份配置的某个字段,而是让不同实例读不同版本的完整快照,这样回退时不需要逐字段反向操作。

灰度按实例分桶,不按请求随机

按请求随机取模会让同一个租户在几十毫秒内反复切换新旧行为,用户看到的是闪烁的页面和重复的写入。灰度单位应当是有稳定标识的实例或租户,例如 Pod 名、租户 ID;同一个标识在整个发布窗口内必须落在同一侧。

分桶用带盐的稳定哈希,盐不变则分配不变,换盐可以在不修改配置内容的情况下重新洗牌。下表是 arch-lab 采用的四段放量判据,阈值是示例值,不是生产承诺。

阶段分桶比例观察指标最短窗口进入下一阶段回退条件
内部1%错误率、预占超时30 分钟无新增告警任一 P1
小流量10%订单成功率、库存差异2 小时与基线差 < 0.2pp差异 > 0.5pp
半量50%p99、重复释放数4 小时指标不劣化出现重复扣减
全量100%同上,观察一天24 小时候选转为稳定任一资金类异常

灰度期间最容易被忽略的是“配置版本和代码版本交叉”。新配置可能只被新代码正确理解,所以实例分桶要和发布批次对齐;否则旧代码读到新字段,会以默认值继续运行,表面上没有报错,实际上灰度组和对照组的差异被抹平了。

可运行实现

下面的 config 包只依赖标准库。Merge 按层优先级合并并记录来源;Release.For 用稳定的 FNV-1a 分桶选择稳定包或候选包。后续两节会继续扩展同一个包,分别加入开关求值和动态同步。

// file: internal/platform/config/config.go
// tier: L1
// verify: go test ./internal/platform/config/ -count=1
package config

import (
	"errors"
	"fmt"
	"hash/fnv"
	"sort"
	"strings"
)

// LayerKind orders sources from weakest to strongest.
type LayerKind string

const (
	Default     LayerKind = "default"
	Environment LayerKind = "environment"
	Instance    LayerKind = "instance"
)

func (k LayerKind) rank() int {
	switch k {
	case Default:
		return 0
	case Environment:
		return 1
	case Instance:
		return 2
	}
	return -1
}

// Layer is one source of key/value pairs. At most one layer of each kind is
// accepted, so a double overlay cannot win by argument order.
type Layer struct {
	Kind   LayerKind
	Values map[string]string
}

// Effective is the merged view plus the layer that supplied each key.
type Effective struct {
	Values map[string]string
	Source map[string]LayerKind
}

// Merge overlays layers by precedence: instance > environment > default.
func Merge(layers ...Layer) (Effective, error) {
	seen := make(map[LayerKind]bool, len(layers))
	for _, l := range layers {
		if l.Kind.rank() < 0 {
			return Effective{}, fmt.Errorf("config: unknown layer %q", l.Kind)
		}
		if seen[l.Kind] {
			return Effective{}, fmt.Errorf("config: duplicate layer %q", l.Kind)
		}
		seen[l.Kind] = true
		for k := range l.Values {
			if strings.TrimSpace(k) == "" {
				return Effective{}, fmt.Errorf("config: empty key in %s", l.Kind)
			}
		}
	}
	out := Effective{Values: map[string]string{}, Source: map[string]LayerKind{}}
	ordered := append([]Layer(nil), layers...)
	sort.Slice(ordered, func(i, j int) bool {
		return ordered[i].Kind.rank() < ordered[j].Kind.rank()
	})
	for _, l := range ordered {
		for k, v := range l.Values {
			out.Values[k], out.Source[k] = v, l.Kind
		}
	}
	return out, nil
}

// Bundle is one immutable configuration version.
type Bundle struct {
	Version uint64
	Values  map[string]string
}

// Validate rejects a bundle that cannot be versioned or published.
func (b Bundle) Validate() error {
	if b.Version == 0 {
		return errors.New("config: bundle version must be positive")
	}
	for k := range b.Values {
		if strings.TrimSpace(k) == "" {
			return errors.New("config: bundle has an empty key")
		}
	}
	return nil
}

// Clone detaches the bundle from the caller's map.
func (b Bundle) Clone() Bundle {
	return Bundle{Version: b.Version, Values: cloneValues(b.Values)}
}

// GrayPlan routes a deterministic share of instances to the candidate.
type GrayPlan struct {
	Percent int    // 0..100
	Salt    string // change it to reshuffle without editing configuration
}

// Selects reports whether an instance should read the candidate version.
func (p GrayPlan) Selects(instanceID string) bool {
	if p.Percent <= 0 {
		return false
	}
	if p.Percent >= 100 {
		return true
	}
	return bucketOf(p.Salt, instanceID) < p.Percent
}

// Release couples a stable bundle with an optional candidate bundle.
type Release struct {
	Stable    Bundle
	Candidate *Bundle
	Plan      GrayPlan
}

// For returns the bundle one instance must load.
func (r Release) For(instanceID string) (Bundle, error) {
	if err := r.Stable.Validate(); err != nil {
		return Bundle{}, err
	}
	if r.Candidate == nil {
		return r.Stable.Clone(), nil
	}
	if err := r.Candidate.Validate(); err != nil {
		return Bundle{}, err
	}
	if r.Candidate.Version <= r.Stable.Version {
		return Bundle{}, fmt.Errorf("config: candidate %d must exceed stable %d",
			r.Candidate.Version, r.Stable.Version)
	}
	if r.Plan.Percent < 0 || r.Plan.Percent > 100 {
		return Bundle{}, fmt.Errorf("config: gray percent %d outside 0..100", r.Plan.Percent)
	}
	if strings.TrimSpace(instanceID) == "" {
		return Bundle{}, errors.New("config: empty instance id")
	}
	if r.Plan.Selects(instanceID) {
		return r.Candidate.Clone(), nil
	}
	return r.Stable.Clone(), nil
}

// bucketOf hashes NUL-joined parts into 0..99.
func bucketOf(parts ...string) int {
	h := fnv.New32a()
	for i, p := range parts {
		if i > 0 {
			h.Write([]byte{0})
		}
		h.Write([]byte(p))
	}
	return int(h.Sum32() % 100)
}

func cloneValues(in map[string]string) map[string]string {
	out := make(map[string]string, len(in))
	for k, v := range in {
		out[k] = v
	}
	return out
}

实现里有三个刻意选择。第一,重复层直接报错,避免“传两次环境层”这种调用错误被顺序掩盖。第二,候选版本不高于稳定版本时拒绝,保证版本号始终指向唯一内容。第三,分桶使用 salt + NUL + instanceID,同一实例在同一盐下永远得到同一个桶,换盐才会重新分配。

本机实测

测试覆盖合并优先级、参数顺序无关、分桶比例、0/100/50% 路由和错误候选版本。命令与环境如下,原始输出保存在 .local/reports/be-arch-gov/ch05-be-arch-gov-05-1-config-layering-version-gray.txt。

$ go version
go version go1.26.0 darwin/arm64
$ gofmt -l .
$ go vet ./...
$ go test ./internal/platform/config/ -count=1 -v -run 'TestMerge|TestGrayPlan|TestRelease'
=== RUN   TestMergePrecedence
    config_test.go:39: values=map[db:orders-canary retries:3 timeout_ms:500] source=map[db:instance retries:environment timeout_ms:default]
--- PASS: TestMergePrecedence (0.00s)
=== RUN   TestGrayPlanDeterministic
    config_test.go:59: instances=10000 selected=990 rate=0.0990
--- PASS: TestGrayPlanDeterministic (0.01s)
=== RUN   TestReleaseRouting
    config_test.go:91: stable=506 candidate=494
--- PASS: TestReleaseRouting (0.00s)
PASS
ok  	archlab/internal/platform/config	0.260s

测试块如下:

// file: internal/platform/config/config_test.go
// tier: L1
// verify: go test ./internal/platform/config/ -count=1
package config

import (
	"fmt"
	"reflect"
	"testing"
)

func TestMergePrecedence(t *testing.T) {
	def := Layer{Kind: Default, Values: map[string]string{"timeout_ms": "500", "retries": "2"}}
	env := Layer{Kind: Environment, Values: map[string]string{"retries": "3", "db": "orders"}}
	inst := Layer{Kind: Instance, Values: map[string]string{"db": "orders-canary"}}
	eff, err := Merge(def, env, inst)
	if err != nil {
		t.Fatalf("Merge: %v", err)
	}
	if eff.Values["timeout_ms"] != "500" || eff.Values["retries"] != "3" || eff.Values["db"] != "orders-canary" {
		t.Fatalf("values = %v", eff.Values)
	}
	if eff.Source["timeout_ms"] != Default || eff.Source["retries"] != Environment || eff.Source["db"] != Instance {
		t.Fatalf("source = %v", eff.Source)
	}
	shuffled, err := Merge(inst, def, env)
	if err != nil {
		t.Fatalf("shuffled: %v", err)
	}
	if !reflect.DeepEqual(eff, shuffled) {
		t.Fatalf("merge depends on order: %v vs %v", eff, shuffled)
	}
	if _, err := Merge(def, def); err == nil {
		t.Fatal("duplicate layer accepted")
	}
	if _, err := Merge(Layer{Kind: "instance-override"}); err == nil {
		t.Fatal("unknown layer accepted")
	}
	t.Logf("values=%v source=%v", eff.Values, eff.Source)
}

func TestGrayPlanDeterministic(t *testing.T) {
	plan := GrayPlan{Percent: 10, Salt: "orders-2026-11"}
	selected := 0
	const instances = 10000
	for i := 0; i < instances; i++ {
		id := fmt.Sprintf("order-pod-%05d", i)
		if plan.Selects(id) != plan.Selects(id) {
			t.Fatalf("%s selection is not deterministic", id)
		}
		if plan.Selects(id) {
			selected++
		}
	}
	rate := float64(selected) / instances
	if rate < 0.07 || rate > 0.13 {
		t.Fatalf("selection rate %.4f outside 7%%..13%%", rate)
	}
	t.Logf("instances=%d selected=%d rate=%.4f", instances, selected, rate)
}

func TestReleaseRouting(t *testing.T) {
	stable := Bundle{Version: 41, Values: map[string]string{"retry": "2"}}
	candidate := Bundle{Version: 42, Values: map[string]string{"retry": "4"}}
	for _, tc := range []struct {
		percent int
		want    uint64
	}{{0, 41}, {100, 42}} {
		r := Release{Stable: stable, Candidate: &candidate, Plan: GrayPlan{Percent: tc.percent, Salt: "s"}}
		got, err := r.For("pod-1")
		if err != nil || got.Version != tc.want {
			t.Fatalf("%d%%: %+v, %v", tc.percent, got, err)
		}
	}
	half := Release{Stable: stable, Candidate: &candidate, Plan: GrayPlan{Percent: 50, Salt: "s"}}
	versions := map[uint64]int{}
	for i := 0; i < 1000; i++ {
		b, err := half.For(fmt.Sprintf("pod-%03d", i))
		if err != nil {
			t.Fatalf("50%%: %v", err)
		}
		versions[b.Version]++
	}
	if versions[41] == 0 || versions[42] == 0 {
		t.Fatalf("50%% did not split: %v", versions)
	}
	older := Bundle{Version: 41, Values: map[string]string{"retry": "4"}}
	if _, err := (Release{Stable: stable, Candidate: &older, Plan: GrayPlan{Percent: 50}}).For("pod-1"); err == nil {
		t.Fatal("expected candidate version error")
	}
	t.Logf("stable=%d candidate=%d", versions[41], versions[42])
}

读数是三组。合并层,timeout_ms 来自默认层、retries 来自环境层、db 来自实例层,来源映射与值一起返回,调换参数顺序结果不变,重复层和未知层都被拒绝。分桶层,1 万个实例在 10% 计划下选中 990 个,比例 9.90%,在 7%–13% 的合理区间内。路由层,50% 计划把 1000 个实例分成 506 与 494,0% 和 100% 分别稳定落在版本 41 和 42。

边界必须说清楚:这是单进程、合成实例名、FNV-1a 的结果,不是生产流量证明。真实实例名可能带序号规律,10% 的实际选中率会偏离 9.90%;上线前应拿真实 Pod 名单重跑分桶,并确认发布系统传入的实例标识稳定。0.26 秒只是本机这组测试的耗时,不代表配置下发延迟。

常见错误与边界

  • 用参数顺序决定优先级。调用方顺序一变,合并结果就变;层优先级必须写死在类型里。
  • 实例层没有审计和过期。今天为排障覆盖的值,三个月后没人记得,最终变成事实上的默认值。
  • 候选版本号不递增,或回滚时复用旧版本号。前者让订阅端无法排序,后者让“同版本不同内容”成为常态。
  • 灰度按请求哈希而不是按实例或租户。同一用户在新旧行为之间闪烁,指标也会被污染。
  • 把密钥塞进普通配置包。密钥需要独立权限、轮换和审计,不能和业务参数共用一条合并链路。

小结

配置分层解决的是“谁有权改、改哪一层”,版本号解决的是“这次变更是什么”,灰度解决的是“先让谁看到”。三者缺一不可:只有分层没有版本,回滚靠记忆;只有版本没有灰度,回滚仍然是一次全量切换。

本节实现把三层合并、来源追溯、候选版本校验和按实例分桶放在同一个包里,并用 1 万个合成实例验证了 10% 与 50% 的分流。下一节处理比配置更短命的运行时开关:它们有类型、有生命周期,也会在代码里留下需要定期清理的债。

阅读导航:上一节:4.3 网关失败模式:超时预算、连接池与级联故障 · 下一节:5.2 功能开关:类型、生命周期与开关债 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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