《后端架构设计·演进与治理》5.2 功能开关:类型、生命周期与开关债

用 arch-lab 订单系统一个上线九个月仍未删除的 checkout-v2 开关开场,区分发布、实验、运维和权限四类开关的生命周期,给出开关求值、过期检测以及缺失责任人、过期时间和清理工单的债务审计 Go 实现,并附 6 个开关样本的真实测试输出。

一个没人敢删的开关

arch-lab 订单系统的 checkout-v2 开关在 2025 年 1 月上线,三个月后全量,但代码里的旧结算分支一直留着。到 2026 年 10 月,这个开关仍然存在:没人知道它还控制着什么,也没人敢删,因为删除需要同时改支付、优惠券和风控三个模块。

同一时期还有一个实验开关 promo-band,过期两个月后仍然开着;一个运维开关 inventory-shadow 没有写过期时间,被当作常驻配置使用。事故发生时,值班同学想关掉 checkout-v2 止血,却发现关闭后旧分支会写出不同格式的订单事件,下游直接解析失败。开关本身没有坏,坏的是它从来没有被当作有生命周期的资产来管理。

开关治理要回答三个问题:这个开关属于哪一类,它应该活多久,谁负责在到期前把它删掉;功能开关的发布实践 也把删除责任放在与放量同等的位置。功能开关与数据兼容 讨论过开关与数据格式的耦合,本节把重点放在类型、生命周期和可审计的开关债上。

四类开关,四种寿命

类型目的典型寿命默认上限到期动作
release控制新版本放量一次发布周期90 天删除旧分支和开关
experiment验证一个假设一个实验周期45 天采纳或放弃,删除
ops降级、限流、熔断一次故障或活动30 天恢复默认并删除
permission长期权限或套餐差异产品生命周期365 天复核保留,但每年复核

四类开关的失败模式不同。发布开关忘记删,留下的是永远不会执行的分支和持续膨胀的配置;实验开关忘记删,留下的是没有结论的流量分配;运维开关忘记删,可能让系统长期运行在降级状态;权限开关忘记删,则会把临时授权固化成事实上的产品能力。

因此不能用同一条规则管所有开关。发布和实验开关必须有到期时间,运维开关的期限更短,权限开关可以没有到期时间,但必须有负责人和年度复核。把权限开关误判成发布开关,会在到期时错误关闭付费用户的功能;把发布开关误判成权限开关,则会永久保留一段死代码。

过期不是自动关闭

一个常见的设计是“到期自动关闭开关”。这很危险:开关到期只说明它该被清理,不说明当前应该走哪条分支。自动关闭可能把全量中的新功能回退,也可能打开一个从未验证过的旧分支。到期应该触发告警、工单和发布门禁,而不是在运行时悄悄改变行为。

本节的实现把生命周期做成只读推导:Lifecycle 根据 ExpiresAt 返回 active、expired 或 permanent,Evaluate 不读取这个状态,只根据 enabled 和百分比求值。这样到期不会改变流量,只会让债务报告多一条记录。真正的关闭动作由人确认,并伴随代码清理。

开关债报告

检查项触发条件严重度建议动作
过期超过 ExpiresAt3确认分支后删除
缺负责人Owner 为空2补 owner,禁止上线
缺过期时间非权限开关 ExpiresAt 为零2补日期或改类型
超龄年龄超过类型上限1排入下一次清理
缺清理工单发布开关已 0% 或 100%1建删除工单
百分比非法不在 0..1003修正配置

报告按严重度、年龄和 key 排序,输出一行汇总。它适合放在发布流水线里做“债务不增长”门禁:本次变更可以新增开关,但必须同时给出过期时间和清理工单,否则报告数量上升,发布被拒绝。

可运行实现

下面的代码与 5.1 共用 config 包。Evaluate 负责运行时求值,AuditDebt 负责离线审计;两者都不修改输入,也不依赖数据库或外部服务。

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

import (
	"errors"
	"fmt"
	"sort"
	"strings"
	"time"
)

// FlagType decides the lifetime and cleanup rule of a switch.
type FlagType string

const (
	ReleaseFlag    FlagType = "release"
	ExperimentFlag FlagType = "experiment"
	OpsFlag        FlagType = "ops"
	PermissionFlag FlagType = "permission"
)

type Lifecycle string

const (
	LifecycleActive    Lifecycle = "active"
	LifecycleExpired   Lifecycle = "expired"
	LifecyclePermanent Lifecycle = "permanent"
)

