卷三「灰度放量与回滚」 讲的是单个平台内怎么按比例放量、怎么发现异常、怎么回退上一个版本。本节面对的是多平台独有的新问题:当《合成小岛》同时在五个平台运行时,「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 | 继续 |
| Poki | r20261013|cfg-a1|ct-a1 | 是 | 41 | 回滚 |
| CrazyGames | r20261012|cfg-a0|ct-a1 | 否 | 9 | 重推 |
| GamePix | r20261013|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 广告网络 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。