7.3 灰度、放量与回滚

版本做完不等于能安全上线。本节把灰度写成配置:用 FNV-1a 分桶决定「放多少」,用硬/警告两级阈值决定「继续、暂停还是回滚」,并用《王朝纪元》的真实跑数,演示一次 50% 直跳 100% 的翻车,以及拆成小步后顺利放量的完整过程与代价。

7.3 灰度、放量与回滚

内容做完、按期交付,还差最后一步:安全地上线。SLG 是全天候在线品,一次全量发布把线上打挂,损失的是正在进行的赛季和联盟战。产业侧那篇 SLG 经济系统运营 2026 清单 把「发布风控」列为运营动作;本节把它做成可跑的判定器——用分桶决定放量范围,用两级阈值决定继续、暂停还是回滚,并把整个过程输出成 CSV 与 SVG。事故后的修复与回滚流程在 15.3 展开,这里聚焦放量节奏本身。

分桶:把「放多少」变成整数问题

灰度的第一步是分桶:把玩家按稳定哈希分到 totalBuckets 个桶里,coveragePct 决定前多少个桶吃到新版本。三个硬要求:

  • 稳定:同一玩家每次哈希到同一个桶,否则同一个人一会儿新一会儿旧。
  • 均匀:桶分布要接近均匀,否则 1% 覆盖可能压中某个联盟的全部玩家。
  • 可跨语言复现:客户端/服务端/后台脚本用同一份哈希,否则判定不一致。

《王朝纪元》用 FNV-1a 32 位:bucket = fnv1a32(playerId) % totalBuckets,isCanary = bucket < coveragePct。下面这段是分桶函数的实跑样本,TS 镜像 proto/src/ch07_canary.ts 用 Math.imul 实现同一函数,逐位一致——这是本套「一份配置两处消费」的又一次落地。

$ python3 sim/ch07_canary.py --config config/ --plan ch07_canary --sample p1001 --sample p1002
plan=ch07_canary outcome=ROLLBACK reached=100% hours=114
  ...
  bucket(p1001)=17 canary@20%=True
  bucket(p1002)=60 canary@20%=False

放量节奏与两级阈值

放量是一串阶段(stage),每阶段给出目标覆盖比例与观察时长。判定发生在每个观察窗口结束时,读四个指标:崩溃率 crashRatePct、错误率 errorRatePct、p99LatencyMs、次日留存 dayRetentionPct。阈值分两级:

  • 硬阈值(hard):任一指标越界即 ROLLBACK,立即回滚到全量旧版本。
  • 警告带(warn):越界但未破硬阈值即 HOLD,停在当前覆盖比例,不再放大。
  • 都未越界则 GO,进入下一阶段。

下面这段是 D 层配置节选(完整文件 config/ch07_canary.json),给出阈值与两个阶段:

<!-- snippet: config/ch07_canary.json(节选) -->
{
  "totalBuckets": 100,
  "thresholds": {
    "hard": { "maxCrashRatePct": 1.0, "maxErrorRatePct": 2.0, "maxP99LatencyMs": 800, "minDayRetentionPct": 40 },
    "warn": { "maxCrashRatePct": 0.9, "maxErrorRatePct": 1.8, "maxP99LatencyMs": 780, "minDayRetentionPct": 41 }
  },
  "stages": [
    { "id": "S3", "coveragePct": 50, "durationHours": 24,
      "telemetry": { "crashRatePct": 0.8, "errorRatePct": 1.4, "p99LatencyMs": 720, "dayRetentionPct": 43 } },
    { "id": "S4", "coveragePct": 100, "durationHours": 48,
      "telemetry": { "crashRatePct": 1.6, "errorRatePct": 2.3, "p99LatencyMs": 910, "dayRetentionPct": 38 } }
  ]
}

telemetry 是监控窗口的观测值:真实环境里它来自监控系统(属 X 层,命令行不可复现),这里直接写进配置,让脚本专注判定策略本身。

判定器

下面这段是 S 层仿真脚本(完整文件 sim/ch07_canary.py),实现分桶、阈值判定与放量曲线,产出 JSON、CSV 与 SVG。