// Flag is one switch. ExpiresAt is mandatory except for permission flags.
type Flag struct {
	Key            string
	Type           FlagType
	Owner          string
	CreatedAt      time.Time
	ExpiresAt      time.Time
	CleanupIssue   string
	Enabled        bool
	RolloutPercent int
}

// Lifecycle derives the flag state at a point in time.
func (f Flag) Lifecycle(now time.Time) Lifecycle {
	if f.ExpiresAt.IsZero() {
		if f.Type == PermissionFlag {
			return LifecyclePermanent
		}
		return LifecycleActive
	}
	if !now.Before(f.ExpiresAt) {
		return LifecycleExpired
	}
	return LifecycleActive
}

// Evaluate is sticky per subject; disabled wins over every percentage.
func (f Flag) Evaluate(subject string) (bool, error) {
	if strings.TrimSpace(f.Key) == "" {
		return false, errors.New("config: flag key is empty")
	}
	if f.RolloutPercent < 0 || f.RolloutPercent > 100 {
		return false, fmt.Errorf("config: flag %q rollout %d outside 0..100", f.Key, f.RolloutPercent)
	}
	if !f.Enabled {
		return false, nil
	}
	if f.RolloutPercent >= 100 {
		return true, nil
	}
	return bucketOf(f.Key, subject) < f.RolloutPercent, nil
}

// Policy sets the maximum age per flag type. A nil map means no limit.
type Policy struct {
	MaxAge map[FlagType]time.Duration
}

// DefaultPolicy mirrors the lifecycle table used in this chapter.
func DefaultPolicy() Policy {
	return Policy{MaxAge: map[FlagType]time.Duration{
		ReleaseFlag: 90 * 24 * time.Hour, ExperimentFlag: 45 * 24 * time.Hour,
		OpsFlag: 30 * 24 * time.Hour, PermissionFlag: 365 * 24 * time.Hour,
	}}
}

// DebtItem is one flag that needs an owner action before removal.
type DebtItem struct {
	Key      string
	Type     FlagType
	Owner    string
	AgeDays  int
	Severity int
	Reasons  []string
}

func (d *DebtItem) add(severity int, reason string) {
	d.Reasons = append(d.Reasons, reason)
	if severity > d.Severity {
		d.Severity = severity
	}
}

// DebtReport is the switch-debt view produced by AuditDebt.
type DebtReport struct {
	Total        int
	Items        []DebtItem
	Expired      int
	OverAge      int
	MissingOwner int
}

func AuditDebt(flags []Flag, now time.Time, policy Policy) DebtReport {
	report := DebtReport{Total: len(flags)}
	for _, f := range flags {
		item := DebtItem{Key: f.Key, Type: f.Type, Owner: f.Owner}
		if f.Key == "" {
			item.add(3, "missing key")
		}
		switch f.Type {
		case ReleaseFlag, ExperimentFlag, OpsFlag, PermissionFlag:
		default:
			item.add(3, "unknown type")
		}
		if strings.TrimSpace(f.Owner) == "" {
			item.add(2, "missing owner")
		}
		if f.RolloutPercent < 0 || f.RolloutPercent > 100 {
			item.add(3, "rollout percent out of range")
		}
		if f.CreatedAt.IsZero() {
			item.add(2, "missing created_at")
		} else {
			item.AgeDays = int(now.Sub(f.CreatedAt).Hours() / 24)
		}
		switch {
		case f.ExpiresAt.IsZero() && f.Type != PermissionFlag:
			item.add(2, "missing expiry")
		case f.ExpiresAt.IsZero():
			if limit := policy.MaxAge[f.Type]; limit > 0 && item.AgeDays > int(limit.Hours()/24) {
				item.add(1, fmt.Sprintf("age %d days over %s review limit", item.AgeDays, f.Type))
			}
		case !now.Before(f.ExpiresAt):
			item.add(3, "expired")
		default:
			if limit := policy.MaxAge[f.Type]; limit > 0 && item.AgeDays > int(limit.Hours()/24) {
				item.add(1, fmt.Sprintf("age %d days over %s limit %d days",
					item.AgeDays, f.Type, int(limit.Hours()/24)))
			}
		}
		if f.Type == ReleaseFlag && (f.RolloutPercent == 0 || f.RolloutPercent == 100) && f.CleanupIssue == "" {
			item.add(1, "fully rolled out without cleanup issue")
		}
		if len(item.Reasons) == 0 {
			continue
		}
		for _, reason := range item.Reasons {
			switch reason {
			case "expired":
				report.Expired++
			case "missing owner":
				report.MissingOwner++
			}
			if strings.HasPrefix(reason, "age ") {
				report.OverAge++
			}
		}
		report.Items = append(report.Items, item)
	}
	sort.Slice(report.Items, func(i, j int) bool {
		a, b := report.Items[i], report.Items[j]
		if a.Severity != b.Severity {
			return a.Severity > b.Severity
		}
		if a.AgeDays != b.AgeDays {
			return a.AgeDays > b.AgeDays
		}
		return a.Key < b.Key
	})
	return report
}

