55.2 特性开关

一句话

开关的价值不是“能关”,而是把部署与发布解耦:代码先上线、功能不生效,出问题秒级关掉,不必回滚部署;代价是每多一个开关,就多一条没人维护的分支。

先破除四个误区

误区 现实
“开关就是 if (flag) {}” 裸 if 是代码分支,不是开关。开关要有求值来源、默认值、放量规则、owner、过期时间、审计,否则它只是一段没人敢删的代码
“默认值随便给,反正会读到配置” 默认值是配置服务挂掉时的唯一依靠。默认开一个半成品功能,等于把故障开关交给网络
“开关可以做权限” 客户端开关对用户可见、可改;用它做权限等于把门锁画在门上。权限必须服务端校验
“开关不用清理” 过期开关是第二套配置系统:分支组合爆炸、没人知道哪个组合测过、删代码时不敢动

四种开关,生命周期完全不同

类型 目的 典型寿命 默认值 例子
Release 未完成功能先合入、分批放量 天–周 关 checkout.v2
Ops / kill switch 紧急关闭出故障的功能或依赖 长期(少而精) 按业务安全侧定 disable-recommendation
Experiment A/B 实验、比较策略 周–月 对照组 rank.algo-b
Permission 内测、租户/账号白名单 月–年 关 beta.tenant-42

四类的清理策略不同:release 开关全量后两周内删掉;experiment 实验结论出来就删;kill switch 可以长期保留,但每个都要有 owner 和演练记录;permission 开关要跟合同/白名单一起维护。

部署 ≠ 发布 ≠ 实验

部署(deploy):二进制/镜像上线,代码存在但功能不生效   ← 由 55.1 的发布策略控制
发布(release):开关打开,用户开始用新功能            ← 由本节的特性开关控制
实验(experiment):按比例/人群分流,比较指标           ← 由实验平台控制

三者分开之后,回滚有了三个粒度:

粒度 操作 生效时间 代价
关功能 开关置 false 秒级 代码还在,可随时再开
换流量 路由权重改回 0 秒级 旧版本实例仍在(见 47.3 路由与灰度)
退版本 重新部署旧 digest 分钟级 要处理数据/配置兼容(见 55.3 回滚设计)

🔑 能用开关关掉的故障,就不要用回滚解决。回滚要动版本、动数据、动依赖;开关只动一个布尔值。

按流量比例切换与“整条链路都跟着切”是另一件事,见 55.6 灰度与流量染色。

在哪里求值

位置 延迟 一致性 可篡改 适用
服务端 SDK(本地缓存 + 流式更新) < 1 ms(量级) 最终一致,秒级 否 默认选择
网关 / Sidecar 1–5 ms 跟网关同步 否 多语言统一、不想改代码
客户端 SDK 本地 最终一致,分钟级 是 UI 实验、外观开关
构建期常量 0 强一致 否 不该叫开关——那叫发版

不要每次请求都远程求值:10,000 QPS × 每请求一次 RPC,等于给开关服务造一个 10,000 QPS 的依赖;它一抖,全站抖。正确做法是 SDK 本地缓存 + 流式订阅,求值不碰网络。

求值顺序:先兜底,再放量

一个可解释的求值链必须按固定顺序:

① 白名单 / override(内部账号、租户)
② 开关服务不可用 → 用本地缓存的最后一次值;没有缓存 → 默认值
③ kill switch 关闭 → false(且记录原因)
④ 分桶比例 → 命中则 true,否则 false

顺序错了会出两类事故:把 override 放在 kill switch 之后,紧急关闭关不掉白名单;把“服务不可用”放在最后,服务一挂就抛异常。

下面这段程序把五个场景跑出来:正常求值、kill switch、服务不可用、白名单、开关债。

package main

import (
	"fmt"
	"hash/fnv"
	"sort"
)

// flag 是一个特性开关。这几个字段各有作用,缺一个就会留下隐患:
// enabled 是 kill switch(总闸),rollout 是放量比例,defaultValue 是"求值不出来时"的兜底。
type flag struct {
	key          string
	enabled      bool
	rollout      int    // 0–100
	salt         string // 分桶盐,上线后不能改
	defaultValue bool
	owner        string
	expires      string // 计划移除日期
}

func bucket(user, salt string) int {
	h := fnv.New32a()
	h.Write([]byte(salt + ":" + user))
	return int(h.Sum32() % 100)
}

// eval 返回 (值, 来源)。来源必须能解释:kill switch / override / rollout / default / error。
func eval(f flag, user string, override *bool, providerDown bool) (bool, string) {
	if override != nil {
		return *override, "override"
	}
	if providerDown {
		return f.defaultValue, "default(provider-down)"
	}
	if !f.enabled {
		return false, "kill-switch"
	}
	if bucket(user, f.salt) < f.rollout {
		return true, "rollout"
	}
	return false, "rollout"
}

