《后端架构设计·高性能与高可用》9.3 开关与灰度的数据兼容约束

实现 arch-lab 的功能开关:默认值、客户名单、百分比灰度、kill switch 与不可变评估快照,并用测试覆盖开关翻转时的在途请求、同一次请求内多次求值一致,以及关闭开关后旧读取器仍能读新数据,最后说明工具链与平台化为什么留给卷四。

开关不是发布按钮

功能开关看起来像发布按钮:打开就上新逻辑,关闭就回旧逻辑。但对订单系统来说,开关只改变“接下来走哪条代码路径”,不改变“已经写进数据库的数据”。如果新路径写出的记录只有新代码能读,关闭开关并不能让旧代码重新读懂它们;开关退得掉代码,退不掉数据。

开关的通用分类、生命周期与治理策略已在 功能开关与发布 中展开,本节只做两件事:实现一个带不可变评估快照的最小开关求值器,以及把“开关关闭后数据仍可读”写成可运行检查。工具链、控制台、审计流和跨服务 SDK 是平台化问题,本节不展开,留到卷四。

求值契约:先杀开关,再名单,最后百分比

arch-lab 的求值顺序是固定的,任何实现都不能按心情调整:

优先级条件结果用途
1Killed=truefalse事故时全局关闭,最高优先级
2客户在 Denyfalse排除问题客户或内部压测账号
3客户在 Allowtrue种子客户、内部账号
4没有客户标识Default匿名请求使用保守默认值
5Percentage=0Default未灰度时保持默认
6Percentage=100true全量
70<Percentage<100按哈希分桶灰度

kill switch 必须压过白名单,否则“全局关闭”会被内部账号戳穿;deny 必须压过 allow,否则同一个客户同时出现在两个名单时结果取决于列表顺序。百分比分桶用 key + 客户标识 的 FNV-1a,同一开关、同一客户在任意实例上结果一致,不依赖本地随机数。

代码:不可变快照与稳定求值

Evaluator 持有可变配置,Snapshot 是某一版本的深拷贝。请求开始时取一次快照,整条请求链路只读快照,因此发布系统在请求处理中翻转开关,不会让同一次请求的前半段走新逻辑、后半段走旧逻辑。快照同时带版本号,便于日志和 trace 解释“这次请求看到的是哪一版配置”。

// file: internal/flag/flag.go
// tier: L1
// verify: go test ./internal/flag/ -count=1 -v
package flag
import (
	"errors"
	"fmt"
	"hash/fnv"
	"slices"
	"sync"
)
type Flag struct {
	Key        string
	Default    bool
	Percentage int
	Allow      []string
	Deny       []string
	Killed     bool
}
var (
	ErrEmptyKey          = errors.New("flag key is empty")
	ErrInvalidPercentage = errors.New("percentage must be within 0..100")
	ErrUnknownFlag       = errors.New("unknown flag")
)
type Evaluation struct {
	Key        string
	CustomerID string
	Value      bool
	Reason     string
	Version    uint64
}
type Evaluator struct {
	mu      sync.RWMutex
	flags   map[string]Flag
	version uint64
}
func NewEvaluator(flags ...Flag) (*Evaluator, error) {
	e := &Evaluator{flags: map[string]Flag{}}
	for _, f := range flags {
		if err := e.Set(f); err != nil {
			return nil, err
		}
	}
	return e, nil
}
func validate(f Flag) error {
	if f.Key == "" {
		return ErrEmptyKey
	}
	if f.Percentage < 0 || f.Percentage > 100 {
		return ErrInvalidPercentage
	}
	return nil
}

func (e *Evaluator) Set(f Flag) error {
	if err := validate(f); err != nil {
		return err
	}
	e.mu.Lock()
	defer e.mu.Unlock()
	f.Allow = append([]string(nil), f.Allow...)
	f.Deny = append([]string(nil), f.Deny...)
	e.flags[f.Key] = f
	e.version++
	return nil
}

// Kill 是事故开关:只改 Killed,不删除名单和百分比,恢复时可以原样回来。
func (e *Evaluator) Kill(key string, killed bool) error {
	e.mu.Lock()
	defer e.mu.Unlock()
	f, ok := e.flags[key]
	if !ok {
		return fmt.Errorf("%w: %s", ErrUnknownFlag, key)
	}
	f.Killed = killed
	e.flags[key] = f
	e.version++
	return nil
}