func (r DebtReport) Summary() string {
	return fmt.Sprintf("flags=%d debt=%d expired=%d over_age=%d missing_owner=%d",
		r.Total, len(r.Items), r.Expired, r.OverAge, r.MissingOwner)
}

本机实测

测试覆盖禁用优先、100% 全开、30% 按主体粘性分流,以及 6 个开关样本的债务报告。命令与环境如下,原始输出保存在 .local/reports/be-arch-gov/ch05-be-arch-gov-05-2-feature-flag-types-lifecycle-debt.txt。

$ go version
go version go1.26.0 darwin/arm64
$ gofmt -l .
$ go vet ./...
$ go test ./internal/platform/config/ -count=1 -v -run 'TestFlag|TestAuditDebt'
=== RUN   TestFlagEvaluate
    flags_test.go:45: subjects=10000 selected=3006 rate=0.3006
--- PASS: TestFlagEvaluate (0.00s)
=== RUN   TestAuditDebt
    flags_test.go:91: flag=promo-band type=experiment age=60d severity=3 reasons=expired
    flags_test.go:91: flag=checkout-v2 type=release age=120d severity=2 reasons=missing owner;age 120 days over release limit 90 days;fully rolled out without cleanup issue
    flags_test.go:91: flag=inventory-shadow type=ops age=40d severity=2 reasons=missing expiry
    flags_test.go:91: flag=old-ops type=ops age=45d severity=1 reasons=age 45 days over ops limit 30 days
    flags_test.go:94: flags=6 debt=4 expired=1 over_age=2 missing_owner=1
--- PASS: TestAuditDebt (0.00s)
PASS
ok  	archlab/internal/platform/config	0.514s

测试块如下:

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

import (
	"fmt"
	"strings"
	"testing"
	"time"
)

func TestFlagEvaluate(t *testing.T) {
	off := Flag{Key: "checkout-v2", Enabled: false, RolloutPercent: 100}
	if on, err := off.Evaluate("pod-1"); err != nil || on {
		t.Fatalf("disabled = %v, %v", on, err)
	}
	full := Flag{Key: "checkout-v2", Enabled: true, RolloutPercent: 100}
	if on, err := full.Evaluate("pod-1"); err != nil || !on {
		t.Fatalf("100%% = %v, %v", on, err)
	}
	partial := Flag{Key: "checkout-v2", Enabled: true, RolloutPercent: 30}
	selected := 0
	const subjects = 10000
	for i := 0; i < subjects; i++ {
		subject := fmt.Sprintf("tenant-%05d", i)
		first, err := partial.Evaluate(subject)
		if err != nil {
			t.Fatalf("Evaluate: %v", err)
		}
		if second, _ := partial.Evaluate(subject); first != second {
			t.Fatalf("%s changed between calls", subject)
		}
		if first {
			selected++
		}
	}
	rate := float64(selected) / subjects
	if rate < 0.27 || rate > 0.33 {
		t.Fatalf("rollout rate %.4f outside 27%%..33%%", rate)
	}
	if _, err := (Flag{Key: "bad", Enabled: true, RolloutPercent: 101}).Evaluate("pod-1"); err == nil {
		t.Fatal("expected rollout error")
	}
	t.Logf("subjects=%d selected=%d rate=%.4f", subjects, selected, rate)
}

