8.3 数据看板与告警

看板的价值不在画曲线,而在给曲线装闸门:用配置驱动的《王朝纪元》告警规则(阈值 / 环比 / 同比三类,带 streak 过滤),跑一段 mulberry32 确定性时序做告警判定,产出 CSV + SVG,再用 B 层基准量化误报率与检出延迟。

指标树(8.1)规定了算什么,埋点 schema(8.2)规定了采什么。最后一步是把这些数字摆上看板——但看板真正的价值不是那条漂亮的曲线,而是给曲线装上闸门:什么时候该有人被叫醒。这一节把「告警」这件事从一句「跌了就报警」变成一套可仿真、可量化的规则。

先划边界。站内的游戏商业核心指标详解 告诉你 DAU、留存、付费率分别是什么;经济系统运营 checklist 给的是产业侧的监控清单。本节不重复它们,本节回答的是清单里那句最含糊的话:「跌到多少才算跌」——用可跑的规则与基准把这个阈值算出来。

看板不是画曲线,是给曲线装闸门

一条 DAU 曲线摆在那里,人眼能看出的只有「大起大落」。但真实的告警要在变化还很小的时候就发出信号,同时又不能被日常噪声吵醒。这两件事互相拉扯,靠拍脑袋定阈值必然顾此失彼。解法是把告警拆成三类可计算的规则:

  • 阈值(threshold):绝对水位跌破某条线,比如 DAU 低于基线 90%;
  • 环比(wow):跟上一周期比,比如比上周同星期下滑超过 12%;
  • 同比(yoy):跟去年同期比,比如比 52 周前同星期下滑超过 12%。

三类规则各管一种场景:阈值管「跌破生死线」,环比管「突然掉」,同比管「今年不如去年」。再加一个 streak(连续天数)——条件必须连续成立 N 天才报警——用来把单日噪声挡在门外。

一份告警规则配置

下面这份配置是 D 层(静态层)产物:《王朝纪元》的告警规则唯一真源。它声明了时序怎么造(趋势、星期形状、噪声、事故)以及每条规则怎么判。

{
  "version": 1,
  "game": "王朝纪元",
  "days": 420,
  "seed": 20261012,
  "note": "看板告警规则唯一真源:时序由确定性 PRNG(mulberry32) + 整数基点的趋势/星期/噪声合成,规则支持阈值(threshold)、环比(wow, 滞后 7 天)、同比(yoy, 滞后 364 天)。streak 表示条件需连续成立的天数,用于过滤单日噪声。",
  "weekdayShapeBp": [50, 0, -30, -40, -20, 40, 100],
  "metrics": [
    { "id": "dau", "name": "日活跃领主数", "base": 120000, "trendPerDayBp": 2, "noiseBp": 400,
      "incidents": [
        { "fromDay": 370, "toDay": 376, "dropBp": 2200, "label": "赛季末期集中流失" },
        { "fromDay": 200, "toDay": 200, "dropBp": 1800, "label": "单日服务故障" }
      ] }
  ],
  "rules": [
    { "id": "dau_floor", "metric": "dau", "kind": "threshold", "direction": "below", "floorRatioBp": 9000, "streak": 2, "level": "P2", "desc": "DAU 跌破基线 90% 且连续 2 天" },
    { "id": "dau_wow", "metric": "dau", "kind": "wow", "direction": "below", "pctBp": 1200, "streak": 2, "level": "P1", "desc": "DAU 环比上周同星期下滑超 12% 且连续 2 天" },
    { "id": "dau_yoy", "metric": "dau", "kind": "yoy", "direction": "below", "pctBp": 1200, "streak": 1, "level": "P1", "desc": "DAU 同比 52 周前同星期下滑超 12%" }
  ]
}

这里有两个工程决定值得说清楚。第一,所有比例都用整数基点(万分之一):趋势 trendPerDayBp: 2 就是每天涨 0.02%,噪声 noiseBp: 400 就是 ±4%,这样时序的生成参数全程是整数,可复现。第二,时序用自实现的确定性 PRNG(mulberry32),不依赖任何外部库——同一个 seed 永远产出同一条曲线,这与附录 B 的整数口径一脉相承。

S 层:造时序 + 判告警

下面这段是 S 层仿真脚本:用 mulberry32 造 420 天的 DAU 时序(趋势 × 星期形状 × 噪声,再叠加两段事故),用三类规则逐日判定,产出 CSV(逐日)、SVG(曲线与告警点)和 JSON(告警集)。脚本里的 mulberry32 与 Node 侧逐位一致(前 6 个随机数在 Python 与 Node 上完全相同)。