type Snapshot struct {
	version uint64
	flags   map[string]Flag
}

func (e *Evaluator) Snapshot() Snapshot {
	e.mu.RLock()
	defer e.mu.RUnlock()
	cp := make(map[string]Flag, len(e.flags))
	for k, f := range e.flags {
		f.Allow = append([]string(nil), f.Allow...)
		f.Deny = append([]string(nil), f.Deny...)
		cp[k] = f
	}
	return Snapshot{version: e.version, flags: cp}
}

func (s Snapshot) Version() uint64 { return s.version }

func (s Snapshot) Bool(key, customerID string) (Evaluation, error) {
	f, ok := s.flags[key]
	if !ok {
		return Evaluation{}, fmt.Errorf("%w: %s", ErrUnknownFlag, key)
	}
	return evaluate(f, customerID, s.version), nil
}

func evaluate(f Flag, customerID string, version uint64) Evaluation {
	ev := Evaluation{Key: f.Key, CustomerID: customerID, Version: version}
	switch {
	case f.Killed:
		ev.Reason = "kill_switch"
		return ev
	case slices.Contains(f.Deny, customerID):
		ev.Reason = "deny_list"
		return ev
	case slices.Contains(f.Allow, customerID):
		ev.Value, ev.Reason = true, "allow_list"
		return ev
	case customerID == "":
		ev.Value, ev.Reason = f.Default, "default_anonymous"
		return ev
	case f.Percentage == 0:
		ev.Value, ev.Reason = f.Default, "default"
		return ev
	case f.Percentage == 100:
		ev.Value, ev.Reason = true, "percentage"
		return ev
	default:
		ev.Value = bucket(f.Key, customerID) < f.Percentage*100
		ev.Reason = "percentage"
		return ev
	}
}

func bucket(key, customerID string) int {
	h := fnv.New32a()
	_, _ = h.Write([]byte(key))
	_, _ = h.Write([]byte{0})
	_, _ = h.Write([]byte(customerID))
	return int(h.Sum32() % 10000)
}

Set、Kill 都递增版本号,快照不可变;旧快照不会因为新配置而改变。代价是配置更新只对新请求生效,已开始的请求继续使用旧版本,这正是“在途请求一致”要的结果。

代码:开关不能修复数据

arch-lab 的开关 checkout_split_payment 打开时,新支付流程会写 IntentID。数据兼容约束只有一条:无论开关开还是关,记录都必须保留旧读取器需要的 AmountCents。开关负责选择写路径,不负责把已经写坏的数据补回来。下面的检查器用旧读取器逐条读取,任何一条失败都记为违规;它不尝试修复,只负责让违规可见。

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

import "errors"

var ErrLegacyAmountMissing = errors.New("legacy amount is missing")

// PaymentRecord 是双写后的支付记录:新字段存在,但旧字段不能丢。
type PaymentRecord struct {
	OrderID     string
	AmountCents int64
	IntentID    string
}

type OldPaymentReader struct{}

func (OldPaymentReader) Read(r PaymentRecord) (int64, error) {
	if r.AmountCents == 0 && r.IntentID != "" {
		return 0, ErrLegacyAmountMissing
	}
	return r.AmountCents, nil
}

type Violation struct {
	OrderID string
	Detail  string
}

// CheckFlagOffCompatibility 模拟关闭开关后旧代码读取全部记录。
func CheckFlagOffCompatibility(records []PaymentRecord) []Violation {
	var out []Violation
	for _, r := range records {
		if _, err := (OldPaymentReader{}).Read(r); err != nil {
			out = append(out, Violation{OrderID: r.OrderID, Detail: err.Error()})
		}
	}
	return out
}

Read 的判据是“金额为零且只有新标识”,这对应最典型的坏写入:新路径认为 IntentID 已经足够,旧路径却只认金额。真实系统还要检查枚举、时间戳和关联表,但判据形状相同:用旧读取器做一次全量或抽样的兼容回放。

测试:在途、重复求值与关闭后的读取

