功能开关把发布与暴露解耦
部署代码与暴露代码是两回事。功能开关是一个运行时开关,决定已上线的代码是否生效,让你可以持续发布,而不必重新部署就能切换行为。 先「空投」,再打开 功能开关把「代码上线」和「用户可见」拆成了两件事。新代码默认藏在开关背后,发布了也什么都不会变,直到有人把它从关闭换成打开。这个分离让新功能能搭正常的发布车走:所谓回滚,无非是把开关关掉,比拆代码快得多。发布和曝光就成了两个不同的动作——可以周一上线,等配置真到该亮的时候再把功能点亮。更妙的是,「空投」阶段就能验证运行时的健康度:已经起来了、启动日志对、指标正常,而用户那边一无所感。 求值只做一次:每次请求进入时计算一次结果然后向下传递。未知开关必须当作关闭处理,这样配置通道出问题也不会变成意外发布。 放量与一键关停是超能力 当开关不只是 on/off,它的价值才显现。灰度,先放 10% 流量,盯着错误率和延迟,系统稳了再加比例,不用一上来就整套换。而最值钱的还是那个关闭键:开着的功能一旦不听话,把开关关掉 = 不用发版就把事故剔除。乱用开关也是坑:无人认领的开关会越攒越多,几乎永远停在「开」的开关悄悄占了不该占的目录端口。所以要有个节奏,全量放完之后尽快复核并删除这个开关。 粘性比分桶比例本身更重要。按用户标识这种稳定键分桶,同一个账号每次看到同一版本;按时间戳或随机抛硬币分桶,同一个人会同时看到新旧两半,对照数据也就被污染了。 让开关保持诚实 开关一多就成了解不开的拼图,一把散乱开关是配置化分支的灾难。命名要说明它是什么、谁开的、默认什么,代码里只留一处定义,让开关指向的功能清清楚楚。还要把开关关闭态当成真实路径来测:只有开了才出现的 bug,本来就不该存在;一次亲密的对齐放量就够检验它。要点:开关是短命的工具,不是一套永久状态机。 每个开关在创建时就要写明负责人与过期时间,并在放量开始之前先建好删除任务。 坏掉的样子 最常见的坏法是漂移:开关推到百分之百之后,删除任务从未排期,几个月后新旧两套分支都还在维护,谁也不敢删。临时门控就这样变成了永久分叉,测试矩阵翻倍,死代码躲在没人敢动的开关后面。根治办法是把删除写进放量计划。 镜像故障是默认开而非默认关。服务把从未配置过的开关读成开,或者开关客户端在取不到配置时选择失败为开,本来应该是空投的上线瞬间把未完成的功能暴露给所有人。客户端应当失败为关,未知求值一律按关处理。 同一次请求内求值打架也很常见:页面按用户标识分桶,写路径却按会话标识分桶,或者放量比例在请求中途被改掉,答案走到一半翻转。用户看到的是新界面,提交却走了旧处理器。正确做法是每次请求只求值一次,下游全部复用这个决定,并把求值原因记下来。 最后是无人清理的孤儿开关。死开关越积越多,重命名后的开关悄悄回退到默认值,两个重叠开关互相矛盾却没有定义谁赢。应当审计哪些开关真正被求值过,对长期无人求值的开关告警,并在完成放量的同一个版本里删掉开关及其分支。 快速验证 把下面代码保存为 flag_check.py,用 Python 3.10 以上版本运行 python3 flag_check.py。实验已在 Python 3.14.4 上运行,只使用标准库。脚本模拟一个带一键关停的百分比开关,按用户稳定分桶,未知用户默认关闭,并把每次求值连同原因写入审计日志。 import hashlib class Flag: """One percentage flag with kill switch, default-closed, and audit log.""" def __init__(self, key, percent): self.key = key self.percent = percent self.killed = False self.audit = [] def _bucket(self, user): digest = hashlib.sha256(f"{self.key}:{user}".encode()).hexdigest() return int(digest, 16) % 100 def evaluate(self, user): if self.killed: decision, reason = False, "killed" elif not user: decision, reason = False, "default-closed" else: hit = self._bucket(user) < self.percent decision, reason = hit, ("in-bucket" if hit else "out-of-bucket") self.audit.append({"user": user, "decision": decision, "reason": reason}) return decision flag = Flag("new-checkout", 20) users = [f"user-{i}" for i in range(200)] # 1. Default closed: a missing user never gets the new path. assert flag.evaluate("") is False assert flag.evaluate(None) is False # 2. Stickiness: the same user gets the same answer twice. first = flag.evaluate("user-1024") second = flag.evaluate("user-1024") assert first == second # 3. Kill switch overrides every bucket. flag.killed = True assert flag.evaluate("user-1024") is False assert flag.evaluate("user-7") is False flag.killed = False # 4. The dial moves exposure for a fixed fixture set. open_20 = sum(flag.evaluate(u) for u in users) flag.percent = 100 open_100 = sum(flag.evaluate(u) for u in users) flag.percent = 0 open_0 = sum(flag.evaluate(u) for u in users) flag.percent = 20 assert open_0 == 0 assert 0 < open_20 < len(users) assert open_100 == len(users) # 5. A different bucketing key disagrees somewhere: the inconsistency to avoid. session_flag = Flag("new-checkout-session", 20) mismatch = [u for u in users if flag.evaluate(u) != session_flag.evaluate(u)] assert mismatch, "expected at least one user where user-key and session-key disagree" # 6. Audit log holds one entry per evaluation with its reason. assert len(flag.audit) == 2 + 2 + 2 + 200 * 3 + 200 assert len(session_flag.audit) == 200 assert all(set(e) == {"user", "decision", "reason"} for e in flag.audit) print(f"default-closed empty user: off; sticky user-1024: {'on' if first else 'off'} twice") print(f"dial 0/20/100 over 200 users: {open_0}/{open_20}/{open_100}") print(f"killed flag forces off; user-key vs session-key disagree on {len(mismatch)} users, e.g. {mismatch[0]}") print(f"audit entries: flag={len(flag.audit)}, session_flag={len(session_flag.audit)}") print("PASS: default-closed, sticky bucketing, kill switch, dial, and audit log behave as asserted") 放量百分之二十时,空用户保持关闭,被关停的开关对所有具名用户强制关闭,同一用户两次求值结果一致。在两百个固定用户上,百分之零、二十、一百三档分别打开零人、三十五人、两百人。按会话键构造的第二个求值器会在几十个账号上给出不同答案,这正是单次求值规则要消除的不一致。审计日志为每次求值保留一条带原因的记录。这个小模型不覆盖分布式配置下发与放量分析,真实开关系统仍需对照验证。 ...