# file: sim/ch07_canary.py
# tier: S
# verify: python3 sim/ch07_canary.py --config config/ --plan ch07_canary
"""灰度放量仿真:分桶 + 指标阈值判定(放量 / 暂停 / 回滚)。

分桶哈希用 FNV-1a 32 位,与 proto/src/ch07_canary.ts 逐位一致(整数运算)。
监控窗口 telemetry 由 config 给定,模拟的是**判定策略**;真实线上指标属 X 层。
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import xml.etree.ElementTree as ET

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "..", "out")


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


# ---------- 分桶:FNV-1a 32 位,纯整数,跨语言逐位一致 ----------

def fnv1a32(s: str, offset: int = 2166136261, prime: int = 16777619) -> int:
    h = offset & 0xFFFFFFFF
    for ch in s.encode("utf-8"):
        h = (h ^ ch) & 0xFFFFFFFF
        h = (h * prime) & 0xFFFFFFFF
    return h


def bucket_of(player_id: str, total_buckets: int, offset: int = 2166136261, prime: int = 16777619) -> int:
    return fnv1a32(player_id, offset, prime) % total_buckets


def is_canary(player_id: str, coverage_pct: int, total_buckets: int,
              offset: int = 2166136261, prime: int = 16777619) -> bool:
    return bucket_of(player_id, total_buckets, offset, prime) < coverage_pct


# ---------- 指标阈值判定 ----------

def evaluate(telemetry: dict, thresholds: dict) -> dict:
    """返回 {decision, breaches}:hard 越界→ROLLBACK,warn 越界→HOLD,否则 GO。"""
    hard, warn = thresholds["hard"], thresholds["warn"]
    breaches = []
    rollback = False
    hold = False
    if telemetry["crashRatePct"] > hard["maxCrashRatePct"]:
        breaches.append("crash>hard"); rollback = True
    elif telemetry["crashRatePct"] >= warn["maxCrashRatePct"]:
        breaches.append("crash>=warn"); hold = True
    if telemetry["errorRatePct"] > hard["maxErrorRatePct"]:
        breaches.append("error>hard"); rollback = True
    elif telemetry["errorRatePct"] >= warn["maxErrorRatePct"]:
        breaches.append("error>=warn"); hold = True
    if telemetry["p99LatencyMs"] > hard["maxP99LatencyMs"]:
        breaches.append("p99>hard"); rollback = True
    elif telemetry["p99LatencyMs"] >= warn["maxP99LatencyMs"]:
        breaches.append("p99>=warn"); hold = True
    if telemetry["dayRetentionPct"] < hard["minDayRetentionPct"]:
        breaches.append("retention<hard"); rollback = True
    elif telemetry["dayRetentionPct"] <= warn["minDayRetentionPct"]:
        breaches.append("retention<=warn"); hold = True
    decision = "ROLLBACK" if rollback else ("HOLD" if hold else "GO")
    return {"decision": decision, "breaches": breaches}


def simulate(cfg: dict) -> dict:
    rows = []
    elapsed = 0
    outcome = "COMPLETE"
    reached = 0
    for st in cfg["stages"]:
        ev = evaluate(st["telemetry"], cfg["thresholds"])
        rows.append({
            "stage": st["id"], "coveragePct": st["coveragePct"], "durationHours": st["durationHours"],
            "startHour": elapsed, "endHour": elapsed + st["durationHours"],
            "decision": ev["decision"], "breaches": ev["breaches"],
            "telemetry": st["telemetry"],
        })
        if ev["decision"] == "ROLLBACK":
            outcome = "ROLLBACK"; reached = st["coveragePct"]; elapsed += st["durationHours"]
            break
        if ev["decision"] == "HOLD":
            outcome = "HOLD"; reached = st["coveragePct"]; elapsed += st["durationHours"]
            break
        reached = st["coveragePct"]
        elapsed += st["durationHours"]
    return {"outcome": outcome, "reachedCoveragePct": reached, "totalHours": elapsed, "stages": rows}


def write_csv(sim: dict, path: str) -> None:
    with open(path, "w", encoding="utf-8") as f:
        f.write("stage,coveragePct,startHour,endHour,crashRatePct,errorRatePct,p99LatencyMs,dayRetentionPct,decision\n")
        for r in sim["stages"]:
            t = r["telemetry"]
            f.write(f"{r['stage']},{r['coveragePct']},{r['startHour']},{r['endHour']},"
                    f"{t['crashRatePct']},{t['errorRatePct']},{t['p99LatencyMs']},{t['dayRetentionPct']},{r['decision']}\n")


def write_svg(cfg: dict, sim: dict, path: str) -> None:
    W, H = 720, 300
    PAD_L, PAD_R, PAD_T, PAD_B = 56, 24, 24, 40
    stages = sim["stages"]
    total_h = max(sim["totalHours"], 1)
    inner_w = W - PAD_L - PAD_R
    inner_h = H - PAD_T - PAD_B

    def x_of(h: int) -> float:
        return PAD_L + inner_w * h / total_h

    def y_of(cov: int) -> float:
        return PAD_T + inner_h * (1 - cov / 100)

    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg",
                             "viewBox": f"0 0 {W} {H}", "width": str(W), "height": str(H)})
    ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "line", {"x1": str(PAD_L), "y1": str(PAD_T), "x2": str(PAD_L), "y2": str(H - PAD_B),
                                "stroke": "#333", "stroke-width": "1"})
    ET.SubElement(svg, "line", {"x1": str(PAD_L), "y1": str(H - PAD_B), "x2": str(W - PAD_R),
                                "y2": str(H - PAD_B), "stroke": "#333", "stroke-width": "1"})
    pts = [(x_of(0), y_of(0))]
    color = {"GO": "#1f6feb", "HOLD": "#d29922", "ROLLBACK": "#d1242f"}
    for r in stages:
        pts.append((x_of(r["endHour"]), y_of(r["coveragePct"])))
    ET.SubElement(svg, "polyline", {"points": " ".join(f"{x:.1f},{y:.1f}" for x, y in pts),
                                    "fill": "none", "stroke": "#1f6feb", "stroke-width": "2"})
    for r in stages:
        cx, cy = x_of(r["endHour"]), y_of(r["coveragePct"])
        ET.SubElement(svg, "circle", {"cx": f"{cx:.1f}", "cy": f"{cy:.1f}", "r": "4",
                                      "fill": color[r["decision"]]})
        t = ET.SubElement(svg, "text", {"x": f"{cx:.1f}", "y": f"{cy - 8:.1f}",
                                        "font-size": "10", "text-anchor": "middle", "fill": "#333"})
        t.text = f"{r['coveragePct']}%"
    for cov in (0, 50, 100):
        y = y_of(cov)
        t = ET.SubElement(svg, "text", {"x": str(PAD_L - 8), "y": f"{y + 4:.1f}",
                                        "font-size": "11", "text-anchor": "end", "fill": "#555"})
        t.text = f"{cov}%"
    ttl = ET.SubElement(svg, "text", {"x": str(PAD_L), "y": "16", "font-size": "13", "fill": "#111"})
    ttl.text = f"灰度放量曲线(outcome={sim['outcome']},reached={sim['reachedCoveragePct']}%)"
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default=os.path.join(HERE, "..", "config"))
    ap.add_argument("--plan", default="ch07_canary", help="配置文件名(不含 .json)")
    ap.add_argument("--sample", action="append", default=[], help="打印某玩家分桶")
    args = ap.parse_args()

    cfg = load_config(args.config, args.plan)
    sim = simulate(cfg)
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, f"{args.plan}_report.json"), "w", encoding="utf-8") as f:
        json.dump(sim, f, ensure_ascii=False, indent=2)
    write_csv(sim, os.path.join(OUT, f"{args.plan}.csv"))
    write_svg(cfg, sim, os.path.join(OUT, f"{args.plan}.svg"))

    print(f"plan={args.plan} outcome={sim['outcome']} reached={sim['reachedCoveragePct']}% hours={sim['totalHours']}")
    for r in sim["stages"]:
        print(f"  {r['stage']} cov={r['coveragePct']:>3}% -> {r['decision']:<8} {r['breaches']}")
    for pid in args.sample:
        print(f"  bucket({pid})={bucket_of(pid, cfg['totalBuckets'])} canary@20%={is_canary(pid, 20, cfg['totalBuckets'])}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

基准方案:50% 直跳 100% 翻车

基准方案是五段式:1% → 5% → 20% → 50% → 100%。前四段稳,最后一段从 50% 直接跳到 100%:

$ python3 sim/ch07_canary.py --config config/ --plan ch07_canary
plan=ch07_canary outcome=ROLLBACK reached=100% hours=114
  S0 cov=  1% -> GO       []
  S1 cov=  5% -> GO       []
  S2 cov= 20% -> GO       []
  S3 cov= 50% -> GO       []
  S4 cov=100% -> ROLLBACK ['crash>hard', 'error>hard', 'p99>hard', 'retention<hard']

S4 四个指标同时越硬线,判定 ROLLBACK,覆盖停在 100% 但随即回退。翻车的根因不是「100% 一定不行」,而是一步跨了 50 个百分点:负载瞬间翻倍,崩溃率、错误率、延迟、留存一起恶化。

修正方案:拆成小步

把 100% 前的跳跃拆成 50% → 70% → 100% 三小步,每步 20~30 个百分点,同样跑一遍(配置 ch07_canary_fixed.json):

$ python3 sim/ch07_canary.py --config config/ --plan ch07_canary_fixed
plan=ch07_canary_fixed outcome=COMPLETE reached=100% hours=138
  S0 cov=  1% -> GO       []
  S1 cov=  5% -> GO       []
  S2 cov= 20% -> GO       []
  S3 cov= 40% -> GO       []
  S3b cov= 70% -> GO       []
  S4 cov=100% -> GO       []

六段全 GO,outcome=COMPLETE,reached=100%。代价是多花 24 小时(hours 从 114 涨到 138)——用时间换确定性,这正是灰度存在的意义。注意两方案的总阶段数不同(5 vs 6),说明「放量节奏」本身就是可调参数,不是固定套路。

回滚不是失败

三个要点收束本节:

  • 回滚是设计出来的能力,不是事故的同义词。判定器越早触发 ROLLBACK,损失越小;把阈值调到「永不回滚」才是真危险。
  • HOLD 与 ROLLBACK 要分开:警告带触发 HOLD(停手观察),硬阈值才 ROLLBACK(推倒重来)。两者混用会导致要么过于激进、要么永不前进。
  • 分桶哈希必须跨语言一致:本节的分桶函数在 Python 与 TS 两侧逐位一致(p1001→17、p1002→60),任何一侧算错,判定就失去意义。

放量判定用的指标口径、埋点与看板,是第 8 章的主题——指标算错,阈值再准也白搭。

阅读导航:上一节:7.2 内容产能与排期 · 下一节:8.1 北极星指标树 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引