第一个测试模拟请求处理中翻转开关:先取快照,再 kill,快照内的求值必须保持旧值,新快照必须看到新值。第二个测试在同一快照上重复求值 100 次,结果、原因、版本都必须一致。第三个测试验证百分比边界与分布。第四个测试构造正常双写和坏写入两组记录,断言关闭开关后正常组无违规、坏组有违规,并且再次翻转开关不会消除违规。

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

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

func TestSnapshotIsStableForInFlightRequest(t *testing.T) {
	e := mustNew(t, Flag{Key: "checkout_split_payment", Default: false, Percentage: 100})
	snap := e.Snapshot()
	first, err := snap.Bool("checkout_split_payment", "customer-7")
	if err != nil || !first.Value || first.Version != snap.Version() {
		t.Fatalf("before flip eval=%+v err=%v", first, err)
	}
	if err := e.Kill("checkout_split_payment", true); err != nil {
		t.Fatal(err)
	}
	for i := 0; i < 100; i++ {
		again, err := snap.Bool("checkout_split_payment", "customer-7")
		if err != nil || again != first {
			t.Fatalf("in-flight eval changed at %d: %+v vs %+v", i, again, first)
		}
	}
	fresh, _ := e.Snapshot().Bool("checkout_split_payment", "customer-7")
	if fresh.Value || fresh.Reason != "kill_switch" {
		t.Fatalf("new snapshot should see kill switch, got %+v", fresh)
	}
	t.Logf("in_flight value=%v version=%d fresh_value=%v fresh_version=%d", first.Value, first.Version, fresh.Value, fresh.Version)
}

func mustNew(t *testing.T, flags ...Flag) *Evaluator {
	t.Helper()
	e, err := NewEvaluator(flags...)
	if err != nil {
		t.Fatal(err)
	}
	return e
}

func TestPercentageBoundaries(t *testing.T) {
	off := mustNew(t, Flag{Key: "f", Default: false, Percentage: 0})
	on := mustNew(t, Flag{Key: "f", Default: false, Percentage: 100})
	for i := 0; i < 1000; i++ {
		if got, _ := off.Snapshot().Bool("f", fmt.Sprintf("c-%d", i)); got.Value {
			t.Fatalf("0%% enabled customer %d", i)
		}
		if got, _ := on.Snapshot().Bool("f", fmt.Sprintf("c-%d", i)); !got.Value {
			t.Fatalf("100%% disabled customer %d", i)
		}
	}
	half := mustNew(t, Flag{Key: "f", Default: false, Percentage: 50})
	enabled := 0
	const customers = 100000
	for i := 0; i < customers; i++ {
		if got, _ := half.Snapshot().Bool("f", fmt.Sprintf("c-%07d", i)); got.Value {
			enabled++
		}
	}
	observed := float64(enabled) / customers * 100
	errPP := math.Abs(observed - 50)
	t.Logf("percentage=50 customers=%d enabled=%d observed=%.4f%% error=%.4fpp", customers, enabled, observed, errPP)
	if errPP > 0.5 {
		t.Fatalf("distribution error %.4fpp", errPP)
	}
}

func TestKillSwitchBeatsAllowList(t *testing.T) {
	e := mustNew(t, Flag{Key: "f", Default: true, Percentage: 100, Allow: []string{"vip"}})
	if ev, _ := e.Snapshot().Bool("f", "vip"); !ev.Value {
		t.Fatalf("allow list not applied: %+v", ev)
	}
	if err := e.Kill("f", true); err != nil {
		t.Fatal(err)
	}
	ev, _ := e.Snapshot().Bool("f", "vip")
	if ev.Value || ev.Reason != "kill_switch" {
		t.Fatalf("kill switch lost to allow list: %+v", ev)
	}
}

