渐进发布一边扩大新版本的暴露范围,一边检查行为是否可以接受,可能在所有请求都切换之前发现退化。它不会自动让改动可逆,不保证只有选定比例的用户受到影响,也不一定需要功能开关。流量路由、实例替换和功能暴露是不同控制,可以共同参与一份发布计划。真正需要说明的是谁先接触变化、根据什么证据继续,以及异常发生之后怎样恢复服务。
虚构计数和阈值用于解释决策规则,并非部署实验或生产服务目标。真实发布需要合适群体、新鲜且有代表性的测量、观察时长,以及经确认的恢复路径。 明确人群及其共享依赖 百分比需要分母:请求、用户、租户和实例并不是一回事。百分之一的实例不一定处理百分之一的流量,一个繁忙客户也可能占据大部分请求样本。应选择能够覆盖相关工作负载的群体,而不是把数量很少当成安全证明。一个工作流需要多次请求都落在同一版本时,还要保持一致的分配,避免新旧行为在流程中交替出现。
新旧人群可能共享数据库、队列、缓存或配额。故障金丝雀能够耗尽共同依赖,也可能写入不兼容数据,使旧版本同样受影响。因此,百分之一的放量并不能把事故影响严格限制在百分之一。确定阶段之前,应盘点共享故障路径,尤其注意跨租户状态、公共连接池和异步消息,不能只看入口分流比例就宣布其他用户完全不会感知。
收集足以支持决定的测量 按金丝雀人群拆分错误率和延迟,与合适的对照群体比较,同时保留绝对的服务目标。如果共同依赖让两个版本都坏了,二者指标相近不能证明健康。发布前就应定义观察窗口、最小流量、相关路由,以及测量缺失或过旧时的处理。十个请求没有错误,与大量代表性请求没有错误,证据强度不同;分母不足时不应把零直接解释成通过。
阶段时长应覆盖准备评估的效应,例如启动、缓存预热、长任务、延迟写入及业务周期。三十秒既不是天然足够,也不是天然没有价值。承接一半流量也不能证明满量容量,因为非线性瓶颈可能在后面才出现。满量之后仍需监控,并明确何时算发布完成。观察不是等待一个固定数字过去,而是取得足够新鲜、相关、可解释的证据。
坏掉的样子 只靠定时器的发布,即使没有取得有效测量也会继续晋升。证据不足时应暂停,由明确负责人在约定时间内调查,不能悄悄按成功处理。自动回滚在触发条件与恢复动作都经过验证时可以缩短响应时间,但有些故障需要值班人员判断。自动化是一种执行手段,不会证明恢复操作本身没有风险;暂停也不能无限无人接手。
另一种失败,是认为恢复旧二进制就能恢复旧状态。数据库模式、已持久化格式、已经发出的消息和外部副作用,都可能在流量切回以后继续存在。应保留仍兼容的旧产物,并演练真实恢复路径。如果反向切换不安全,应准备向前修复或业务对账方案,不要展示一个实际上无法恢复服务的回滚按钮。发布可逆性来自这些设计条件,而不是来自阶段名称。
全量平均会掩盖局部退化。假设旧版本错误率为零,处理十分之一请求的金丝雀出现百分之十五错误,摊到全量后只有百分之一点五。算术上的稀释并没有使故障人群变得可以接受。需要同时观察群体拆分、业务结果和整体健康,而且对照组自身也必须正常。不同人群的流量类型差异,还可能让简单比较失去解释力。
快速验证 保存为 rollout_check.py,用 Python 3.10 或更新版本执行 python3 rollout_check.py。2026-10-19 以 Python 3.12.14 完整运行原样程序并通过。下面是使用虚构请求计数的确定性决策模型,不是部署控制器;它不会改变路由,不等待真实遥测,也不实际执行回滚。最小请求数和错误阈值都是教学参数,不应直接作为生产服务目标。
from dataclasses import dataclass STAGES = (1, 10, 50, 100) MIN_REQUESTS = 1000 MAX_ERROR_RATE = 0.02 # Illustrative gate, not an SLO error budget. @dataclass(frozen=True) class Sample: requests: int errors: int def decide(sample): if not 0 <= sample.errors <= sample.requests: raise ValueError("invalid request/error counts") if sample.requests < MIN_REQUESTS: return "PAUSE", None rate = sample.errors / sample.requests return ("ROLLBACK_REQUESTED" if rate > MAX_ERROR_RATE else "PROMOTE"), rate def plan(samples): history = [] for stage in STAGES: decision, rate = decide(samples[stage]) history.append((stage, decision, rate)) if decision != "PROMOTE": break return history healthy = plan({stage: Sample(2000, 10) for stage in STAGES}) faulty = plan({stage: Sample(2000, 300) for stage in STAGES}) missing = plan({stage: Sample(0, 0) for stage in STAGES}) sparse = plan({stage: Sample(100, 0) for stage in STAGES}) assert [row[0] for row in healthy] == list(STAGES) assert all(row[1] == "PROMOTE" for row in healthy) assert faulty == [(1, "ROLLBACK_REQUESTED", 0.15)] assert missing == sparse == [(1, "PAUSE", None)] assert decide(Sample(1000, 20)) == ("PROMOTE", 0.02) assert decide(Sample(1000, 21)) == ("ROLLBACK_REQUESTED", 0.021) for sample in (Sample(-1, 0), Sample(10, -1), Sample(10, 11)): try: decide(sample) except ValueError: pass else: raise AssertionError("invalid sample accepted") # A 10% canary at 15%, with a zero-error control, gives 1.5% fleet errors. fleet_rate = 0.1 * 0.15 + 0.9 * 0.0 assert fleet_rate < MAX_ERROR_RATE for label, history in (("healthy", healthy), ("faulty", faulty), ("missing", missing), ("sparse", sparse)): print(label, history) print(f"fleet dilution: {fleet_rate:.1%}; rollback was requested, not executed") 健康样本会通过四个阶段,故障样本在第一级返回请求回滚。没有测量与只有一百个请求的零错误样本都会暂停。边界断言允许恰好百分之二,拒绝百分之二点一;不合法的请求和错误计数则触发校验失败。这些检查说明代码如何决策,没有证明阈值具有统计合理性,也没有测量不同流量分配是否真正落实。
...