上一节把版本推到了「审核通过」这一步,但审核通过只意味着可以发布,不意味着该全量发布。版本一旦对全量用户生效,一个隐藏缺陷就同时砸在所有人头上;而在放量过程中,同样的缺陷只砸在一小部分人头上。这一节讲的就是怎么用最小的暴露换最大的信息——灰度放量与回滚。它是 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 的配置,配一个可跑的校验脚本,把「能不能上线」从口头确认变成一次机器校验。
阅读导航:上一节:提审与发布流程 · 下一节:上线检查清单 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。