3.3 多平台版本一致性与回滚

多平台发行最怕的不是某个平台崩,而是「你以为四个平台跑的是同一版,其实不是」。本节把每个平台的源码版本、配置摘要、内容摘要合成构建指纹,找多数共识、标出离群平台,再按错误率阈值判回滚、重推还是继续;配 Python 仿真与 TypeScript 内核跨语言对拍。

卷三「灰度放量与回滚」 讲的是单个平台内怎么按比例放量、怎么发现异常、怎么回退上一个版本。本节面对的是多平台独有的新问题:当《合成小岛》同时在五个平台运行时,「1.8.0 已经上线」这句话可能对每个平台意味着不同的东西——微信跑的是今天的源码,某个 Web 平台可能还是昨天的,另一个平台被单独热修过。这一节讲的就是怎么证明多个平台跑的确实是同一版,以及不一致时怎么判回滚。

四个平台,真的跑的是同一版吗

先看一个真实会发生的场景。你在今天发了一版 1.8.0,五个平台都显示「已上线 1.8.0」。但实际上:

  • 三个平台拉的是今天的最新源码 + 最新配置;
  • 一个平台卡在昨天的源码(它的审核慢了半天,你以为它更新了其实没有);
  • 另一个平台被单独热修过内容(美术同学发现一个错字,只改了那一个平台)。

结果就是「同一个版本号,五种实际内容」。版本号是人写的字符串,它不保证内容一致。要判断一致性,必须用从内容算出来的指纹,而不是版本号。

构建指纹:三要素合成 buildId

一个平台实际跑的东西由三部分决定,任一不同就是不同的构建:

buildId = sourceRev | configDigest | contentDigest
  • sourceRev:源码版本(比如 r20261013),来自版本控制;
  • configDigest:渠道配置的摘要(第 3.1 节那份 config 的指纹);
  • contentDigest:内容资源(美术 / 文案 / 数值表)的摘要。

三者串起来就是该平台产物的构建指纹。它对应第 3.2 节产物清单哈希 3216319739 的思路——用内容算指纹,而不是用版本号标身份。有了每个平台的 buildId,就能找共识:出现次数最多的那个就是「多数派」,其余都是离群。

D 层:一致性判定配置

判定表放在 config/g03_consistency.json:每个平台给出源码版本、配置摘要、内容摘要与当前错误率,外加一个全局回滚阈值。口径是「公开可观察机制」的建模假设,不是任何平台后台的实测错误率。

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "多平台版本一致性与回滚判定表。口径是「公开可观察机制」的建模假设,不是任何平台后台的实测错误率。buildId = sourceRev|configDigest|contentDigest;共识取多数 buildId(并列取字典序最小);判定:errPermille>=阈值→rollback,否则 buildId!=共识→resync,否则 continue。全整数,无浮点。真实错误率与真实灰度状态需真实业务环境,命令行不可复现(X)。",
  "errorRollbackPermille": 30,
  "goldenSourceRev": "r20261013",
  "platforms": [
    { "id": "wechat", "version": "1.8.0", "sourceRev": "r20261013", "configDigest": "cfg-a1", "contentDigest": "ct-a1", "errPermille": 12 },
    { "id": "douyin", "version": "1.8.0", "sourceRev": "r20261013", "configDigest": "cfg-a1", "contentDigest": "ct-a1", "errPermille": 8 },
    { "id": "poki", "version": "1.8.0", "sourceRev": "r20261013", "configDigest": "cfg-a1", "contentDigest": "ct-a1", "errPermille": 41 },
    { "id": "crazygames", "version": "1.8.0", "sourceRev": "r20261012", "configDigest": "cfg-a0", "contentDigest": "ct-a1", "errPermille": 9 },
    { "id": "gamepix", "version": "1.8.0", "sourceRev": "r20261013", "configDigest": "cfg-a1", "contentDigest": "ct-a2", "errPermille": 15 }
  ]
}

S 层:把判定矩阵算出来

下面这段是 S 层仿真脚本 sim/g03_consistency.py。它读上面那份 config,合成每个平台的 buildId、找共识、标出离群,再按「错误率优先、一致性其次」的顺序给出判定,产出 JSON + CSV + SVG。

