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 怎么显示”,不能决定“服务端允不允许”。权限、额度、风控必须在服务端用可信身份判断;客户端开关最多作为“是否展示入口”的提示。