一个没人敢删的开关
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 和百分比求值。这样到期不会改变流量,只会让债务报告多一条记录。真正的关闭动作由人确认,并伴随代码清理。
开关债报告
| 检查项 | 触发条件 | 严重度 | 建议动作 |
|---|---|---|---|
| 过期 | 超过 ExpiresAt | 3 | 确认分支后删除 |
| 缺负责人 | Owner 为空 | 2 | 补 owner,禁止上线 |
| 缺过期时间 | 非权限开关 ExpiresAt 为零 | 2 | 补日期或改类型 |
| 超龄 | 年龄超过类型上限 | 1 | 排入下一次清理 |
| 缺清理工单 | 发布开关已 0% 或 100% | 1 | 建删除工单 |
| 百分比非法 | 不在 0..100 | 3 | 修正配置 |
报告按严重度、年龄和 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 动态配置一致性:推送、轮询与回滚 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。