9.2 灰度、放量与回滚

《合成小岛》新版本上线不靠「全量一次推」,而是按 1%/5%/20%/50%/100% 五档灰度放量:每档观察一段时间,崩溃率越界或留存跌破下限就回滚。这一节给出一张灰度与回滚阈值配置、一段可播种的放量仿真脚本,量化缺陷版本被抓住的档位分布与用户暴露量,与「大爆炸全量发布」对照,并做一次阈值敏感性扫描,把「放量放多大、阈值卡多紧」变成可跑出的数字。

上一节把版本推到了「审核通过」这一步,但审核通过只意味着可以发布,不意味着该全量发布。版本一旦对全量用户生效,一个隐藏缺陷就同时砸在所有人头上;而在放量过程中,同样的缺陷只砸在一小部分人头上。这一节讲的就是怎么用最小的暴露换最大的信息——灰度放量与回滚。它是 9.1 的自然延续:9.1 解决「能不能上线」,本节解决「上线之后怎么一步步放大、出事怎么退回来」。

先说边界。买量与增长(第 5、6 章)讲的是「人怎么进来」,本节讲的是**「版本怎么对已有用户生效」,两者不重叠。真实平台的灰度能力(按比例放量、按人群圈选、秒级回滚)属于公开可观察机制**,因平台而异、命令行不可复现(X);本节把它抽象成「档位 + 观察窗口 + 回滚阈值」三个可配置量,用仿真量化放量的代价与收益。

灰度不是「慢慢推」,是「用小暴露换大信息」

灰度放量(canary release)常被误解成「稳妥起见慢慢来」。它真正的逻辑是用小暴露换大信息:先把新版本只放给 1% 的用户,观察一段时间;如果这段观察窗口里没有异常,就把比例提到 5%、20%、50%,最后到 100%。每一档都是一次采样——样本越大,越能看清版本的真实表现;但样本越大,一旦版本有缺陷,被砸中的用户也越多。

于是灰度设计的核心是一对对立的量:放量速度(多久放大一档,决定新版本铺开得多快)与暴露代价(缺陷版本在放量过程中被多少用户、多久地体验到)。这两个量都取决于同一件事:你在每一档观察什么指标、阈值卡在哪。阈值卡得松,缺陷版本可能一路放到 100% 才被发现;卡得紧,健康版本又可能被误判成缺陷而白白回滚。本节把这两个量都做成可跑的。

D 层:把放量档位与回滚阈值写进配置

下面这份是 D 层(静态层)配置:五档放量计划、健康基准值、回滚阈值、缺陷率假设。全整数、禁浮点。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "灰度放量与回滚模型:新版本按 1%/5%/20%/50%/100% 五档逐步放量,每档观察一段时间;若崩溃率越过上限或留存低于下限即回滚。健康值 / 阈值 / 缺陷率均为假设参数,真实线上监控口径需真实业务环境,命令行不可复现(X)。全整数,禁浮点。",
  "runs": 2000,
  "dauPerDay": 100000,
  "bigBangDetectMinutes": 1440,
  "rampSteps": [
    { "step": 1, "label": "1%", "trafficPermille": 10, "dwellMinutes": 60 },
    { "step": 2, "label": "5%", "trafficPermille": 50, "dwellMinutes": 120 },
    { "step": 3, "label": "20%", "trafficPermille": 200, "dwellMinutes": 180 },
    { "step": 4, "label": "50%", "trafficPermille": 500, "dwellMinutes": 240 },
    { "step": 5, "label": "100%", "trafficPermille": 1000, "dwellMinutes": 0 }
  ],
  "health": {
    "goodCrashPermille": 2,
    "badCrashPermille": 5,
    "goodRetentionPermille": 400,
    "badRetentionPermille": 392,
    "crashNoise": 3,
    "retentionNoise": 8
  },
  "thresholds": {
    "crashRateMaxPermille": 6,
    "retentionMinPermille": 392
  },
  "thresholdSweep": {
    "retentionMinPermille": [388, 392, 396, 400, 404]
  },
  "defectRatePermille": 400
}

rampSteps 是五档放量:trafficPermille 是该档覆盖的流量千分比(10‰ = 1%),dwellMinutes 是该档观察时长;最后一档 100% 的 dwellMinutes 为 0(终点档,无观察窗口)。health 是两套假设基准——健康版本与缺陷版本的崩溃率、留存;crashNoise / retentionNoise 是观测噪声幅度。thresholds 是回滚线:崩溃率越过 crashRateMaxPermille、或留存低于 retentionMinPermille 就回滚。thresholdSweep 是后面要扫的留存下限取值。所有数字是假设值,真实线上监控口径需真实业务环境,命令行不可复现(X)。

