部署代码和向用户开放功能,是不同动作。功能开关可以在代码部署后选择运行路径,支持渐进开放和快速关闭;前提是旧路径仍兼容,配置也真正到达负责求值的进程。开关控制一次决定,不会撤销部署或新行为已经造成的全部效果。讨论回滚时,应明确能停止什么、不能恢复什么,而不是把关闭按钮当作万能撤销。

在明确的门控后部署

发布开关通常默认选择既有路径,让新实现先接受验证。不过部署阶段仍可能变化:导入、启动钩子、表结构迁移、依赖和资源使用,都可能位于被保护分支之外。因此关闭开关不能保证用户完全无感。应同时测试关闭和开启状态的真实产物,包括启动流程,以及两条路径与已有数据的兼容性,避免只看功能入口是否隐藏。

关闭开关可以阻止后续请求进入新路径,却不会撤销已经完成的写入、已发送消息、迁移或在途工作。回滚窗口内应让两条路径都能处理相关数据格式,并定义部分执行后可能需要的修复。紧急关闭只有在传播延迟和实际停止边界符合响应计划时才有效;控制台显示关闭,不代表所有正在运行的操作已经中止。

按含义选择默认行为

对于尚未完成的发布功能,未知配置时退回旧路径可能是安全选择。但这是具体开关的语义,不是任何情况下 false 都安全。关闭保护性检查,或者反转名为 disable-validation 的开关,可能产生相反结果。应明确备用值、配置新鲜度,以及无法求值时的处理,不能只用“失败为关”四个字替代对实际行为的审查。

在合适的操作边界求值一次,并在该操作内部复用决定。多个服务或独立请求之间,需要明确传递兼容决定的方式;每个进程本地求值一次,不会自然形成全局一致性。权限校验也应独立存在。客户端控制显示的开关,不能变成服务器接受受保护操作的授权凭证,否则隐藏按钮并没有真正限制访问。

用稳定身份逐步开放

把稳定用户标识与稳定开关键一起散列,可以形成可重复的分组。改变身份字段、盐值或分桶算法,即使百分比不变,也可能让用户重新分组。用户比例也不等于请求比例:少量重度用户可能产生大量流量。因此解释放量指标前,先记录分配单位,确认观察的是账号、请求还是其他业务实体,不能把几个百分比直接互换。

紧急关闭应覆盖普通分桶选择,并具有经过测试的配置传递路径。SDK 缓存和配置服务故障,可能让部分实例继续使用旧值。应按实际求值的变体观察错误与延迟,不能把控制面修改成功当作每次请求已经切换的证明。放量节奏应依据服务风险和观察延迟决定,不存在对所有业务都适用的固定百分比或等待时间。

坏掉的样子

临时发布开关如果没有删除负责人,会变成长期分支。应指定负责人和退出条件,在回滚窗口结束后删除不再需要的路径。运维开关和权益控制则可能有意长期存在,需要持续测试和维护,不能要求所有开关都很快过期。区分类型,才能既避免死配置堆积,又保留真正需要的长期运行控制。

互相矛盾的开关还可能形成无人测试的组合。可行时集中决定规则,定义优先级,同时验证备用与开启路径。日志只保留足以诊断不一致的非敏感上下文,避免无限保留真实用户标识。本例使用的名字全部是合成测试数据;把这份内存审计列表搬到生产环境前,还需另行设计保留、访问和脱敏规则。

快速验证

把代码保存为 flag_check.py,用 Python 3.10 或更新版本运行。原始实验记录使用 Python 3.14.4,本轮用 Python 3.12.14 重新执行其决定逻辑。脚本只模拟一个百分比发布开关、紧急关闭、稳定用户分桶和内存审计列表。百分比假定已通过零至一百的输入验证,不包含真实配置下载、SDK 缓存或身份服务。

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 two flag keys 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; different flag keys 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")

在百分之二十时,缺失用户保持关闭,紧急关闭覆盖全部具名用户,同一用户重复求值保持稳定。两百个固定测试用户在零、二十、一百三档,分别有零、三十五、两百人开启。三十五是这组输入的确定结果,不保证任意有限用户集合都精确达到百分之二十,也不说明相同比例的请求会使用新路径。

第二个求值器改变的是开关键,用户标识仍相同。因此它展示的是盐值或键漂移,不是真实的会话标识对照实验。审计条数证明每次求值都有一条记录。本地模型不测试分布式配置传播、请求内结果复用、数据库回滚或授权;这些仍是实际系统需要分别验证的集成责任,不能由本例的 PASS 自动推导。

延伸阅读

来源