渐进发布一边扩大新版本的暴露范围,一边检查行为是否可以接受,可能在所有请求都切换之前发现退化。它不会自动让改动可逆,不保证只有选定比例的用户受到影响,也不一定需要功能开关。流量路由、实例替换和功能暴露是不同控制,可以共同参与一份发布计划。真正需要说明的是谁先接触变化、根据什么证据继续,以及异常发生之后怎样恢复服务。

明确人群及其共享依赖

百分比需要分母:请求、用户、租户和实例并不是一回事。百分之一的实例不一定处理百分之一的流量,一个繁忙客户也可能占据大部分请求样本。应选择能够覆盖相关工作负载的群体,而不是把数量很少当成安全证明。一个工作流需要多次请求都落在同一版本时,还要保持一致的分配,避免新旧行为在流程中交替出现。

新旧人群可能共享数据库、队列、缓存或配额。故障金丝雀能够耗尽共同依赖,也可能写入不兼容数据,使旧版本同样受影响。因此,百分之一的放量并不能把事故影响严格限制在百分之一。确定阶段之前,应盘点共享故障路径,尤其注意跨租户状态、公共连接池和异步消息,不能只看入口分流比例就宣布其他用户完全不会感知。

收集足以支持决定的测量

按金丝雀人群拆分错误率和延迟,与合适的对照群体比较,同时保留绝对的服务目标。如果共同依赖让两个版本都坏了,二者指标相近不能证明健康。发布前就应定义观察窗口、最小流量、相关路由,以及测量缺失或过旧时的处理。十个请求没有错误,与大量代表性请求没有错误,证据强度不同;分母不足时不应把零直接解释成通过。

阶段时长应覆盖准备评估的效应,例如启动、缓存预热、长任务、延迟写入及业务周期。三十秒既不是天然足够,也不是天然没有价值。承接一半流量也不能证明满量容量,因为非线性瓶颈可能在后面才出现。满量之后仍需监控,并明确何时算发布完成。观察不是等待一个固定数字过去,而是取得足够新鲜、相关、可解释的证据。

坏掉的样子

只靠定时器的发布,即使没有取得有效测量也会继续晋升。证据不足时应暂停,由明确负责人在约定时间内调查,不能悄悄按成功处理。自动回滚在触发条件与恢复动作都经过验证时可以缩短响应时间,但有些故障需要值班人员判断。自动化是一种执行手段,不会证明恢复操作本身没有风险;暂停也不能无限无人接手。

另一种失败,是认为恢复旧二进制就能恢复旧状态。数据库模式、已持久化格式、已经发出的消息和外部副作用,都可能在流量切回以后继续存在。应保留仍兼容的旧产物,并演练真实恢复路径。如果反向切换不安全,应准备向前修复或业务对账方案,不要展示一个实际上无法恢复服务的回滚按钮。发布可逆性来自这些设计条件,而不是来自阶段名称。

全量平均会掩盖局部退化。假设旧版本错误率为零,处理十分之一请求的金丝雀出现百分之十五错误,摊到全量后只有百分之一点五。算术上的稀释并没有使故障人群变得可以接受。需要同时观察群体拆分、业务结果和整体健康,而且对照组自身也必须正常。不同人群的流量类型差异,还可能让简单比较失去解释力。

快速验证

保存为 rollout_check.py,用 Python 3.10 或更新版本执行 python3 rollout_check.py。本轮以 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")

健康样本会通过四个阶段,故障样本在第一级返回请求回滚。没有测量与只有一百个请求的零错误样本都会暂停。边界断言允许恰好百分之二,拒绝百分之二点一;不合法的请求和错误计数则触发校验失败。这些检查说明代码如何决策,没有证明阈值具有统计合理性,也没有测量不同流量分配是否真正落实。

最后的计算用明确假设的零错误对照展示全量稀释。真实发布还必须验证群体可比性、遥测新鲜度、观察时间和恢复动作的实际结果。返回 ROLLBACK_REQUESTED 只是一项决定,服务是否恢复必须另有执行结果和健康证据。把这两个阶段分开记录,才能避免流水线打印了成功,却仍有故障版本在处理请求。

延伸阅读

来源