S 层:灰度放量仿真

下面这段是 S 层仿真脚本,读上面这份配置:每个版本是否有缺陷由可播种 LCG 按 defectRatePermille 决定,逐档采样崩溃率与留存,越界即回滚。它同时对照「大爆炸全量发布」,并对留存下限做一次阈值敏感性扫描。

#!/usr/bin/env python3
# file: sim/l09_canary_ramp.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l09_canary_ramp.py --config config/ --out out/
"""灰度放量与回滚仿真:读 config/l09_canary.json。
新版本按五档逐步放量(1% / 5% / 20% / 50% / 100%),每档观察一段时间;
监控崩溃率与留存:崩溃率越过上限或留存低于下限即回滚,回滚发生在哪一档就停在哪一档。
每个版本「是否有缺陷」由可播种 LCG 按 defectRatePermille 决定;观测值 = 健康值 + 噪声(LCG)。
对照「大爆炸全量发布」:缺陷版本在全量用户上暴露一整个检测窗口。
再做一次阈值敏感性扫描:留存下限越紧,健康版本被误回滚的比例越高。
输出:回滚率(缺陷版 / 健康版)、缺陷版被抓住的档位分布、暴露量(用户·分钟)对比。
健康值 / 阈值 / 缺陷率均为假设参数,真实线上监控口径需真实业务环境,命令行不可复现(X)。
产出 JSON + CSV + SVG。全整数,无浮点。
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import os
import xml.etree.ElementTree as ET


def load(d: str) -> dict:
    with open(os.path.join(d, "l09_canary.json"), encoding="utf-8") as f:
        return json.load(f)


def make_rng(seed: int):
    state = seed & 0x7FFFFFFF

    def nxt() -> int:
        nonlocal state
        state = (1103515245 * state + 12345) & 0x7FFFFFFF
        return state

    return nxt


def sample(rng, base: int, noise: int) -> int:
    """健康值 + [-noise, +noise] 的整数噪声;崩溃率下限截断到 0。"""
    return max(0, base + (rng() % (2 * noise + 1) - noise))


def run_canary(rng, cfg: dict, is_bad: bool, retention_min: int) -> dict:
    """走一遍灰度:返回 {rolledBack, atStep, exposure}。exposure 单位 = 用户·分钟。"""
    h, th = cfg["health"], cfg["thresholds"]
    base_crash = h["badCrashPermille"] if is_bad else h["goodCrashPermille"]
    base_ret = h["badRetentionPermille"] if is_bad else h["goodRetentionPermille"]
    exposure = 0
    for st in cfg["rampSteps"]:
        users = cfg["dauPerDay"] * st["trafficPermille"] // 1000
        exposure += users * st["dwellMinutes"]
        crash = sample(rng, base_crash, h["crashNoise"])
        ret = sample(rng, base_ret, h["retentionNoise"])
        if crash > th["crashRateMaxPermille"] or ret < retention_min:
            return {"rolledBack": True, "atStep": st["step"], "exposure": exposure}
    return {"rolledBack": False, "atStep": 0, "exposure": exposure}


def percentile(sorted_vals: list, p: int) -> int:
    if not sorted_vals:
        return 0
    return sorted_vals[(p * (len(sorted_vals) - 1)) // 100]


def draw_svg(path: str, bad_exposure: list, big_bang: int, caught_at: dict, n_bad: int) -> int:
    W, H = 720, 340
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                             "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "text", {"x": "16", "y": "26", "font-size": "15", "fill": "#111827"}).text = \
        "缺陷版本暴露量:灰度回滚 vs 大爆炸(对数刻度,单位 用户·分钟)"
    bars = [("灰度回滚(中位)", percentile(sorted(bad_exposure), 50)), ("大爆炸全量", big_bang)]
    maxlog = max(math.log10(max(1, v)) for _, v in bars) or 1
    for i, (label, val) in enumerate(bars):
        y = 60 + i * 70
        w = int(560 * math.log10(max(1, val)) / maxlog)
        ET.SubElement(svg, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
                                    "fill": "#374151"}).text = label
        ET.SubElement(svg, "rect", {"x": "170", "y": str(y + 6), "width": str(w), "height": "24",
                                    "rx": "4", "fill": "#dc2626" if i == 1 else "#2563eb"})
        ET.SubElement(svg, "text", {"x": str(178 + w), "y": str(y + 23), "font-size": "12",
                                    "fill": "#6b7280"}).text = f"{val}"
    ET.SubElement(svg, "text", {"x": "16", "y": str(H - 60), "font-size": "13",
                                "fill": "#111827"}).text = f"缺陷版回滚档位分布(共 {n_bad} 次)"
    for j, (step, cnt) in enumerate(sorted(caught_at.items())):
        x = 40 + j * 110
        h = 60 * cnt // (max(caught_at.values()) or 1)
        ET.SubElement(svg, "rect", {"x": str(x), "y": str(H - 30 - h), "width": "80", "height": str(h),
                                    "fill": "#f59e0b"})
        ET.SubElement(svg, "text", {"x": str(x + 10), "y": str(H - 16), "font-size": "11",
                                    "fill": "#6b7280"}).text = f"档{step}:{cnt}"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(svg))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    cfg = load(a.config)
    rng = make_rng(cfg["seed"])
    big_bang = cfg["dauPerDay"] * cfg["bigBangDetectMinutes"]
    retention_min = cfg["thresholds"]["retentionMinPermille"]

    bad_exp, good_exp = [], []
    caught_at = {}
    n_bad = n_good = bad_rb = good_rb = 0
    for _ in range(cfg["runs"]):
        is_bad = rng() % 1000 < cfg["defectRatePermille"]
        r = run_canary(rng, cfg, is_bad, retention_min)
        if is_bad:
            n_bad += 1
            bad_exp.append(r["exposure"])
            if r["rolledBack"]:
                bad_rb += 1
                caught_at[r["atStep"]] = caught_at.get(r["atStep"], 0) + 1
        else:
            n_good += 1
            good_exp.append(r["exposure"])
            if r["rolledBack"]:
                good_rb += 1
    bad_exp.sort()
    good_exp.sort()

    # 阈值敏感性:固定同一批版本,只改留存下限,看健康版误报率如何变化。
    sweep = []
    for t in cfg["thresholdSweep"]["retentionMinPermille"]:
        srng = make_rng(cfg["seed"])
        fp = 0
        for _ in range(cfg["runs"]):
            is_bad = srng() % 1000 < cfg["defectRatePermille"]
            r = run_canary(srng, cfg, is_bad, t)
            if (not is_bad) and r["rolledBack"]:
                fp += 1
        sweep.append({"retentionMinPermille": t, "goodRollbackPermille": fp * 1000 // n_good if n_good else 0})

    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "l09_canary_ramp.svg"), bad_exp, big_bang, caught_at, n_bad)
    with open(os.path.join(a.out, "l09_canary_ramp.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["step", "label", "trafficPermille", "dwellMinutes", "users", "stepExposure"])
        for st in cfg["rampSteps"]:
            users = cfg["dauPerDay"] * st["trafficPermille"] // 1000
            w.writerow([st["step"], st["label"], st["trafficPermille"], st["dwellMinutes"],
                        users, users * st["dwellMinutes"]])
    rep = {"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"], "runs": cfg["runs"],
           "dauPerDay": cfg["dauPerDay"], "bigBangExposure": big_bang,
           "badReleases": n_bad, "goodReleases": n_good,
           "badRollbackRatePermille": bad_rb * 1000 // n_bad if n_bad else 0,
           "goodRollbackRatePermille": good_rb * 1000 // n_good if n_good else 0,
           "caughtAtStep": {str(k): v for k, v in sorted(caught_at.items())},
           "badExposure": {"p50": percentile(bad_exp, 50), "p90": percentile(bad_exp, 90),
                           "max": bad_exp[-1] if bad_exp else 0},
           "exposureRatioWhole": (big_bang // percentile(bad_exp, 50)) if percentile(bad_exp, 50) else 0,
           "thresholdSweep": sweep,
           "csv": os.path.join(a.out, "l09_canary_ramp.csv"),
           "svg": os.path.join(a.out, "l09_canary_ramp.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "l09_canary_ramp.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)
    print(json.dumps({"badReleases": n_bad, "goodReleases": n_good,
                      "badRollbackRatePermille": rep["badRollbackRatePermille"],
                      "goodRollbackRatePermille": rep["goodRollbackRatePermille"],
                      "caughtAtStep": rep["caughtAtStep"], "badP50Exposure": rep["badExposure"]["p50"],
                      "bigBangExposure": big_bang, "exposureRatioWhole": rep["exposureRatioWhole"],
                      "sweep": sweep}, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

脚本的核心是 run_canary:按档位顺序推进,每档先把该档的暴露量累加(用户数 × 观察分钟),再采样两个指标,任一越界就返回「在哪一档回滚」。在本机跑它,输出如下(同机相对量级,绝对数值不可移植):

{"badReleases": 770, "goodReleases": 1230, "badRollbackRatePermille": 992, "goodRollbackRatePermille": 0, "caughtAtStep": {"1": 488, "2": 166, "3": 64, "4": 31, "5": 15}, "badP50Exposure": 60000, "bigBangExposure": 144000000, "exposureRatioWhole": 2400, "sweep": [{"retentionMinPermille": 388, "goodRollbackPermille": 0}, {"retentionMinPermille": 392, "goodRollbackPermille": 0}, {"retentionMinPermille": 396, "goodRollbackPermille": 727}, {"retentionMinPermille": 400, "goodRollbackPermille": 920}, {"retentionMinPermille": 404, "goodRollbackPermille": 986}]}

两千次里 770 次是有缺陷的版本,它们 99.2% 都被抓住并回滚;回滚发生在哪一档的分布是 第 1 档 488 次、第 2 档 166 次、第 3 档 64 次、第 4 档 31 次、第 5 档 15 次——大多数缺陷在最便宜的第一档就暴露了,但仍有一小撮「症状轻」的缺陷一路漏到了后段。健康版本在阈值 392‰ 下的误回滚率是 0。

暴露量对比:为什么灰度是「用小钱试错」

上一节的分档暴露量直接解释了灰度的价值。缺陷版本在中位情形下只暴露了 6 万用户·分钟(第一档 1% × 60 分钟),而对照的「大爆炸全量发布」是全量用户暴露一整个检测窗口 14400 万用户·分钟——两者相差约 2400 倍(同机相对量级)。这就是灰度存在的全部理由:缺陷的代价被放量档位按比例削掉了,档位越早抓住缺陷,省下的暴露越多。

但「2400 倍」这个数字本身依赖一堆假设:日活、检测窗口、缺陷率、放量档位。真实的绝对量级不可移植,能移植的是结构:只要第一档的比例足够小、观察窗口足够短,缺陷版本在「发现—回滚」之间的暴露就一定远小于全量。灰度的调参方向因此很清晰——在能看清指标的前提下,第一档尽量小、观察窗口尽量短。

阈值越紧越好吗

直觉上,回滚阈值卡得越紧,越不容易漏掉缺陷。但仿真里的阈值扫描给了一个反向的提醒:阈值越紧,健康版本被误回滚的比例越高。扫描结果(同一批版本,只改留存下限)是——下限 388‰ / 392‰ 时误回滚率 0;收到 396‰ 升到 727‰;400‰ 升到 920‰;404‰ 升到 986‰。也就是说,把留存下限从 392 收到 400(只收紧了 8‰),健康版本的误回滚率就从 0 蹿到九成。

原因是观测噪声:健康版本的留存本身就在基准值上下波动,阈值一旦落进这个波动区间,正常波动就会被读成「留存跌破」。所以阈值的正确位置不是「越紧越好」,而是卡在「健康版本的正常波动之外、缺陷版本的异常之下」的那条缝里。缝太窄就没有容错,缝太宽就会漏。这条缝有多宽,取决于你的指标噪声有多大——噪声越大,阈值越要往外放,同时观察窗口要拉长以压低噪声。

小结与下一步

这一节把灰度放量做成了一个可跑模型:五档放量、逐档采样、越界回滚;缺陷版本 99.2% 被抓住,中位暴露量比大爆炸全量低约三个数量级(同机相对量级);阈值扫描则说明阈值不是越紧越好,要卡在健康波动之外、异常之下。灰度是「用小暴露换大信息」,回滚是「信息一到就止损」。

灰度、放量、回滚都依赖一套上线前的检查:包体、性能、功能、变现、数据、运营各类项目是否齐备,门禁项是否全过。下一节就把这张上线检查清单做成一份带 schema 的配置,配一个可跑的校验脚本,把「能不能上线」从口头确认变成一次机器校验。

阅读导航:上一节:提审与发布流程 · 下一节:上线检查清单 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制