# file: sim/ch08_alerting.py
# tier: S
# verify: python3 sim/ch08_alerting.py --config config/
"""看板告警规则仿真:造确定性时序(mulberry32 自实现 PRNG,零依赖),用阈值 / 环比 /
同比三类规则判定告警,产出 CSV(逐日)+ SVG(曲线与告警点)+ JSON(告警集)。
时序模型:value[d] = base × 趋势^d × (1 + 星期形状 + 噪声) × 事故衰减。"""
from __future__ import annotations
import argparse, csv, json, os
import xml.etree.ElementTree as ET


def mulberry32(seed: int):
    """与 Node 侧逐位一致的确定性 PRNG,返回 [0,1) 均匀分布。"""
    a = seed & 0xFFFFFFFF

    def rnd() -> float:
        nonlocal a
        a = (a + 0x6D2B79F5) & 0xFFFFFFFF
        t = (a ^ (a >> 15)) & 0xFFFFFFFF
        t = (t * (1 | a)) & 0xFFFFFFFF
        t = ((t + ((t ^ (t >> 7)) * (61 | t))) ^ t) & 0xFFFFFFFF
        return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296

    return rnd


def gen_series(m: dict, days: int, seed: int, shape: list[int], with_incidents: bool = True) -> list[int]:
    rnd, trend, noise, base = mulberry32(seed), 1.0 + m["trendPerDayBp"] / 10000.0, m["noiseBp"] / 10000.0, float(m["base"])
    incs = m.get("incidents", []) if with_incidents else []
    out = []
    for d in range(days):
        seasonal = shape[d % len(shape)] / 10000.0
        v = base * (trend ** d) * (1.0 + seasonal + (rnd() * 2.0 - 1.0) * noise)
        for inc in incs:
            if inc["fromDay"] <= d <= inc["toDay"]:
                v *= 1.0 - inc["dropBp"] / 10000.0
        out.append(int(round(v)))
    return out


def pct_change_bp(cur: int, prev: int) -> int:
    """(cur - prev) / prev,单位基点(万分之一),整数地板除。"""
    return 0 if prev == 0 else (cur - prev) * 10000 // prev


def condition(rule: dict, series: list[int], base: int, d: int) -> bool:
    if rule["kind"] == "threshold":
        return series[d] < base * rule["floorRatioBp"] // 10000
    lag = 7 if rule["kind"] == "wow" else 364
    return d >= lag and pct_change_bp(series[d], series[d - lag]) < -rule["pctBp"]


def eval_rule(rule: dict, series: list[int], base: int) -> list[bool]:
    """streak 表示条件需连续成立的天数,用于过滤单日噪声。"""
    streak = rule.get("streak", 1)
    cond = [condition(rule, series, base, d) for d in range(len(series))]
    return [bool(cond[d]) and d >= streak - 1 and all(cond[d - i] for i in range(streak)) for d in range(len(series))]


def episodes(firing: list[bool]) -> list[dict]:
    eps, start = [], None
    for d, f in enumerate(firing + [False]):
        if f and start is None:
            start = d
        if not f and start is not None:
            eps.append({"from": start, "to": d - 1, "len": d - start}); start = None
    return eps


def run(cfg: dict, with_incidents: bool = True) -> dict:
    days, shape = cfg["days"], cfg["weekdayShapeBp"]
    metrics = {m["id"]: m for m in cfg["metrics"]}
    series = {mid: gen_series(m, days, cfg["seed"], shape, with_incidents) for mid, m in metrics.items()}
    rules = []
    for r in cfg["rules"]:
        firing = eval_rule(r, series[r["metric"]], metrics[r["metric"]]["base"])
        rules.append({"id": r["id"], "metric": r["metric"], "kind": r["kind"], "level": r.get("level", "P2"),
                      "streak": r.get("streak", 1), "firingDays": sum(firing),
                      "episodes": episodes(firing), "firing": firing})
    return {"days": days, "series": series, "rules": rules}