func TestAuditDebt(t *testing.T) {
	now := time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC)
	flags := []Flag{
		{Key: "promo-band", Type: ExperimentFlag, Owner: "growth",
			CreatedAt: now.Add(-60 * 24 * time.Hour), ExpiresAt: now.Add(-5 * 24 * time.Hour),
			Enabled: true, RolloutPercent: 100},
		{Key: "checkout-v2", Type: ReleaseFlag,
			CreatedAt: now.Add(-120 * 24 * time.Hour), ExpiresAt: now.Add(10 * 24 * time.Hour),
			Enabled: true, RolloutPercent: 100},
		{Key: "inventory-shadow", Type: OpsFlag, Owner: "sre",
			CreatedAt: now.Add(-40 * 24 * time.Hour), Enabled: true, RolloutPercent: 10},
		{Key: "old-ops", Type: OpsFlag, Owner: "sre",
			CreatedAt: now.Add(-45 * 24 * time.Hour), ExpiresAt: now.Add(24 * time.Hour),
			Enabled: true, RolloutPercent: 50},
		{Key: "legacy-admin", Type: PermissionFlag, Owner: "security",
			CreatedAt: now.Add(-10 * 24 * time.Hour), Enabled: true, RolloutPercent: 100},
		{Key: "new-experiment", Type: ExperimentFlag, Owner: "growth",
			CreatedAt: now.Add(-5 * 24 * time.Hour), ExpiresAt: now.Add(10 * 24 * time.Hour),
			Enabled: true, RolloutPercent: 25},
	}
	report := AuditDebt(flags, now, DefaultPolicy())
	if report.Total != 6 || len(report.Items) != 4 {
		t.Fatalf("total=%d debt=%d", report.Total, len(report.Items))
	}
	if report.Expired != 1 || report.OverAge != 2 || report.MissingOwner != 1 {
		t.Fatalf("counters expired=%d over_age=%d missing_owner=%d",
			report.Expired, report.OverAge, report.MissingOwner)
	}
	wantOrder := []string{"promo-band", "checkout-v2", "inventory-shadow", "old-ops"}
	for i, want := range wantOrder {
		if report.Items[i].Key != want {
			t.Fatalf("item[%d] = %s, want %s", i, report.Items[i].Key, want)
		}
	}
	reasons := strings.Join(report.Items[1].Reasons, ";")
	if !strings.Contains(reasons, "missing owner") || !strings.Contains(reasons, "cleanup") {
		t.Fatalf("checkout-v2 reasons = %s", reasons)
	}
	const wantSummary = "flags=6 debt=4 expired=1 over_age=2 missing_owner=1"
	if got := report.Summary(); got != wantSummary {
		t.Fatalf("summary = %q", got)
	}
	for _, it := range report.Items {
		t.Logf("flag=%s type=%s age=%dd severity=%d reasons=%s",
			it.Key, it.Type, it.AgeDays, it.Severity, strings.Join(it.Reasons, ";"))
	}
	t.Logf("%s", report.Summary())
}

读数分两层。求值层,1 万个主体在 30% 计划下选中 3006 个,比例 30.06%,同一主体重复求值结果一致,禁用开关即使配置 100% 也返回 false。审计层,6 个开关中 4 个有债:promo-band 已过期,checkout-v2 缺负责人、超龄且没有清理工单,inventory-shadow 缺过期时间,old-ops 超过 30 天上限;两个健康开关没有被误报。

边界要说清楚:样本日期固定在 2026-11-03,年龄按 24 小时整除计算,跨时区和夏令时会让天数差一天;这是审计口径,不是计费口径。哈希分桶是 FNV-1a,只保证稳定,不保证抗对抗性。0.51 秒是本机测试耗时,不代表生产审计任务的耗时,真实开关数量可能高几个数量级。

常见错误与边界

  • 所有开关都给一个统一的过期时间。实验、发布、运维的合理寿命不同,统一期限要么误删权限开关,要么放过长期降级。
  • 到期自动关闭。到期是清理信号,不是流量决策;自动关闭可能回退全量功能或打开未验证分支。
  • 只统计数量,不看严重度。100 个低危超龄开关和 1 个过期资金开关不是同一件事。
  • 开关求值按请求随机。同一用户会在新旧行为之间闪烁,灰度和实验结论都不可信。

小结

开关不是配置的语法糖,而是有类型、有寿命、有责任人的资产。发布和实验开关必须在到期前删除,运维开关要尽快恢复默认,权限开关可以长期存在但需要年度复核;到期只触发审计,不自动改变运行时行为。

本节用 Evaluate 保证求值粘性,用 AuditDebt 把过期、缺负责人、缺期限、超龄和缺清理工单变成可排序的报告。下一节转向更底层的动态配置同步:当配置中心同时存在推送和轮询时,实例如何用版本水位拒绝陈旧数据,并在需要时安全回滚。

阅读导航:上一节:5.1 配置分层、版本与灰度发布 · 下一节:5.3 动态配置一致性:推送、轮询与回滚 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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