开关不是发布按钮
功能开关看起来像发布按钮:打开就上新逻辑,关闭就回旧逻辑。但对订单系统来说,开关只改变“接下来走哪条代码路径”,不改变“已经写进数据库的数据”。如果新路径写出的记录只有新代码能读,关闭开关并不能让旧代码重新读懂它们;开关退得掉代码,退不掉数据。
开关的通用分类、生命周期与治理策略已在 功能开关与发布 中展开,本节只做两件事:实现一个带不可变评估快照的最小开关求值器,以及把“开关关闭后数据仍可读”写成可运行检查。工具链、控制台、审计流和跨服务 SDK 是平台化问题,本节不展开,留到卷四。
求值契约:先杀开关,再名单,最后百分比
arch-lab 的求值顺序是固定的,任何实现都不能按心情调整:
| 优先级 | 条件 | 结果 | 用途 |
|---|---|---|---|
| 1 | Killed=true | false | 事故时全局关闭,最高优先级 |
| 2 | 客户在 Deny | false | 排除问题客户或内部压测账号 |
| 3 | 客户在 Allow | true | 种子客户、内部账号 |
| 4 | 没有客户标识 | Default | 匿名请求使用保守默认值 |
| 5 | Percentage=0 | Default | 未灰度时保持默认 |
| 6 | Percentage=100 | true | 全量 |
| 7 | 0<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 订单链路的最小观测契约 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。