def write_csv(cfg: dict, res: dict, path: str) -> None:
    mid = cfg["metrics"][0]["id"]; s = res["series"][mid]
    flags = {r["id"]: r["firing"] for r in res["rules"]}
    floor = cfg["metrics"][0]["base"] * cfg["rules"][0]["floorRatioBp"] // 10000
    with open(path, "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["day", "value", "floor", "wow_pctBp", "yoy_pctBp"] + [f"{r}_fire" for r in flags])
        for d in range(res["days"]):
            wow = pct_change_bp(s[d], s[d - 7]) if d >= 7 else ""
            yoy = pct_change_bp(s[d], s[d - 364]) if d >= 364 else ""
            w.writerow([d, s[d], floor, wow, yoy] + [int(flags[r][d]) for r in flags])


def render_svg(cfg: dict, res: dict, path: str) -> None:
    s, days = res["series"][cfg["metrics"][0]["id"]], res["days"]
    W, H, pl, pb, pt = 1000, 420, 60, 40, 30
    pw, ph = W - pl - 20, H - pb - pt
    lo, hi = min(s), max(s); span = max(1, hi - lo)
    floor = cfg["metrics"][0]["base"] * cfg["rules"][0]["floorRatioBp"] // 10000
    px = lambda d: pl + d / (days - 1) * pw
    py = lambda v: pt + (hi - v) / span * ph
    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": "#0f1115"})
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": f"{py(floor):.1f}", "x2": str(W - 20), "y2": f"{py(floor):.1f}",
                                "stroke": "#f0b429", "stroke-width": "1", "stroke-dasharray": "6 4"})
    ET.SubElement(svg, "polyline", {"points": " ".join(f"{px(d):.1f},{py(s[d]):.1f}" for d in range(days)),
                                    "fill": "none", "stroke": "#4f9cf9", "stroke-width": "1.6"})
    colors = {"threshold": "#ef4444", "wow": "#f97316", "yoy": "#eab308"}
    for r in res["rules"]:
        for d, f in enumerate(r["firing"]):
            if f:
                ET.SubElement(svg, "circle", {"cx": f"{px(d):.1f}", "cy": f"{py(s[d]):.1f}", "r": "2.6",
                                              "fill": colors.get(r["kind"], "#ef4444")})
    for inc in cfg["metrics"][0].get("incidents", []):
        ET.SubElement(svg, "rect", {"x": f"{px(inc['fromDay']):.1f}", "y": str(pt),
                                    "width": f"{px(inc['toDay']) - px(inc['fromDay']) + 3:.1f}", "height": str(ph),
                                    "fill": "#ef4444", "opacity": "0.08"})
    lx = pl
    for label, color in [("序列", "#4f9cf9"), ("地板", "#f0b429"), ("阈值", "#ef4444"), ("环比", "#f97316"), ("同比", "#eab308")]:
        ET.SubElement(svg, "rect", {"x": str(lx), "y": "8", "width": "10", "height": "10", "fill": color})
        t = ET.SubElement(svg, "text", {"x": str(lx + 14), "y": "17", "fill": "#c8d2e0", "font-family": "sans-serif", "font-size": "11"})
        t.text = label; lx += 72
    ET.indent(ET.ElementTree(svg))
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    args = ap.parse_args()
    with open(os.path.join(args.config, "ch08_alerting.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    res = run(cfg, with_incidents=True)
    report = {"version": cfg["version"], "days": cfg["days"], "seed": cfg["seed"],
              "incidents": cfg["metrics"][0].get("incidents", []),
              "rules": [{k: v for k, v in r.items() if k != "firing"} for r in res["rules"]]}
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "ch08_alerting.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    write_csv(cfg, res, os.path.join(args.out, "ch08_alerting.csv"))
    render_svg(cfg, res, os.path.join(args.out, "ch08_alerting.svg"))
    print(json.dumps(report, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

本机跑它,三条规则各抓到什么(同机相对量级,绝对数值不可移植):

规则类型触发天数告警段说明
dau_floor阈值6371–376跌破基线 90%(108000)
dau_wow环比6371–376比上周同星期低超 12%
dau_yoy同比7370–376比 52 周前同星期低超 12%

三条规则都锁定了第 370–376 天那段「赛季末期集中流失」。有意思的是 dau_yoy 比另外两条早一天报警:环比规则要等到事故第二天才能和「上周同一天」比出落差,而同比规则在事故首日当天就比出了下滑。

单日噪声为什么没触发

配置里还埋了一段第 200 天的单日故障(跌 18%)。从 CSV 看,那天的值 103770 确实跌破了地板 108000——按条件它是成立的。但 dau_floor 和 dau_wow 的 streak 都是 2,要求连续两天成立,于是这一天被挡住了,没有报警。这正是 streak 的取舍:它滤掉了单日噪声,代价是延迟一天——对集中流失这种持续性事故无所谓,但对真正的秒级故障,你需要的是另一条更灵敏的规则。「阈值定多少」和「连续几天算数」是两个独立的设计旋钮,本节把它们拆开,就是为了让你能分别调。

B 层:这些规则到底会不会乱叫

一条规则「看起来合理」不算数,得看它在大量随机种子上的表现。下面这段是 B 层基准脚本,复用 ch08_alerting 的时序生成与判定,跑 200 个种子,量化三件事:无事故时的误报、有事故时的检出率、检出延迟。它是软告警——指标越界只打印提示,不返回非零退出码。

# file: sim/ch08_alert_baseline.py
# tier: B
# verify: python3 sim/ch08_alert_baseline.py --config config/ --seeds 200
"""告警规则的稳态基准(B 层,软告警不硬失败)。复用 ch08_alerting 的时序生成与规则
判定,跑一批随机种子,量化三件事:无事故时的误报、有事故时的检出率、检出延迟。
产出 JSON;越界只打印软告警,不返回非零退出码。"""
from __future__ import annotations
import argparse, json, os, sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import ch08_alerting as A  # noqa: E402


def first_firing(firing: list[bool]) -> int | None:
    return next((d for d, f in enumerate(firing) if f), None)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    ap.add_argument("--seeds", type=int, default=200)
    args = ap.parse_args()
    with open(os.path.join(args.config, "ch08_alerting.json"), encoding="utf-8") as f:
        cfg = json.load(f)
    metric, days, shape = cfg["metrics"][0], cfg["days"], cfg["weekdayShapeBp"]
    main_inc = max(metric["incidents"], key=lambda i: i["dropBp"])   # 主事故 = 跌幅最大的一段
    start, end = main_inc["fromDay"], main_inc["toDay"]
    agg = {r["id"]: {"kind": r["kind"], "falseEpisodes": 0, "seedsWithFP": 0, "detected": 0,
                     "latSum": 0, "latN": 0} for r in cfg["rules"]}
    for s in range(args.seeds):
        seed = cfg["seed"] + s * 7919
        clean = A.gen_series(metric, days, seed, shape, with_incidents=False)
        inc = A.gen_series(metric, days, seed, shape, with_incidents=True)
        for rule in cfg["rules"]:
            a = agg[rule["id"]]
            fp = A.eval_rule(rule, clean, metric["base"])
            a["falseEpisodes"] += len(A.episodes(fp))
            a["seedsWithFP"] += int(any(fp))
            hits = [d for d, f in enumerate(A.eval_rule(rule, inc, metric["base"])) if f and start - 1 <= d <= end + 2]
            if hits:
                a["detected"] += 1
                a["latSum"] += hits[0] - start
                a["latN"] += 1
    n = args.seeds
    report = {"seeds": n, "incidentStart": start, "incidentEnd": end, "rules": []}
    soft = []
    for rid, a in agg.items():
        far, det = a["seedsWithFP"] / n, a["detected"] / n
        report["rules"].append({"id": rid, "kind": a["kind"],
                                "meanFalseEpisodesPerSeed": round(a["falseEpisodes"] / n, 4),
                                "falseAlarmRate": round(far, 4), "detectionRate": round(det, 4),
                                "meanLatencyDays": None if not a["latN"] else round(a["latSum"] / a["latN"], 2)})
        if far > 0.05:
            soft.append(f"SOFT 误报率偏高 {rid}={far:.3f}")
        if det < 0.95:
            soft.append(f"SOFT 检出率偏低 {rid}={det:.3f}")
    report["softWarnings"] = soft
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "ch08_alert_baseline.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps(report, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

跑 200 个种子,结果是这样的:

规则误报率检出率平均检出延迟(天)
dau_floor0.01.01.0
dau_wow0.01.01.0
dau_yoy0.01.00.07

三条规则在 200 个种子上零误报(噪声 ±4% 够不到 12% 的环比/同比门槛),集中流失100% 被检出,延迟 1 天(streak 的代价)到 0 天。这才是「阈值定多少」的答案:不是拍一个数,而是让规则在一批种子上同时满足低误报与高检出。如果哪天有人把 noiseBp 调大到 1200(±12%),误报率会立刻上升,这个基准脚本会在 softWarnings 里直接喊出来。

三节合起来是一条链

8.1 定义算什么(指标树 + 口径唯一),8.2 定义采什么(事件 schema + 命名规范),8.3 定义什么时候响(阈值 / 环比 / 同比 + 基准)。三节共用同一套 bastion 配置口径:指标树的每个叶子指向一个事件,事件的字段决定看板的每个数,看板的每条规则决定谁被叫醒。下一节开始进入本卷的第二部分——经济系统,先给资源和货币分层,那是所有经济闭环的地基。

阅读导航:上一节:埋点规范与口径统一 · 下一节:资源分类与货币分层 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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