一个没人说得清的开关
去年大促前,「刚好」的结算团队加了一个开关 checkout_fallback_v2。当时支付渠道不稳定,开关打开后结算页会跳过优惠试算,直接按原价下单,牺牲优惠换取下单成功率。大促结束后,大家忙着复盘和补需求,开关一直开着,后来连名字都没人再提。
半年后的一个周五晚上,新来的工程师做配置清理,看到这个开关没有描述、没有 owner、最后修改时间还是半年前,判断它是废弃配置,删掉了。删除后的第二个小时,支付渠道抖动,结算页没有降级路径,优惠试算超时把整个下单接口拖垮,故障持续了四十分钟。
事后复盘发现三个问题。第一,开关没有 owner,没人能确认它是否还在用;第二,开关没有过期时间,临时配置变成了永久配置;第三,删除开关没有审批和影响分析,配置平台把它当成一条普通记录。三件事单独看都不致命,合在一起就让一个“临时”开关在半年后引发了生产故障。
开关是运行时配置,不是发布
很多团队把开关当成“代码里的 if”,发布时顺手加一个,之后就不管了。但开关和代码有本质区别:代码变更走发布流程,有评审、有回滚、有版本;开关变更走配置平台,改一个数字就能影响全部流量,几分钟内生效,往往没有同等强度的评审。
| 字段 | 作用 | 缺失后的后果 |
|---|---|---|
name | 唯一标识,同时作为分流 salt | 重名或改名会重排用户,历史数据无法复现 |
owner | 谁负责确认开关是否仍需要 | 没人敢删,也没人敢关,临时配置永久化 |
enabled | 总开关,关闭时永远返回 false | 关闭不彻底,残留流量继续走旧逻辑 |
percent | 放量比例,0..100 | 没有灰度能力,只能全开或全关 |
default | 配置缺失时的兜底值 | 配置中心故障时行为不确定 |
expires_at | 计划清理时间 | 到期无人处理,开关悄悄变成基础设施 |
description | 一句话说明它控制什么 | 半年后没人知道该不该删 |
audit | 谁在什么时候改了什么 | 故障时无法定位变更来源 |
开关治理的核心不是技术,而是把“临时”变成可追踪的状态。一个合格的开关至少要有 owner、默认值、过期时间和一句话描述;删除或修改要留下审计记录;到期未清理要自动提醒 owner,而不是等下一个新人来“打扫卫生”。
默认值尤其重要。配置中心可能超时、返回空、返回半截 JSON,这时代码必须有确定的兜底行为。安全相关的开关应该失败关闭:读不到配置就按最保守的分支走。降级开关则相反,读不到配置时可能要保持降级,避免把流量打回不稳定链路。默认值不是“随便填一个”,而是故障场景下的明确决策。
失败场景:删掉一个“看起来没用”的开关
回到开头。checkout_fallback_v2 被删除后,代码里的读取逻辑拿到空配置。如果实现是“读不到就当作 false”,结算页会走正常优惠试算;如果实现是“读不到就 panic”,服务直接不可用。这个团队用的是前者,所以故障不是立刻发生,而是等到支付渠道抖动、试算超时叠加时才爆发。
更隐蔽的是,开关名字里的 v2 让新人以为它是旧版本的残留。实际上它是当前唯一的降级入口,v1 早在半年前就被下线了。名字没有描述、没有 owner、没有过期时间,三条信息全部缺失,删除看起来完全合理。
第二个变种是“开关打开了,但没人知道它控制什么”。运维看到 checkout_fallback_v2=true,以为是开启降级;实际代码里这个值表示“使用新版降级策略”,关闭才是旧策略。布尔值的语义没有写清楚,值班同学在故障时按直觉操作,反而扩大了影响。
第三个变种是放量比例没有下限保护。配置平台允许写 percent=1000,代码直接比较 bucket < 1000,于是所有用户命中。校验缺失让一个手误变成全量变更。开关的输入校验必须在服务端做,不能只依赖配置平台的前端表单。
用 Flag、Validate 与 Evaluate 管住开关
Validate 负责检查开关定义,Evaluate 负责判断某个用户是否命中。命名、比例和用户校验都在服务端完成,配置平台只是存储和展示。
// file: internal/experiment/config.go
// tier: L1
// verify: go test ./internal/experiment/
package experiment
import (
"errors"
"fmt"
"strings"
)
var (
// ErrEmptyFlagName 表示开关没有名字,无法定位和审计。
ErrEmptyFlagName = errors.New("experiment: empty flag name")
// ErrInvalidPercent 表示放量比例不在 0..100。
ErrInvalidPercent = errors.New("experiment: percent out of range")
)
// Flag 是一个运行时开关。Name 同时充当分流 salt,因此同名开关的用户命中
// 结果稳定;Percent 是启用的百分比,0 表示全关,100 表示全开。
type Flag struct {
Name string
Enabled bool
Percent int
}
// Validate 检查开关定义本身是否合法。它不关心某个用户是否命中,适合在
// 配置平台保存、发布前调用。Enabled=false 时 Percent 仍必须是 0..100,
// 避免关闭期间写入脏值,之后重新打开时突然全量。
func Validate(f Flag) error {
if strings.TrimSpace(f.Name) == "" {
return ErrEmptyFlagName
}
if f.Percent < 0 || f.Percent > 100 {
return fmt.Errorf("%w: %d", ErrInvalidPercent, f.Percent)
}
return nil
}
// Evaluate 判断用户在某个开关下是否命中。Enabled=false 时直接返回 false,
// 不再看 Percent,也不要求 userID 非空,保证“关闭”是真正的兜底。
// 开启后先用 Validate 校验定义,再按 Name 作为 salt 稳定分流。
func Evaluate(f Flag, userID string) (bool, error) {
if !f.Enabled {
return false, nil
}
if err := Validate(f); err != nil {
return false, err
}
if userID == "" {
return false, ErrEmptyUserID
}
b, err := Bucket(userID, "flag:"+f.Name, 100)
if err != nil {
return false, err
}
return b < f.Percent, nil
}
实现里有几个关键点:
Validate只检查定义本身:名字不能为空白,比例必须在0..100。它可以在配置保存、发布、启动加载时反复调用,成本很低。- 名字用
strings.TrimSpace判断,避免只由空格组成的名字绕过校验。名字同时是分流 salt,所以改名等于重排用户,必须谨慎。 Evaluate在Enabled=false时直接返回false,不校验比例,也不要求用户 ID。关闭是最高优先级的兜底,配置再脏也不能让流量漏过去。- 开启后才调用
Validate,再校验用户 ID 非空。空用户 ID 不能落到 0 号桶,否则匿名流量会全部命中或全部不命中。 - salt 使用
"flag:"+Name,给开关一个独立命名空间,避免和实验分流共用同一套哈希输入。 - 比例用
bucket < Percent比较,Percent=0全不命中,Percent=100全命中。边界不需要特判,因为桶的范围就是0..99。 - 同一用户在同一开关下结果稳定,因为
Bucket是确定性哈希。放量比例从 10 调到 30 时,原来命中的用户仍然命中,新增的是桶号在10..29的用户,不会整体重排。
这套实现不负责存储、推送和审计,那些属于配置平台。服务端只保证“拿到一条配置后,行为确定、可复现、失败关闭”。
测试:关闭兜底、边界与稳定性
开关测试要覆盖四类问题:关闭时是否绝对不命中、比例边界是否正确、同一用户是否稳定、非法配置是否被挡住。
// file: internal/experiment/config_test.go
// tier: L1
// verify: go test ./internal/experiment/
package experiment
import (
"errors"
"testing"
)
func TestValidate(t *testing.T) {
if err := Validate(Flag{Name: "new-checkout", Percent: 50}); err != nil {
t.Fatalf("valid flag = %v, want nil", err)
}
if err := Validate(Flag{Name: " ", Percent: 10}); !errors.Is(err, ErrEmptyFlagName) {
t.Fatalf("blank name = %v, want ErrEmptyFlagName", err)
}
for _, p := range []int{-1, 101} {
if err := Validate(Flag{Name: "x", Percent: p}); !errors.Is(err, ErrInvalidPercent) {
t.Fatalf("percent=%d = %v, want ErrInvalidPercent", p, err)
}
}
}
func TestEvaluateDisabled(t *testing.T) {
for _, p := range []int{0, 50, 100, 200} {
hit, err := Evaluate(Flag{Name: "x", Enabled: false, Percent: p}, "")
if err != nil {
t.Fatalf("disabled percent=%d = %v, want nil", p, err)
}
if hit {
t.Fatalf("disabled percent=%d hit = true, want false", p)
}
}
}
func TestEvaluateBounds(t *testing.T) {
for i := 0; i < 200; i++ {
user := "bound-user-" + itoa(i)
if hit, err := Evaluate(Flag{Name: "zero", Enabled: true, Percent: 0}, user); err != nil || hit {
t.Fatalf("percent=0 user=%s = %v, %v; want false, nil", user, hit, err)
}
if hit, err := Evaluate(Flag{Name: "full", Enabled: true, Percent: 100}, user); err != nil || !hit {
t.Fatalf("percent=100 user=%s = %v, %v; want true, nil", user, hit, err)
}
}
}
func TestEvaluateStable(t *testing.T) {
f := Flag{Name: "new-search", Enabled: true, Percent: 37}
first, err := Evaluate(f, "u-1")
if err != nil {
t.Fatalf("Evaluate = %v, want nil", err)
}
second, _ := Evaluate(f, "u-1")
if first != second {
t.Fatalf("Evaluate changed from %v to %v", first, second)
}
}
func TestEvaluateRejectsBadInput(t *testing.T) {
if _, err := Evaluate(Flag{Name: "", Enabled: true, Percent: 10}, "u-1"); !errors.Is(err, ErrEmptyFlagName) {
t.Fatalf("empty name = %v, want ErrEmptyFlagName", err)
}
if _, err := Evaluate(Flag{Name: "x", Enabled: true, Percent: 101}, "u-1"); !errors.Is(err, ErrInvalidPercent) {
t.Fatalf("percent=101 = %v, want ErrInvalidPercent", err)
}
if _, err := Evaluate(Flag{Name: "x", Enabled: true, Percent: 10}, ""); !errors.Is(err, ErrEmptyUserID) {
t.Fatalf("empty user = %v, want ErrEmptyUserID", err)
}
}
func TestEvaluateDistribution(t *testing.T) {
const users = 10000
f := Flag{Name: "rollout-30", Enabled: true, Percent: 30}
hits := 0
for i := 0; i < users; i++ {
hit, err := Evaluate(f, "dist-user-"+itoa(i))
if err != nil {
t.Fatalf("Evaluate = %v, want nil", err)
}
if hit {
hits++
}
}
share := float64(hits) / users
if share < 0.27 || share > 0.33 {
t.Fatalf("hit share = %.3f, want 0.30 ±0.03", share)
}
}
TestValidate 覆盖合法配置、空白名字和 -1、101 两个越界比例。TestEvaluateDisabled 用 0、50、100、200 四种比例验证“关闭时永远返回 false”,包括非法比例,因为关闭分支在 Validate 之前。
TestEvaluateBounds 对 200 个用户验证 Percent=0 全不命中、Percent=100 全命中。TestEvaluateStable 对同一用户重复调用,断言结果不变。TestEvaluateRejectsBadInput 覆盖空名字、越界比例和空用户 ID。TestEvaluateDistribution 用一万个用户验证 30% 放量的命中比例在 27% 到 33% 之间。
这些测试固定了“关闭优先于校验”的语义。如果以后有人把 Validate 挪到 Enabled 判断之前,TestEvaluateDisabled 会立刻失败,提醒他关闭兜底被破坏了。
运行命令与真实输出
在仓库根目录执行:
cd .local/checks/webapp-lab && go test ./internal/experiment/ -run 'TestValidate|TestEvaluate' -count=1 -v
一次本机运行的真实输出如下,耗时随机器变化:
=== RUN TestValidate
--- PASS: TestValidate (0.00s)
=== RUN TestEvaluateDisabled
--- PASS: TestEvaluateDisabled (0.00s)
=== RUN TestEvaluateBounds
--- PASS: TestEvaluateBounds (0.00s)
=== RUN TestEvaluateStable
--- PASS: TestEvaluateStable (0.00s)
=== RUN TestEvaluateRejectsBadInput
--- PASS: TestEvaluateRejectsBadInput (0.00s)
=== RUN TestEvaluateDistribution
--- PASS: TestEvaluateDistribution (0.00s)
PASS
ok webapp/internal/experiment 0.271s
PASS 表示 6 个开关用例全部通过。它们保证的是单条配置在服务端的行为;owner、过期提醒、审计和删除审批属于配置平台的治理流程,代码测试覆盖不到,但同样决定开关会不会变成无人认领的遗留物。
小结
开关是运行时配置,不是发布。它需要 owner、默认值、过期时间、描述和审计;删除和修改要有审批与影响分析;服务端要对名字、比例和用户做校验,并在关闭时绝对兜底。
Flag、Validate 和 Evaluate 把定义校验与命中判断分开,关闭优先、比例边界明确、同一用户稳定。下一章从实验与配置转向拉新:邀请关系、归因和奖励发放同样需要幂等与防作弊。
阅读导航:上一节:8.2 指标、显著性与误判 · 下一节:9.1 人群圈选与触达节奏 。
常见错误与边界
- 把开关当成代码里的普通 if。开关变更不走发布评审,却能在几分钟内影响全量流量,治理强度必须匹配影响面。
- 开关没有 owner。没人能确认它是否还在用,删除时只能靠猜,临时配置会永久化。
- 没有过期时间。大促开关在半年后仍然开着,下一次故障时没人知道它是降级入口。
- 默认值随手填。配置中心超时或返回空时,代码行为不确定,安全相关开关应该失败关闭。
- 关闭分支仍做校验。配置比例写脏时,关闭的开关反而报错,流量漏进旧逻辑。
- 比例只在前端校验。服务端直接比较
bucket < Percent,Percent=1000会让所有用户命中。 - 名字没有语义、没有描述。
v2这种命名会让新人误判,删除时缺乏影响分析。 - 删除开关没有审计和审批。配置平台把它当普通记录,一次清理就可能拆掉降级链路。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。