卷三讲完了变现(1–4 章)、买量(5 章)、增长(6 章)、留存(7 章)、实验(8 章),第 9 章回到一个更基础的问题:一款游戏到底怎么「上线」。先说分工。本站有一篇小程序小游戏开发综述 ,那是 96 行的单篇综述,含发布与审核口径,讲的是「做小游戏要考虑哪些事」;本节给的是可跑的发布流水线模型——把「冻结到上线」拆成可配置的阶段加一层可变轮次的平台审核,用仿真算出时间分布与卡点。那篇告诉你「发布涉及哪些环节」,本节告诉你「这些环节串起来要多久、卡在哪、被驳回的代价有多大」。
这一节只讲提审与发布这条链路本身:阶段怎么拆、时间怎么估、门禁怎么卡。合规资质与内容红线是第 10 章的主题,本节只在清单里留一个勾选位,不展开。
发布不是「点一下按钮」
在开发者的想象里,「发布」是编辑器里点一下「提交」按钮;在真实的流水线里,它是一串有序阶段,每一段都可能卡住。把《合成小岛》的一次发布拆开,大致是:代码冻结与打标(拉一个发布分支、定版本号)、构建与自测(打包、跑单测与端到端回放)、提审材料准备(截图、描述、权限说明、测试账号)、提交提审、平台审核、审核通过后发布。前四段是自己可控的,耗时基本由团队产能决定;只有平台审核这一段不完全可控——它可能一次通过,也可能被打回,要求你改完再提。
这条链路里最反直觉的一点是:审核不是一次性的门,而是可能重复的循环。一次驳回意味着「整改 + 重新排队」,而重新排队又要再等一个审核周期。所以「什么时候能上线」不是一个确定值,而是一个分布——这正是本节要用仿真去量化的东西。
关于平台审核的具体规则(审核时长、驳回理由、加急通道),它们属于公开可观察机制:开发者能在平台后台看到审核状态流转、能在社区看到他人的审核经验,但这些数字因平台、因品类、因时段而异,命令行不可复现(X)。本节把审核时长与驳回率当作假设参数,全部写进配置,读者要换成自己平台的经验值时改配置即可,不必改脚本。
D 层:把发布阶段写进配置
下面这份是 D 层(静态层)配置,把可控阶段的耗时、审核的可变参数、驳回率都写成整数。全整数、禁浮点。
{
"version": 1,
"game": "merge-island",
"seed": 20261012,
"note": "上线发布流水线模型:从代码冻结到版本对全量用户可见。阶段耗时与审核驳回率全为整数(小时 / 千分比)。平台审核时长与驳回率是公开可观察机制的估计值,真实值需真实业务环境,命令行不可复现(X)。",
"runs": 2000,
"stages": [
{ "id": "freeze", "name": "代码冻结与打标", "hours": 2 },
{ "id": "build", "name": "构建与自测", "hours": 3 },
{ "id": "material", "name": "提审材料准备", "hours": 6 },
{ "id": "submit", "name": "提交提审", "hours": 1 }
],
"review": {
"name": "平台审核",
"firstPassHours": 24,
"reworkHours": 8,
"resubmitHours": 24,
"rejectRatePermille": 300,
"maxRounds": 4
},
"release": { "name": "审核通过后发布", "hours": 2 }
}
stages 是四个可控阶段,hours 是各阶段预计耗时(小时)。review 描述平台审核:firstPassHours 是首轮审核时长,resubmitHours 是重新提审的排队时长,reworkHours 是被驳回后整改的时间,rejectRatePermille 是每一轮被驳回的千分比,maxRounds 是重审上限。release 是审核通过后到版本对全量用户可见的发布耗时。这些数字是估计值,真实值需真实业务环境,命令行不可复现(X)。
S 层:发布流水线仿真
下面这段是 S 层仿真脚本,读上面这份配置,跑两千次发布,统计「冻结到上线」的小时数分布与驳回轮次。审核每一轮是否驳回由可播种 LCG 决定,保证同一 seed 下结果可复现。
#!/usr/bin/env python3
# file: sim/l09_release_pipeline.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/l09_release_pipeline.py --config config/ --out out/
"""上线发布流水线仿真:读 config/l09_release.json。
把「从代码冻结到版本对全量用户可见」拆成若干阶段(冻结 / 构建 / 材料 / 提交),
再叠一层「平台审核」——审核可能一次通过,也可能被驳回要求整改后重新提审。
每个阶段耗时是整数小时;审核每一轮是否驳回由可播种 LCG 按 rejectRatePermille 决定。
跑 N 次得到「到上线小时数」的分布(p50 / p90 / 均值 / 最大值)与瓶颈阶段。
平台审核时长与驳回率是公开可观察机制的估计值,真实值需真实业务环境,命令行不可复现(X)。
产出 JSON + CSV + SVG。全整数,无浮点。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET
def load(d: str) -> dict:
with open(os.path.join(d, "l09_release.json"), encoding="utf-8") as f:
return json.load(f)
def make_rng(seed: int):
"""可播种 LCG(Numerical Recipes 参数),返回 [0, 2^31) 整数。"""
state = seed & 0x7FFFFFFF
def nxt() -> int:
nonlocal state
state = (1103515245 * state + 12345) & 0x7FFFFFFF
return state
return nxt
def one_run(rng, fixed_hours: int, release_hours: int, review: dict) -> dict:
"""跑一次发布:返回 {hours, reviewHours, attempts, passed}。"""
review_h = 0
attempts = 0
passed = False
while attempts < review["maxRounds"]:
review_h += review["firstPassHours"] if attempts == 0 else review["resubmitHours"]
attempts += 1
if rng() % 1000 >= review["rejectRatePermille"]:
passed = True
break
review_h += review["reworkHours"]
return {"hours": fixed_hours + review_h + (release_hours if passed else 0),
"reviewHours": review_h, "attempts": attempts, "passed": passed}
def percentile(sorted_vals: list, p: int) -> int:
if not sorted_vals:
return 0
idx = (p * (len(sorted_vals) - 1)) // 100
return sorted_vals[idx]
def draw_svg(path: str, totals: list, p50: int, p90: int) -> int:
W, H, PAD = 720, 320, 48
bins = 10
lo, hi = min(totals), max(totals)
span = hi - lo + 1
counts = [0] * bins
for v in totals:
counts[min(bins - 1, (v - lo) * bins // span)] += 1
peak = max(counts) or 1
plot_w = W - PAD * 2
plot_h = H - 90
bw = plot_w // bins
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 = \
"冻结到上线小时数分布(柱高 = 次数)"
for i, c in enumerate(counts):
h = plot_h * c // peak
x = PAD + i * bw
y = H - 40 - h
ET.SubElement(svg, "rect", {"x": str(x + 2), "y": str(y), "width": str(bw - 4),
"height": str(h), "fill": "#2563eb"})
ET.SubElement(svg, "text", {"x": str(x + bw // 2 - 10), "y": str(H - 24), "font-size": "11",
"fill": "#6b7280"}).text = str(lo + i * span // bins)
ET.SubElement(svg, "text", {"x": str(PAD), "y": str(H - 8), "font-size": "12",
"fill": "#374151"}).text = f"p50={p50}h p90={p90}h"
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"])
fixed_hours = sum(s["hours"] for s in cfg["stages"])
release_hours = cfg["release"]["hours"]
review = cfg["review"]
runs = [one_run(rng, fixed_hours, release_hours, review) for _ in range(cfg["runs"])]
totals = sorted(r["hours"] for r in runs)
p50 = percentile(totals, 50)
p90 = percentile(totals, 90)
mean = sum(totals) // len(totals)
blocked = sum(1 for r in runs if not r["passed"])
avg_attempts = sum(r["attempts"] for r in runs) / len(runs)
# 瓶颈阶段:固定阶段里耗时最长者(审核单独列出)
bottleneck = max(cfg["stages"], key=lambda s: s["hours"])
os.makedirs(a.out, exist_ok=True)
svg = draw_svg(os.path.join(a.out, "l09_release_pipeline.svg"), totals, p50, p90)
with open(os.path.join(a.out, "l09_release_pipeline.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["stageId", "name", "hours", "kind"])
for s in cfg["stages"]:
w.writerow([s["id"], s["name"], s["hours"], "fixed"])
w.writerow([review["name"], review["name"], review["firstPassHours"], "review-first"])
w.writerow([review["name"], review["name"], review["resubmitHours"], "review-resubmit"])
w.writerow([review["name"], review["name"], review["reworkHours"], "review-rework"])
w.writerow([cfg["release"]["id"] if "id" in cfg["release"] else "release",
cfg["release"]["name"], release_hours, "release"])
rep = {"version": cfg["version"], "game": cfg["game"], "seed": cfg["seed"], "runs": cfg["runs"],
"fixedHours": fixed_hours, "releaseHours": release_hours,
"bottleneckStage": bottleneck["name"], "bottleneckHours": bottleneck["hours"],
"hours": {"p50": p50, "p90": p90, "mean": mean, "min": totals[0], "max": totals[-1]},
"review": {"rejectRatePermille": review["rejectRatePermille"], "maxRounds": review["maxRounds"],
"blockedRuns": blocked, "avgAttemptsX1000": int(avg_attempts * 1000)},
"csv": os.path.join(a.out, "l09_release_pipeline.csv"),
"svg": os.path.join(a.out, "l09_release_pipeline.svg"), "svgBytes": svg}
with open(os.path.join(a.out, "l09_release_pipeline.json"), "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(json.dumps({"fixedHours": fixed_hours, "p50": p50, "p90": p90, "mean": mean,
"max": totals[-1], "blockedRuns": blocked, "avgAttemptsX1000": rep["review"]["avgAttemptsX1000"],
"bottleneck": bottleneck["name"]}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
脚本的核心是 one_run:先把四个可控阶段的耗时加起来,再叠审核循环——每一轮先等一个审核周期,然后按 rejectRatePermille 决定是否被驳回;被驳回就加上整改时间,进入下一轮。在本机跑它,输出如下(同机相对量级,绝对数值不可移植):
{"fixedHours": 12, "p50": 38, "p90": 70, "mean": 51, "max": 140, "blockedRuns": 15, "avgAttemptsX1000": 1416, "bottleneck": "提审材料准备"}
四个可控阶段合计 12 小时(瓶颈是「提审材料准备」的 6 小时),审核首轮 24 小时。两千次发布的中位数是 38 小时、p90 是 70 小时、均值 51 小时、最长 140 小时;两千次里有 15 次连跑四轮都被驳回(视为卡死重来),平均提审轮次 1.416 轮。注意 p90 是中位数的近两倍——这个差距几乎全部来自「被驳回重审」这条循环路径,而不是可控阶段本身。
P 层:发布门禁状态机
上面是「要多久」的估计,还差「现在能不能提审」的判定。下面这段是 P 层(内核层)TypeScript,把有序阶段与门禁检查做成一个纯函数状态机:给定每个阶段挂的检查通过与否,返回流水线卡在哪一段、前一段是什么、以及能否提审。
// file: core/src/l09/release_gate.ts
// tier: P
// verify: node --test 'core/test/l09_*.test.ts'
//
// 发布门禁状态机:把「代码冻结 → 构建 → 材料 → 提审」串成有序阶段,每个阶段挂若干门禁检查;
// 某阶段有任一检查未过,流水线就卡在该阶段,返回卡点与其前一阶段。
// 纯函数、无 Math.random()、无浮点;只用可擦除语法(禁 enum / namespace / 参数属性)。
export type StageId = "freeze" | "build" | "material" | "submit" | "review" | "release";
export interface Stage {
id: StageId;
name: string;
}
export interface GateCheck {
id: string;
name: string;
stage: StageId;
passed: boolean;
}
export interface StageReadiness {
stage: StageId;
name: string;
ready: boolean;
blocking: string[]; // 未通过检查的 id 列表
}
export interface PipelineReadiness {
ready: boolean;
readyStage: StageId | "none"; // 最后一个连续就绪的阶段
blockedAt: StageId | null; // 卡点
stages: StageReadiness[];
}
/** 取某阶段挂的全部门禁检查。 */
export function stageChecks(checks: GateCheck[], stage: StageId): GateCheck[] {
return checks.filter((c) => c.stage === stage);
}
/** 判定单阶段是否就绪;blocking 是未通过检查的 id 列表。 */
export function evaluateStage(stages: Stage[], checks: GateCheck[], stage: StageId): StageReadiness {
const s = stages.find((x) => x.id === stage);
const blocking = stageChecks(checks, stage).filter((c) => !c.passed).map((c) => c.id);
return { stage, name: s ? s.name : stage, ready: blocking.length === 0, blocking };
}
/** 从前往后走:遇到第一个不就绪的阶段即卡住,返回卡点与其前一阶段。 */
export function pipelineReadiness(stages: Stage[], checks: GateCheck[]): PipelineReadiness {
const results = stages.map((s) => evaluateStage(stages, checks, s.id));
const idx = results.findIndex((r) => !r.ready);
const blockedAt: StageId | null = idx < 0 ? null : results[idx].stage;
const readyStage: StageId | "none" =
idx < 0 ? results[results.length - 1].stage : idx === 0 ? "none" : results[idx - 1].stage;
return { ready: blockedAt === null, readyStage, blockedAt, stages: results };
}
/** 提审前置:审核与发布之前的所有阶段必须全就绪。 */
export function canSubmit(stages: Stage[], checks: GateCheck[]): boolean {
const pre = stages.filter((s) => s.id !== "review" && s.id !== "release");
return pre.every((s) => evaluateStage(stages, checks, s.id).ready);
}
pipelineReadiness 从前往后扫,遇到第一个有检查未过的阶段就停住,返回 blockedAt(卡点)与 readyStage(卡点前最后一个就绪阶段)。canSubmit 更简单:只要「审核与发布之前」的阶段全就绪,就允许提审——它回答的是「材料齐了没、能不能点提交」,而不是「审核会不会过」。本机跑它的测试(node --test 'core/test/l09_*.test.ts'),5 个断言全过:
✔ 全部通过:流水线就绪,readyStage 为最后一个阶段
✔ 材料阶段门禁未过:卡在 material,readyStage 为 build
✔ 第一阶段未过:readyStage 为 none
✔ evaluateStage:blocking 精确列出未过检查
✔ canSubmit:构建未过即不可提审;全过时可提审
ℹ pass 5 fail 0
门禁状态机的价值在于把「发布卡住」从口头协调变成一次函数调用:CI 里跑一遍 canSubmit,不过就不允许打提审标签;过了才把「提审」这一步交给人工点按钮。
两个真正决定上线的数字
回到仿真结果,发布排期真正要盯的是两个数字。第一是 p90 而不是均值:均值 51 小时看着还行,但 p90 是 70 小时——也就是说,每十次发布里大约有一次要等到接近三天。排期要按 p90 留缓冲,而不是按均值,否则总有几次上线会踩到「差一点就来不及」。第二是驳回轮次的期望值:rejectRatePermille 每提高一档,p90 会被拉长多少,可以直接把配置里的值改掉重跑,看分布怎么动——这正是把审核参数写进配置而不是硬编码的意义。
瓶颈阶段也值得单独看。本例里可控阶段的瓶颈是「提审材料准备」(6 小时),比构建与自测(3 小时)还长。这件事在很多团队里被低估:材料准备往往不是「顺手截几张图」,而是一个能吃掉半天到一天的工作量,把它排进发布日的时间线,比临时抱佛脚更靠谱。
小结与下一步
这一节把「发布」从一个动作拆成了一条流水线:四个可控阶段加一层可变轮次的平台审核,用可播种仿真算出时间分布与瓶颈,再用一个门禁状态机内核判定「现在能不能提审」。两个要记住的数字是 p90(排期按它留缓冲)和驳回轮次期望(它才是长尾的来源)。
但审核通过、版本发布,并不等于「对全量用户生效」。真正把风险摊开的做法是灰度放量:先给一小部分用户,观察指标,没问题再逐档放大,出问题就回滚。下一节就把这条放量曲线与回滚阈值做成可跑的仿真与控制器。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。