**功能开关(feature flags)**是分离「部署代码」和「发布行为」最便宜的手段,而且你根本不需要 SaaS。我的开关就跑在一个随应用一起发布的配置文件里,再加一个应急用的环境变量。这就够支撑一个紧急开关(kill switch)、一次渐进式灰度发布和一次 A/B 实验了——而且小到我真的会在用完之后把它删掉。功能开关最难的部分从来不是「求一个布尔值」这件事,而是围绕它的生命周期,而这恰好是重量级厂商帮不了你的地方。
功能开关是干什么的——以及它们会变成什么
用一个开关的理由是部署与发布分离。部署是把字节放上服务器,发布是改变用户看到的东西。把两者绑在一起,就意味着每次发布都是一次部署、每次部署都是一次风险。开关切断了这条链:让代码先暗着上线,等你准备好了再打开。
一个开关能做好的四件事:
- 把「部署」和「决定要不要上」拆开。 新编辑器先合并进来、先关着,等周二上午十点我还醒着能盯着看的时候再打开。
- 紧急开关。 一个布尔量,在高负载下关掉某个子系统——改一次配置就行,而不是回滚。这是唯一真正意义上长期存在的开关类型。
- 渐进式灰度发布。 先给 1% 的用户,再 10%、50%、最后全量——一旦错误率动了就提前刹车。
- A/B 实验。 两个变体、一个指标、最后做一个决定。
然后是最常见的失败模式,比上面四种用途加起来还普遍:永远不删的开关。一个发布开关挂了一年是永久的 if;再叠加 &&,你就得到了一团**「if 意大利面」——代码真实的运行行为是一堆没人能在脑子里装下的状态矩阵。僵尸开关比没有开关更糟:它的「关闭」分支根本没测过,还早晚会有人去拨一个早就没用的开关。每个开关都以工具的身份出生,最后以技术债**的身份死去——除非你在第一天就为它的删除做好安排。
四种特性开关:发布开关、紧急开关、实验开关、权限开关
最有用的一个习惯,是给每个开关贴上类型标签,因为类型本身就告诉你它应该活多久。
| 类型 | 存活时间 | 缺失时的默认值 | 何时删除 |
|---|---|---|---|
| 发布开关 | 数天–数周 | 关 | 新路径成为唯一路径 |
| 运维/紧急开关 | 永久(设计如此) | 开(运维上倾向于放行) | 该子系统被移除 |
| 实验开关 | 直到出结果 | 关 | A/B 实验有了结论 |
| 权限开关 | 数月 | 关 | 演进成真正的权限系统 |
「存活时间」这一列才是重点。没被删掉的发布开关是一颗定时炸弹;而长期存在的权限开关本身没问题——前提是它被正确地建模。把这两种东西混在一堆没有区分的布尔量里,就是你后来什么都删不掉的原因。
极简自托管方案:配置文件、环境变量覆盖、按请求求值
我用的设计只有三条规则:
- 开关是数据,不是代码。 它们活在一个文件里——默认值提交进仓库,线上值挂载在服务器上。改开关就是改数据,所以它不需要部署。
- 单一事实来源,单一覆盖入口。 文件是事实,每个开关对应一个环境变量,只为了让我在事故中能从 shell 强制取值,而不用改任何文件。
- 按请求求值,在内存里做。 文件只解析一次,变化时热重载。开关查找从不在请求热路径上发起网络调用。
整个系统就是一个配置文件:
# flags.yaml —— 这就是全部的「功能开关服务」
version: 1
flags:
new_editor: # 发布开关:短命
type: release
enabled: true
rollout: 25 # 命中主体(subject)的百分比
default: false # 缺失时调用方拿到的值
owner: deepseek
created: 2026-11-01
remove_by: 2026-12-15 # 过了这天 CI 会失败
checkouts_paused: # 运维 / 紧急开关
type: ops
enabled: false # 关闭 = 正常路径
default: false
recommendations_v2: # 实验开关:需要一个结论
type: experiment
enabled: true
rollout: 50
default: false
team_sso: # 权限开关:白名单
type: permission
enabled: true
allow: [team-alpha, team-beta]
default: false
remove_by 这个字段不是装饰品——一个小 CI 检查会读它,日期一过就构建失败。这是我找到的唯一能可靠地「杀掉」开关、而不让它老成一根承重 if 的办法。
安全地给默认值:失败关闭,并给开关标类型
两条规则能让一套自制的开关系统不至于变成故障来源。
失败关闭(fail closed)。 如果配置文件缺失、损坏,或者开关名未知,答案就是「关」——绝不在请求路径上抛异常,也绝不意外地「开」。功能开关应当能够被删除,并把系统带回它之前的行为。唯一的例外是运维开关:那里由你刻意决定「失败放行」是否更安全;对大多数紧急开关来说,只有当该功能确实可选时,「松开刹车」才是正确的默认。
给查询标类型。 让每个调用点都显式带上默认值,而不是依赖配置文件里碰巧写了什么。一个缺失的开关配上一个有类型的默认值,是一种接近编译期的承诺;一个缺失就抛异常的开关,是一通凌晨三点的电话。
import hashlib, json, os
_config: dict = {}
def load(path: str = "flags.json") -> None:
global _config
try:
with open(path) as f:
_config = json.load(f).get("flags", {})
except (OSError, ValueError):
_config = {} # 失败关闭:文件读不出来 => 所有开关都关
def enabled(name: str, subject: str = "", default: bool = False) -> bool:
# 用调用方显式传入的默认值,而不是文件里恰好写了什么
flag = _config.get(name)
if flag is None:
return default
# 来自 shell 的应急覆盖:FLAG_NEW_EDITOR=1
if os.environ.get(f"FLAG_{name.upper()}") in ("1", "true"):
return True
if not flag.get("enabled", False):
return False
if "allow" in flag: # 权限开关
return subject in flag["allow"]
pct = flag.get("rollout")
if pct is None:
return True # 全量开启
# 稳定的分桶:同一个主体永远落在同一个桶里
h = hashlib.sha1(f"{name}:{subject}".encode()).hexdigest()
return int(h[:8], 16) % 100 < pct
两个细节撑起了全部重量。稳定分桶——对 name:subject 做哈希——意味着 25% 里的用户在下一个请求里还在这个组里,于是灰度是一个队列,而不是一次抛硬币。而调用方的默认值意味着,一个已经从文件里删掉的开关会退化成安全行为,而不是崩溃。
生命周期:创建 → 灰度发布 → 移除
每个开关都有生日,也都有忌日。跳过忌日,就是整场病的根源。
落到实践里,这几步长这样:
- 创建。 加上配置行:类型、负责人、一个安全的
default、一个remove_by日期。把代码提交到if enabled("new_editor", user.id)背后。 - 灰度发布。 用几天把
rollout往上翻,盯着错误率和延迟。出问题就设成0——这就是紧急开关,而且因为不涉及部署,几秒钟就生效。要是整个功能都坏了,enabled: false就是回滚。 - 移除。 当灰度到 100%、旧路径成了死代码时,在一次提交里同时删掉配置里的开关和它守护的分支。退役的是代码,不只是那个开关。
之所以要用机械手段强制执行第 3 步,是因为人在这件事上普遍不可靠。留一个开关就有两种行为,两种都得测;四个开关就要测十六种。这个数是指数级的,而唯一常数时间的解药就是删除。这也是我把过期开关和失败的测试同等对待的原因:构建先停下,直到它被清掉。
如果这篇你只带走一句话,那就是:在创建开关的时候就把移除排上日程,然后让 CI 去做那个记得住的人。
什么时候开关是错的工具
开关很诱人,所以值得点名它在哪些场合是错误答案:
- 当它其实是用户配置时。 如果该由人来选主题、选偏好,那是一张设置表,不是功能开关。开关是给你控制的,而且是临时的;设置是给他们的,而且是永久的。
- 当它其实是长期权限时。 按套餐分级的限额、角色、配额,属于权限(entitlement)或授权模型,而不是一份越来越长的开关白名单。一个已经变成你授权层的开关系统,永远删不掉。
- 当它在掩盖一次坏掉的部署时。 如果你不靠一个开关守着就没法安全上线,那 bug 在部署路径上。去修那条路——原子符号链接与蓝绿部署解决的正是这些机制,好让开关不必去扛它们。
- 用于 schema 变更时。 数据库迁移要的是向前/向后兼容的方案,不是一个布尔量。你没法用一个开关消掉一个已经不存在的列。
- 当你永远不会删它、而分支本来也便宜时。 五行函数里的一个布尔量是合理的常量,五十个就不是了。
功能开关是一件带小契约的小工具:把部署和发布解耦,给自己一个紧急开关和一次灰度发布,然后清理干净。自托管那部分几乎是顺带的——一个文件、一次哈希、一个环境变量覆盖——而恰恰是这一点让它可存活。它和我在 2026 年自托管的东西是同一种哲学:一个你完全理解的、无聊的本地版本,胜过你租来之后就忘了的托管版本。
写下开关,标上它的死期,然后按时删掉。这就是全部的纪律,也是任何厂商替你做不了的那部分。