#!/usr/bin/env python3
# file: sim/g03_consistency.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/g03_consistency.py --config config/ --out out/
"""多平台版本一致性与回滚判定仿真:读 config/g03_consistency.json,把每个平台
的「源码版本 + 配置摘要 + 内容摘要」合成 buildId,找共识,再按错误率判回滚。

  buildId   = sourceRev | configDigest | contentDigest
  共识      = 出现次数最多的 buildId(并列取字典序最小,保证确定性)
  inSync    = buildId == 共识
  判定      = errPermille >= 阈值 → rollback
              elif buildId != 共识 → resync
              else → continue

口径:错误率与灰度状态是「公开可观察机制」的建模假设,真实值需真实业务环境,
命令行不可复现(X)。产出 JSON + CSV + SVG,全整数,无浮点。
"""
from __future__ import annotations
import argparse, csv, json, os
import xml.etree.ElementTree as ET

CSV_COLS = ("id", "version", "buildId", "inSync", "matchesGolden", "errPermille", "decision", "reason")


def build_id(p: dict) -> str:
    return f'{p["sourceRev"]}|{p["configDigest"]}|{p["contentDigest"]}'


def consensus_of(ids: list) -> str:
    """出现次数最多的 buildId;并列取字典序最小。"""
    counts = {}
    for x in ids:
        counts[x] = counts.get(x, 0) + 1
    best = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
    return best[0][0]


def verdict(cfg: dict, p: dict, consensus: str) -> dict:
    bid = build_id(p)
    in_sync = bid == consensus
    matches_golden = p["sourceRev"] == cfg["goldenSourceRev"]
    if p["errPermille"] >= cfg["errorRollbackPermille"]:
        decision, reason = "rollback", "err>=threshold"
    elif not in_sync:
        decision, reason = "resync", "buildId!=consensus"
    else:
        decision, reason = "continue", "in-sync"
    return {"id": p["id"], "version": p["version"], "buildId": bid, "inSync": in_sync,
            "matchesGolden": matches_golden, "errPermille": p["errPermille"],
            "decision": decision, "reason": reason}


