上线不靠祈祷:vibe 项目的功能开关与应急关停实战
功能开关把部署和发布解耦:代码先上,功能后开。四种开关类型与选型矩阵、一张表的最小实现、兜底铁律(新功能默认关、止血开关冻结)、开关腐烂治理,以及开关与灰度发布如何配合。

本文只讲一件事:功能开关(Feature Flags)——上线后,用开关而不是祈祷来控制功能。先划清边界,不讲的不讲:
- vs 《别把新版本一把梭哈:灰度发布与回滚实战》:那篇讲流量分流式渐进发布(10%→50%→100%);本文讲开关式控制——按用户、租户、比例决定"谁能用",出事一键关停。机制完全不同,不讲分流与回滚。
- vs 《一个人项目的第一条流水线》:那篇讲"怎么发版";本文讲"发版之后怎么控"——开关是发布后的运行时治理。
- vs 《事故 Runbook》:关停开关是 runbook 里的第一招,本文把开关体系本身展开讲透,不重复事故流程。
一句话定义:功能开关就是代码里的 if,只不过这个 if 的条件不在代码里写死,而是在运行时从外部读取。部署和发布从此解耦:代码先上,功能后开;出事不用回滚,直接关开关。
开关解决的三个真问题
先对上场景,再谈实现。功能开关不是银弹,它精确解决三个问题:
问题一:新功能不敢全量开。你用 AI 两天搓出个新功能,测试环境跑得好好的,生产环境有 10 万用户。直接全量?心跳加速。开关让你先给 5% 用户开,观察两天没事再扩到 50%、100%。注意这和灰度发布的区别:灰度是"流量"维度(同一个人这次命中、下次可能不命中),开关是"身份"维度(这个用户开了就是开了,体验一致)。
问题二:线上事故需要秒级止血。凌晨三点,新功能把数据库打爆了。回滚要重新部署,至少 10 分钟;关开关只要改一行配置,秒级生效。开关是你的"大红按钮",事故 Runbook 里第一条就该是它。
问题三:付费功能按套餐 gating。免费版没有 AI 总结,Pro 版有。传统做法是代码里写死 if (plan === 'pro'),每次调套餐都要改代码发版。用开关,套餐规则变成运行时配置,运营同学(或者你自己)在后台点两下就行。这和付费墙设计是天然搭档:付费墙决定"卖什么价",开关决定"给谁开"。
四种开关类型与选型矩阵
不是所有开关都长一个样。按控制维度,分四种:
| 类型 | 怎么开 | 典型场景 | 一人团队建议 |
|---|---|---|---|
| 布尔开关 | 全局开/关 | 新功能总闸、事故止血 | 必做,最简单 |
| 按比例 rollout | 10%→50%→100% 用户 | 新功能渐进开放 | 用户量 >1 万再做 |
| 按身份定向 | 指定用户/租户/邮箱域名 | 内测、KA 客户先行 | 有 B 端客户就做 |
| 按套餐 gating | 读用户订阅计划 | 付费功能控制 | 有付费墙就做 |
选型逻辑见文末决策图,一句话:从布尔开关起步,按"出过的事"逐步加类型。没出过事故,就别先做按比例 rollout;没 B 端客户,就别做按租户定向。开关系统本身也会腐烂(见第五节),类型越多,腐烂越快。
最小实现:先从一张 DB 表起步
一人团队不需要 LaunchDarkly。第一版,一个数据库表就够:
-- feature_flags 表
CREATE TABLE feature_flags (
key TEXT PRIMARY KEY, -- 'new_checkout'
enabled BOOLEAN NOT NULL DEFAULT FALSE,
rollout_pct INTEGER NOT NULL DEFAULT 0, -- 0-100
targets JSONB NOT NULL DEFAULT '[]', -- ["user:123", "tenant:acme"]
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT NOT NULL, -- 谁改的,审计用
expires_at TIMESTAMPTZ -- 过期时间,防腐烂
);
-- 读取(带本地缓存,见下)
-- SELECT enabled, rollout_pct, targets FROM feature_flags WHERE key = 'new_checkout';
读取路径有三个要点:
- 缓存:开关是高频读取(每个请求都可能查),不能每次都打 DB。用内存缓存 30–60 秒 + 变更时主动失效。注意缓存和"秒级止血"的矛盾:事故时你希望立刻生效,所以关停操作要走"写 DB + 清缓存"的同步路径,而不是等缓存自然过期。
- 兜底:读开关本身可能失败(DB 抖动、缓存击穿)。失败时默认开还是默认关?这是下一节的主题,先记住结论:按"失败影响面"定,没有银弹。
- 审计:
updated_by字段不是摆设。谁在什么时候关了哪个开关,是事故复盘时最关键的一行日志。
等用户量上来,再考虑 Unleash(自托管开源)或 LaunchDarkly(SaaS)。迁移成本不高,因为接口已经抽象好了:业务代码只调 flags.isEnabled('new_checkout', user),后面是 DB 表还是第三方服务,业务无感。
让 AI 生成的代码默认走开关
vibe coder 有个专属痛点:你让 agent 写新功能,它习惯把逻辑写死——if (user.plan === 'pro') 这种代码,agent 写得飞起,改起来全是发版。有个简单的 prompt 约束能根治:
## Feature flag 约定(每次写新功能必读)
1. 任何用户可见的新功能,必须包在 flags.isEnabled('exp_yyyymmdd_功能名', user) 里,禁止写死条件
2. 开关 key 命名:exp_ + 日期 + 功能名,建表时填 expires_at(默认 30 天后)
3. 开关读取失败的兜底:新功能默认关,止血开关用最后已知值(不要一刀切 return false)
把这段放进项目的 AGENTS.md 或每次开工的上下文里。效果是:agent 生成的每一行新功能代码,天生就是可开关、可灰度、可秒级止血的。你不需要每次 review 时人肉找"写死的地方",因为约束前置了。这就是把开关从"运维习惯"变成"代码规范"——而规范,是唯一能规模化约束 AI 生成代码的东西。
兜底铁律:读失败时,默认开还是默认关?
这是开关系统里最容易被 AI 生成的代码漏掉的一点,也是出不出大事的分水岭。场景:你的开关服务挂了(DB 主从切换、缓存雪崩),isEnabled() 抛异常或超时,这时怎么办?
| 开关类型 | 读失败默认 | 理由 |
|---|---|---|
| 新功能体验开关 | 关(fail-closed) | 宁可用户看不到新功能,也不能让半成品逻辑在异常状态下乱跑 |
| 事故止血总闸(如限流降级开关) | 保持最后已知状态 | 止血开关如果在事故中翻转,会造成二次伤害;用本地缓存的最后值 |
| 付费 gating 开关 | 关(fail-closed) | fail-open 会让免费用户白嫖付费功能,且事后无法追溯 |
| 运维调试开关(如详细日志) | 关 | 调试开关在异常时打开,可能打爆磁盘/泄露敏感日志 |
注意第二行的特殊性:止血用的开关,兜底策略是"冻结",不是"开"也不是"关"。实现上就是本地内存里永远留一份最后一次成功读取的值,读失败时用这份。AI 生成的开关代码十有八九是 catch { return false; } 一刀切——对止血开关来说,这一刀切下去,正在止血的伤口会重新裂开。Code Review 时看到开关读取的 catch 块,多问一句"这里默认什么",能拦住一整类事故。
开关腐烂治理:上线 30 天后必须 review
开关最大的敌人不是故障,是遗忘。每个开关上线时都是"临时"的,三个月后代码里躺着 40 个没人敢删的 if——这叫开关腐烂(flag rot)。治理就三招:
- 命名规范:
exp_202610_checkout_v2比new_stuff好一万倍。命名里带上日期和用途,三个月后你还知道它是干嘛的。 - 过期时间:建开关时必填
expires_at(见上面的表结构)。到期后 CI 直接报错,逼你做决定:删开关(功能全量了就把if去掉),或续期(写理由)。 - 30 天 review:日历上定个重复事件,每月看一遍开关列表。判断标准很简单:这个开关上次被改动是什么时候?如果超过 60 天没人碰,它要么已经全量(删),要么已经没用(删)。
删开关本身是个需要小心的操作:先确认开关当前是"全开"状态,再把代码里的 if 分支删掉(保留开的那一侧),发版验证,最后删 DB 里的行。顺序不能反——先删 DB 行会导致读失败走兜底,万一兜底是关,等于线上事故。
与灰度发布配合:开关管"谁能用",灰度管"多少流量"
这两者经常被混为一谈,其实正交:
| 功能开关 | 灰度发布 | |
|---|---|---|
| 控制维度 | 身份(谁) | 流量(多少) |
| 典型动作 | 给 Pro 用户开 / 关掉出事的功能 | 10% 流量先上新版本 |
| 回退速度 | 秒级(改配置) | 分钟级(切流量) |
| 适用阶段 | 功能级 | 版本级 |
实战组合:大版本发布时,先用灰度把新版本推到 10% 流量,同时用开关把新版本里的激进功能默认关闭;观察无异常后,先开开关(功能级放量),再扩灰度(流量级放量)。两层保险,任何一层出问题都有独立的回退路径。记住分工口诀:开关回答"谁能用",灰度回答"多少量"。
Solo 上线检查清单
发版前打勾,缺一个都别上线:
- ☐ 每个开关都有
expires_at,且命名带日期 - ☐ 开关读取失败的兜底策略已按上表逐个定好(特别是止血开关用"冻结")
- ☐ 关停路径走"写 DB + 同步清缓存",验证过秒级生效
- ☐
updated_by审计字段有人看(至少事故复盘时能查到) - ☐ 日历上有每月开关 review 的重复事件
- ☐ 业务代码只调
flags.isEnabled(),不直连 DB 表(方便以后换 Unleash/LaunchDarkly)
功能开关是那种"平时觉得多余,出事时救命"的东西。它的成本是一张表和一点纪律,它的收益是凌晨三点你能多睡的觉。上线不靠祈祷,靠开关。
评论 (0)
相关文章

prompt 是建议,不是约束。AI 结构化输出可靠性的四层防线:模型原生结构化输出(OpenAI vs Anthropic,官方文档核实)、zod 校验的严格/宽松取舍、把报错原文喂回的修复重试、按成本选的降级兜底。

用户写的 2000 字,要扛住浏览器崩溃、断网和误关标签页。三层防线——输入防抖、localStorage 兜底、带版本号的服务端持久化,外加版本号乐观锁的冲突 UX(保留两份让用户选)、离线重放与草稿生命周期。

两个人同时改同一行是"一致性"问题,不是"实时"问题——"WebSocket 广播 JSON"的天真方案撑不过三个人(同行冲突、光标打架、断线丢数据)。本指南先与站内 realtime-features(单向推送)、offline-sync(单用户)两篇划界,再深入:OT vs CRDT 一句话讲透+选型对照表(结论先行:一人团队直接选 Yjs)、Y.Doc/Awareness/Provider 三件套(30 行客户端+40 行带鉴权的 y-websocket 服务端)、房间级 ACL 与快照兜底防恶意客户端、生产坑(大文档快照/分片、Y.UndoManager 语义、按席位计费),最后附 5 项上线验收清单。