一次改错默认值的发布
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 功能开关:类型、生命周期与开关债 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。