func TestFlagOffCompatibility(t *testing.T) {
	good := []PaymentRecord{
		{OrderID: "o-1", AmountCents: 1999, IntentID: "pi_1"},
		{OrderID: "o-2", AmountCents: 2999, IntentID: "pi_2"},
	}
	bad := append([]PaymentRecord(nil), good...)
	bad = append(bad, PaymentRecord{OrderID: "o-3", IntentID: "pi_3"})
	if v := CheckFlagOffCompatibility(good); len(v) != 0 {
		t.Fatalf("dual-written records should be readable: %+v", v)
	}
	if v := CheckFlagOffCompatibility(bad); len(v) != 1 || v[0].OrderID != "o-3" {
		t.Fatalf("bad record not detected: %+v", v)
	}
	e := mustNew(t, Flag{Key: "checkout_split_payment", Default: false, Percentage: 100})
	_ = e.Kill("checkout_split_payment", true)
	if v := CheckFlagOffCompatibility(bad); len(v) != 1 {
		t.Fatalf("turning the flag off must not repair data: %+v", v)
	}
	t.Log("flag_off violations_good=0 violations_bad=1 repaired_by_switch=false")
}

真实运行结果

以下输出来自本机 macOS(darwin/arm64,Go 1.26.0),在独立模块 .local/checks/be-arch-lab/pages/be-arch-perf-09-3-feature-flag-data-compatibility/ 执行 go test -v ./... -count=1,单次运行。原始输出保存在 .local/reports/be-arch/be-arch-perf-09-3-feature-flag-data-compatibility-snapshot-compat.txt。

$ go test -v ./... -count=1
=== RUN   TestSnapshotIsStableForInFlightRequest
    flag_test.go:29: in_flight value=true version=1 fresh_value=false fresh_version=2
--- PASS: TestSnapshotIsStableForInFlightRequest (0.00s)
=== RUN   TestPercentageBoundaries
    flag_test.go:62: percentage=50 customers=100000 enabled=49958 observed=49.9580% error=0.0420pp
--- PASS: TestPercentageBoundaries (0.04s)
=== RUN   TestKillSwitchBeatsAllowList
--- PASS: TestKillSwitchBeatsAllowList (0.00s)
=== RUN   TestFlagOffCompatibility
    flag_test.go:100: flag_off violations_good=0 violations_bad=1 repaired_by_switch=false
--- PASS: TestFlagOffCompatibility (0.00s)
PASS
ok  	archlab/internal/flag	0.250s

两个数字值得注意。第一,50% 灰度在 10 万客户上观测到 49.9580%,误差 0.042 个百分点,和 9.1 的分流器同量级;这说明按 key+customer 分桶的分布是稳定的,不是每次请求重新随机。第二,关闭开关后旧读取器的违规数仍然是 1,repaired_by_switch=false 是本节最重要的断言:开关只能改变后续写入路径,不能让已经缺失的 AmountCents 自动出现。

工具链与平台化的边界

本节实现的是库级求值器:配置在进程内存里,快照由应用自己取,违规检查在测试或离线任务里跑。生产平台还要解决四类问题:配置的持久化与多实例推送、变更审计与审批、按环境和租户隔离、以及跨语言 SDK 的一致性。这些属于卷四的发布平台与治理范围,本节不假装已经具备。

库级实现仍然有价值:它把语义固定下来。平台可以替换存储和推送方式,但求值优先级、快照不可变、版本号随配置递增、kill switch 压过名单这四条不能变。否则同一个开关在 Go 服务和 Java 服务上会得到不同结果,排查时连“到底开没开”都说不清。

常见错误与边界

第一,请求处理中多次读取实时配置。前半段看到开、后半段看到关,会写出半新半旧的数据;请求入口取一次快照,链路内只读快照。

第二,用百分比代替数据兼容。百分比只决定谁走新路径,不保证新路径写出的数据旧代码能读;两者要分别测试。

第三,认为关闭开关等于回滚。关闭只影响后续请求,已经写入的新格式记录仍在;要么双写兼容,要么准备数据修复。

小结

功能开关的求值语义可以用很小的库固定下来:kill switch、名单、默认值、百分比、稳定分桶,加上不可变快照与版本号。arch-lab 的测试证明,开关翻转不会改变在途请求的求值结果,50% 灰度在 10 万客户上的分布误差约 0.04 个百分点;更重要的是,关闭开关后坏数据仍然违规,开关不能修复数据。下一篇进入观测:订单链路需要一份最小但可执行的 trace 与指标契约。

阅读导航:上一节:9.2 回滚、兼容窗口与数据迁移 · 下一节:10.1 订单链路的最小观测契约 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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