func main() {
	flags := []flag{
		{"checkout.v2", true, 20, "checkout-v2-2026", false, "team-order", "2027-01-31"},
		{"new-search", true, 50, "search-2026-10", false, "team-search", "2026-09-30"}, // 已过期
		{"dark-mode", false, 100, "dark-2026", false, "team-web", "2026-09-01"},        // 已过期且关闭
		{"risk-strict", true, 100, "risk-2026", true, "team-risk", "2026-12-31"},
	}
	users := []string{"u-1", "u-2", "u-3", "u-4", "u-5", "u-6"}

	fmt.Println("① 正常求值(checkout.v2,rollout=20%)")
	fmt.Printf("%-6s %-6s %-6s %s\n", "USER", "BUCKET", "VALUE", "SOURCE")
	on := 0
	for _, u := range users {
		v, src := eval(flags[0], u, nil, false)
		if v {
			on++
		}
		fmt.Printf("%-6s %-6d %-6v %s\n", u, bucket(u, flags[0].salt), v, src)
	}
	fmt.Printf("抽样命中 %d/%d(分桶是确定的:同一 user 永远同一结果)\n", on, len(users))
	bigOn := 0
	for i := 0; i < 200; i++ {
		if v, _ := eval(flags[0], fmt.Sprintf("u-%d", i), nil, false); v {
			bigOn++
		}
	}
	fmt.Printf("200 个用户:%d/200 = %.1f%%(比例要够大样本才看得出来)\n\n", bigOn, float64(bigOn)/2)

	fmt.Println("② 出问题:kill switch 打开(不需要发版、不需要回滚部署)")
	f := flags[0]
	f.enabled = false
	for _, u := range users[:2] {
		v, src := eval(f, u, nil, false)
		fmt.Printf("   %-4s value=%-6v source=%s\n", u, v, src)
	}

	fmt.Println("\n③ 开关服务不可用:回落默认值,且默认值必须是安全值")
	for _, u := range users[:2] {
		v, src := eval(flags[0], u, nil, true)
		fmt.Printf("   %-4s value=%-6v source=%s\n", u, v, src)
	}

	fmt.Println("\n④ 白名单覆盖:内部账号强制打开,不受 rollout 影响")
	yes := true
	v, src := eval(flags[0], "u-1", &yes, false)
	fmt.Printf("   u-1 value=%v source=%s\n", v, src)

	fmt.Println("\n⑤ 开关债:过期未清理的开关")
	stale := 0
	sort.Slice(flags, func(i, j int) bool { return flags[i].expires < flags[j].expires })
	for _, f := range flags {
		mark := ""
		if f.expires < "2026-10-29" {
			mark = "  ← 已过期,应删除"
			stale++
		}
		fmt.Printf("   %-14s owner=%-12s expires=%s%s\n", f.key, f.owner, f.expires, mark)
	}
	fmt.Printf("   过期 %d/%d(每个过期开关都是一条没人维护的分支)\n", stale, len(flags))
}

本机运行结果:

① 正常求值(checkout.v2,rollout=20%)
USER   BUCKET VALUE  SOURCE
u-1    14     true   rollout
u-2    95     false  rollout
u-3    76     false  rollout
u-4    57     false  rollout
u-5    38     false  rollout
u-6    19     true   rollout
抽样命中 2/6(分桶是确定的:同一 user 永远同一结果)
200 个用户:41/200 = 20.5%(比例要够大样本才看得出来)

② 出问题:kill switch 打开(不需要发版、不需要回滚部署)
   u-1  value=false  source=kill-switch
   u-2  value=false  source=kill-switch

③ 开关服务不可用:回落默认值,且默认值必须是安全值
   u-1  value=false  source=default(provider-down)
   u-2  value=false  source=default(provider-down)

④ 白名单覆盖:内部账号强制打开,不受 rollout 影响
   u-1 value=true source=override

⑤ 开关债:过期未清理的开关
   dark-mode      owner=team-web     expires=2026-09-01  ← 已过期,应删除
   new-search     owner=team-search  expires=2026-09-30  ← 已过期,应删除
   risk-strict    owner=team-risk    expires=2026-12-31
   checkout.v2    owner=team-order   expires=2027-01-31
   过期 2/4(每个过期开关都是一条没人维护的分支)

注意 ① 的两个数字:6 个用户抽样命中 2 个(33%),200 个用户是 20.5%——小样本看不出比例,判断放量是否准确至少看几百个键。分桶盐固定,所以同一用户永远在同一侧;改盐等于把所有人重新洗一遍。

开关的生命周期

阶段 动作 退出条件
创建 命名、指定 owner、写 expires、定默认值 评审通过(默认值尤其要评审)
内部 白名单/内部账号 内部验证无阻断问题
灰度 1% → 10% → 50%,每档看指标 错误率、P99、业务指标平稳
全量 100%,保留 kill switch 观察一个完整发布周期
清理 删开关、删死代码分支、删配置 代码合并、配置删除

没有 expires 的开关等于永久开关。建议在 CI 里做一条检查:expires < today 的开关直接让流水线告警(不是失败),owner 必须处理。

开关债:组合爆炸是算术,不是感觉

n 个布尔开关,理论上最多有 2^n 种组合:

开关数 组合数 现实
10 1,024 还能人工想
20 1,048,576 只能测常见组合
50 ≈ 1.13 × 10^15 组合测试已不可能