def draw_svg(path: str, rows: list, threshold: int) -> int:
    """纯 Python SVG:每平台一根错误率横条 + 回滚阈值标线。"""
    W, H, PAD, ROW = 780, 70 + 46 * len(rows), 200, 46
    root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
                              "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(root, "text", {"x": "16", "y": "28", "font-size": "15",
                                 "fill": "#111827"}).text = "各平台错误率(‰) 与回滚阈值"
    span = W - PAD - 120
    hi = max([r["errPermille"] for r in rows] + [threshold]) or 1
    xth = PAD + span * threshold // hi
    ET.SubElement(root, "line", {"x1": str(xth), "y1": "44", "x2": str(xth), "y2": str(H - 16),
                                 "stroke": "#ef4444", "stroke-dasharray": "4 4"})
    ET.SubElement(root, "text", {"x": str(xth + 4), "y": "58", "font-size": "11",
                                 "fill": "#ef4444"}).text = f"阈值 {threshold}"
    for i, r in enumerate(rows):
        y = 60 + i * ROW
        w = span * r["errPermille"] // hi
        color = "#dc2626" if r["decision"] == "rollback" else ("#f59e0b" if r["decision"] == "resync" else "#16a34a")
        ET.SubElement(root, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
                                     "fill": "#374151"}).text = r["id"]
        ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w), "height": "20",
                                     "rx": "4", "fill": color})
        ET.SubElement(root, "text", {"x": str(PAD + w + 8), "y": str(y + 22), "font-size": "11",
                                     "fill": "#6b7280"}).text = f'{r["errPermille"]}‰ · {r["decision"]}'
    os.makedirs(os.path.dirname(path), exist_ok=True)
    ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
    return len(ET.tostring(root))


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default="out/")
    a = ap.parse_args()
    with open(os.path.join(a.config, "g03_consistency.json"), encoding="utf-8") as f:
        cfg = json.load(f)

    ids = [build_id(p) for p in cfg["platforms"]]
    consensus = consensus_of(ids)
    rows = [verdict(cfg, p, consensus) for p in cfg["platforms"]]
    in_sync = sum(1 for r in rows if r["inSync"])
    golden = sum(1 for r in rows if r["matchesGolden"])
    n_rollback = sum(1 for r in rows if r["decision"] == "rollback")
    n_resync = sum(1 for r in rows if r["decision"] == "resync")
    n_continue = sum(1 for r in rows if r["decision"] == "continue")
    digest = (f"consensus={consensus},inSync={in_sync},outSync={len(rows) - in_sync}"
              f",rollback={n_rollback},resync={n_resync},continue={n_continue},goldenMatch={golden}")

    os.makedirs(a.out, exist_ok=True)
    svg = draw_svg(os.path.join(a.out, "g03_consistency.svg"), rows, cfg["errorRollbackPermille"])
    with open(os.path.join(a.out, "g03_consistency.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(CSV_COLS)
        for r in rows:
            w.writerow([r["id"], r["version"], r["buildId"], r["inSync"], r["matchesGolden"],
                        r["errPermille"], r["decision"], r["reason"]])
    rep = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
           "errorRollbackPermille": cfg["errorRollbackPermille"], "consensus": consensus,
           "inSync": in_sync, "outSync": len(rows) - in_sync, "rollback": n_rollback,
           "resync": n_resync, "continue": n_continue, "goldenMatch": golden, "digest": digest,
           "platforms": rows, "svg": os.path.join(a.out, "g03_consistency.svg"), "svgBytes": svg}
    with open(os.path.join(a.out, "g03_consistency.json"), "w", encoding="utf-8") as f:
        json.dump(rep, f, ensure_ascii=False, indent=2)

    print(json.dumps({"digest": digest, "consensus": consensus,
                      "decisions": {r["id"]: r["decision"] for r in rows}}, ensure_ascii=False, indent=2))
    return 0


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

在本机跑 /opt/homebrew/bin/python3 sim/g03_consistency.py --config config/ --out out/,五个平台的判定结果是(同机相对量级,绝对数值不可移植):

平台buildId与共识一致错误率‰判定
微信r20261013|cfg-a1|ct-a1是12继续
抖音r20261013|cfg-a1|ct-a1是8继续
Pokir20261013|cfg-a1|ct-a1是41回滚
CrazyGamesr20261012|cfg-a0|ct-a1否9重推
GamePixr20261013|cfg-a1|ct-a2否15重推

这张表把三种完全不同的处置分开了:微信抖音是「继续」,Poki 是「回滚」(它内容与共识一致,但错误率触了 30‰ 的回滚线),CrazyGames 与 GamePix 是「重推」(错误率安全,但构建指纹与共识不符——前者源码与配置都漂了,后者内容漂了)。关键在判定顺序:错误率优先于一致性。Poki 明明和共识一致,却要回滚——因为「版本对」不等于「跑得好」。反过来,离群但没崩的平台不该回滚(回滚会丢掉它可能已经修好的东西),而该重推一个与共识一致的新包。

P 层内核:同一套判定,跨语言对拍

下面这段是 P 层 TypeScript 内核 core/src/g03/consistency.ts:它复算同一套 buildId 合成、共识选取与判定逻辑,读同一份 config,产出与 Python 逐字符一致的摘要。

// file: core/src/g03/consistency.ts
// tier: P
// verify: node --test 'core/test/g03_*.test.ts'
//
// 多平台版本一致性与回滚判定内核:把每个平台的「源码版本 + 配置摘要 + 内容摘要」
// 合成 buildId,找多数共识,再按错误率阈值判回滚 / 重推 / 继续。
// 与 sim/g03_consistency.py 逐位等价的 TypeScript 对偶实现。
// 全整数、无浮点、无 Math.random(只用可擦除语法,禁 enum / namespace)。

export interface PlatformState {
  readonly id: string; readonly version: string;
  readonly sourceRev: string; readonly configDigest: string; readonly contentDigest: string;
  readonly errPermille: number;
}
export interface ConsistencyConfig {
  readonly version: number; readonly game: string;
  readonly errorRollbackPermille: number; readonly goldenSourceRev: string;
  readonly platforms: readonly PlatformState[];
}
export interface Verdict {
  readonly id: string; readonly version: string; readonly buildId: string;
  readonly inSync: boolean; readonly matchesGolden: boolean;
  readonly errPermille: number; readonly decision: string; readonly reason: string;
}

/** buildId:源码版本 + 配置摘要 + 内容摘要,三者任一不同即视为不同构建。 */
export function buildId(p: PlatformState): string {
  return `${p.sourceRev}|${p.configDigest}|${p.contentDigest}`;
}

/** 多数共识;并列取字典序最小,保证确定性。 */
export function consensusOf(ids: readonly string[]): string {
  const counts = new Map<string, number>();
  for (const x of ids) counts.set(x, (counts.get(x) ?? 0) + 1);
  const entries = [...counts.entries()].sort((a, b) => (b[1] - a[1]) || (a[0] < b[0] ? -1 : 1));
  return entries[0]![0];
}

/** 单平台判定:先看错误率是否触回滚线,再看是否与共识一致。 */
export function verdict(cfg: ConsistencyConfig, p: PlatformState, consensus: string): Verdict {
  const bid = buildId(p);
  const inSync = bid === consensus;
  const matchesGolden = p.sourceRev === cfg.goldenSourceRev;
  let decision = "continue";
  let reason = "in-sync";
  if (p.errPermille >= cfg.errorRollbackPermille) { decision = "rollback"; reason = "err>=threshold"; }
  else if (!inSync) { decision = "resync"; reason = "buildId!=consensus"; }
  return { id: p.id, version: p.version, buildId: bid, inSync, matchesGolden,
    errPermille: p.errPermille, decision, reason };
}

export function evaluateAll(cfg: ConsistencyConfig): { consensus: string; rows: Verdict[] } {
  const consensus = consensusOf(cfg.platforms.map(buildId));
  return { consensus, rows: cfg.platforms.map((p) => verdict(cfg, p, consensus)) };
}

/** 跨语言对拍摘要:与 sim/g03_consistency.py 的 digest 逐字符一致。 */
export function digest(consensus: string, rows: readonly Verdict[]): string {
  let inSync = 0; let rollback = 0; let resync = 0; let cont = 0; let golden = 0;
  for (const r of rows) {
    if (r.inSync) inSync += 1;
    if (r.matchesGolden) golden += 1;
    if (r.decision === "rollback") rollback += 1;
    else if (r.decision === "resync") resync += 1;
    else cont += 1;
  }
  return `consensus=${consensus},inSync=${inSync},outSync=${rows.length - inSync}`
    + `,rollback=${rollback},resync=${resync},continue=${cont},goldenMatch=${golden}`;
}

配套测试 core/test/g03_consistency.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 复算逐字符相等,并验共识取多数、阈值优先、重推判定。在本机跑 node --test 'core/test/g03_*.test.ts',本节 6 个用例全过;两套独立实现给出同一个摘要:

digest = consensus=r20261013|cfg-a1|ct-a1,inSync=3,outSync=2,rollback=1,resync=2,continue=2,goldenMatch=4

只要有一边的共识取法、判定顺序或计数口径写错,摘要立刻分叉。这套机制和卷二「一份逻辑、两种宿主」 同源:同一个 buildId,在 Python 仿真、TS 内核、浏览器原型里必须长得一模一样——否则「多平台一致」就无从谈起。

回滚不是「一键回退」

新手以为回滚就是「把上一个版本推回去」。多平台场景里,回滚是三种动作的编排:

判定动作理由
rollback回退到该平台上一个已知good构建错误率触线,先止血
resync重推一个与共识一致的新包离群但没崩,向多数派对齐
continue不动与共识一致且健康

难点在**「已知good构建」从哪来**——这正是第 3.2 节那份产物清单哈希的用途:每次成功发布都把「buildId → 产物清单哈希 → 版本」记下来,回滚时按 buildId 找到那个哈希对应的产物重新部署,而不是凭记忆点「上一版」。没有产物身份档案,回滚就是赌博。

X 层:真实错误率与灰度状态不可复现

必须划清边界。上面算的是模型侧:给定各平台的错误率与构建指纹,该做什么动作。下面这些属于 X 层,需真实业务环境,命令行不可复现:

  • 真实的平台错误率(来自各家后台或自建埋点,口径、采样、延迟都不同,不可直接横向比);
  • 真实的灰度状态(某个平台到底放量到几成、审核到哪一步,只有后台知道);
  • 真实的「上一个已知good构建」(要靠发布记录积累,不是算出来的)。

工程上正确的做法是:把可复现的判定逻辑与不可复现的平台事实分开——判定逻辑进单测(本节),平台事实由后台 / 埋点 / 发布记录喂进来。判定逻辑可以 100% 单测覆盖,但它只能告诉你「按现有事实该做什么」,事实本身对不对,永远要靠真实环境去证伪。

自检表

检查项合格标准
指纹而非版本号一致性由 buildId 判定,不看人写的版本字符串
共识确定多数 buildId 唯一确定,并列有确定性 tie-break
判定有序错误率优先于一致性(健康比「版本对」更重要)
动作区分回滚 / 重推 / 继续三种动作分明,不混为一谈
档案可查有「buildId → 产物哈希」档案,回滚不靠记忆
跨语言一致Python 与 TS 两套实现的摘要逐字符相等
边界清晰真实错误率与灰度状态标 X,不写成「实测」

多平台版本一致性最危险的错觉,是**「版本号一样 = 内容一样」。真正的一致要靠从内容算出来的指纹去证明,而不是靠一个字符串去声称。到这里,「一套代码多平台」这条主张链就闭合了:3.1 把差异收进配置,3.2 把配置展开成产物矩阵,3.3 用产物指纹守住多平台的一致性底线。下一章我们转到海外变现**——同一套产物到了不同平台,钱从哪来、怎么收。

阅读导航:上一节:3.2 构建矩阵与产物管理 · 下一节:4.1 Web 广告形态与 H5 广告网络 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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