所以“开关越多越灵活”是错觉:每加一个开关,你就放弃了一部分可验证性。控制开关数量的办法不是“少用”,而是“每个开关都有退出计划”。

生产落地:指名 → 可抄 → 可验 → 可退

指名:求值标准用 OpenFeature(厂商中立的 SDK 规范,换供应商不改业务代码);产品可选 Unleash(可自建)、LaunchDarkly(托管、审计与实验强)、Flagsmith。只做“按环境开关”的小团队,用配置中心(48.1 配置中心)也够,别为了一个布尔值上开关平台。

可抄(本机无开关服务,以下命令未验证):

# Unleash 自建(示例端口 4242)
docker run -d --name unleash -p 4242:4242 \
  -e INIT_ADMIN_API_TOKENS='*:*.unleash-insecure-admin-api-token' \
  unleashorg/unleash-server:6

# 创建开关:名字里带 owner 与退出日期,逼自己清理
curl -s -X POST "$UNLEASH_URL/api/admin/projects/default/features" \
  -H "Authorization: $UNLEASH_TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"checkout.v2","type":"release","description":"owner:team-order expires:2027-01-31"}'

# 打开(生产环境)
curl -s -X POST "$UNLEASH_URL/api/admin/projects/default/features/checkout.v2/environments/production/toggle" \
  -H "Authorization: $UNLEASH_TOKEN" -H 'Content-Type: application/json' \
  -d '{"enabled":true}'

可验:本节 Go 程序真跑过,输出如上;它验证的是“求值顺序、kill switch、兜底、开关债”四条语义,不依赖任何服务。线上至少验证一次:关掉开关 → 5 秒内错误率回落 → 再打开 → 指标恢复,并把这次演练记进 runbook。

可退:

  • 功能异常:关开关(秒级),不要先回滚部署;开关关不掉才走 55.3 回滚设计。
  • 开关配置本身配错:开关平台保留版本历史,一键回滚上一版规则;同时确认客户端本地缓存会在多久内刷新。
  • 开关服务故障:SDK 用本地缓存 + 默认值兜底,明确“最长容忍多久的旧值”(与 54.5 配置注入 的兜底策略一致)。
  • 清理回退:删开关前先确认没有运营/客服依赖它的“临时打开”入口,否则删完就少了一个应急手段。

反模式

反模式 后果
默认值给“开” 开关服务故障时半成品功能全量生效
开关嵌套(A 开且 B 关才走新逻辑) 组合爆炸,没人能说清线上是哪个组合
用开关做权限/鉴权 客户端可篡改,等于没有鉴权
开关粒度太细(每个字段一个) 配置比代码还难维护
没有 owner / expires 永久开关,几年后没人敢删
每次请求远程求值 开关服务成为全站强依赖,抖动放大
客户端开关控制金额/风控 改一个请求参数就能绕过
全量后不删开关 死代码 + 恐惧式维护,新人不敢动
用开关代替分支逻辑 业务规则散落在配置里,无法测试

自检

Q1 已经有了发布策略(滚动/蓝绿/金丝雀),为什么还需要特性开关?

▸ 答案:发布策略控制“哪些实例跑新代码”,开关控制“新代码里的功能是否生效”。没有开关时,回滚只能退版本(分钟级,且要处理数据/配置);有开关时,先关功能(秒级),版本可以留着继续排查。两者解决的是不同粒度的问题:策略管实例,开关管功能。

Q2 为什么默认值必须是“安全值”,而不是“期望值”?

▸ 答案:默认值只在求值失败时使用(开关服务不可用、网络断、SDK 异常)。这时系统已经处于异常状态,默认值应该让它落在“即使功能不生效也不会出事”的一侧。对 release 开关,安全值是关;对 kill switch,安全值是“核心链路仍能跑”的那个状态。把默认值设成“开”,等于让一次配置服务故障直接触发功能全量。

Q3 kill switch 为什么不能依赖开关服务可用?

▸ 答案:kill switch 的使用场景恰恰是“系统已经出问题”——可能是开关服务自己挂了,也可能是网络分区。如果关开关需要先连上服务,那它就不是应急手段。做法:SDK 本地缓存最后一次配置 + 本地文件兜底 + 明确的“无配置时默认值”,并定期演练“断网后开关仍可关闭”。

Q4 50 个开关的组合数是多少?这个数字对测试意味着什么?

▸ 答案:2^50 ≈ 1.13 × 10^15。意味着不可能穷举,只能测“主路径 + 每个开关单独开/关 + 少数关键组合”。所以开关越多,未测试的组合越多;控制开关数量、及时清理过期开关,本质是在保护可验证性,不是在追求整洁。

Q5 客户端开关为什么不能用于权限或金额控制?

▸ 答案:客户端代码与配置都在用户设备上,用户可改内存、改请求、改本地存储;客户端开关只能决定“UI 怎么显示”,不能决定“服务端允不允许”。权限、额度、风控必须在服务端用可信身份判断;客户端开关最多作为“是否展示入口”的提示。

关联