事故:一份合法 JSON,让活动入口变成空白页
案例设定里,方舟的活动表出过一次重复 id:运营复制上一期活动改内容,忘了改 id。客户端加载时后一行覆盖前一行,玩家点开活动只看到空白页;日志里没有异常,因为覆盖本身是合法赋值。另一回是活动引用了已下架的卡牌,战斗能进去,结算时拿不到展示资源,最后变成一张灰卡。
这两个问题都不是「解析失败」,而是「解析成功但语义不成立」。配置校验要拦的是后者。判断标准不是 JSON.parse 有没有抛错,而是这份数据能不能被玩法层和表现层安全消费。
校验码:每一种错误都要有名字
校验报告不能只返回一个布尔值。配置平台要把错误定位到表、行、字段和原因,策划才知道改哪里,客户端才能决定是阻断加载还是降级。
| 错误码 | 例子 | 失败后果 |
|---|---|---|
missing-required | 卡牌缺攻击力 | 战斗读取 undefined,伤害计算变成 NaN |
wrong-type | 攻击力填成 "120" | 比较和累加出现字符串拼接 |
out-of-range | 章节号填 0 或 99 | 关卡列表越界,入口不可见 |
unknown-enum | 稀有度填 mythic | 老客户端没有对应分支,展示为空 |
duplicate-id | 两行活动使用同一 id | 后一行覆盖前一行,数据静默丢失 |
unknown-ref | 活动引用已删除卡牌 | 结算时资源缺失,或运行时抛错 |
orphan-row | 关卡存在但没有入口 | 内容永远不可达,白做 |
invalid-window | 活动结束时间早于开始时间 | 活动一上线就结束 |
错误码还有一个作用:让灰度判据可计算。发布系统按错误码聚合失败原因,才能区分「表结构不兼容」和「个别行内容有误」,而不是笼统地报一个加载失败率。
引用完整性必须跨表做
引用检查不能边读边判。关卡表可能先于卡牌表到达,此时卡牌 id 集合还不完整;正确做法是两遍:第一遍收集所有表的 id 集合,第二遍再逐行检查 ref 和 ref-list。这样校验结果与表的读取顺序无关,也方便在配置平台增量校验时复用 id 索引。
孤儿行是另一种完整性:行本身合法,引用也存在,但没有入口能到达它。关卡用 entryLevelIds 作为根,沿 nextLevelId 走;卡牌从可达关卡的敌方列表、首领卡和活动主推卡出发,再沿进化目标扩散。走不到的关卡和卡牌就是孤儿。它们不会让客户端崩溃,却会让「做了但玩家看不到」的内容悄悄堆积。
校验结果还要稳定排序。同一份配置在打包机、配置平台和本地跑出不同顺序的报告,评审就无法比对两次发布的差异。
客户端版本 × 配置版本矩阵
5.1 的注册表回答「某个构建能读哪一版 schema」,5.2 要把它变成发布前的矩阵。矩阵的每一行是一个客户端构建,每一列是该构建解析出的 schema 版本与状态。
| 客户端构建 | 解析到的 schema | 状态 | 处理 |
|---|---|---|---|
| 9,900 | 无 | unsupported | 无可用 schema,先推动升级或服务端兜底 |
| 10,000 | v2 | deprecated | 允许,但记录退役倒计时 |
| 10,240 | v2 | deprecated | 允许,进入灰度观察 |
| 10,500 | v3 | supported | 正常发布 |
| 10,700 | v3 | supported | v2 已退役,不再回退 |
矩阵的意义是把「兼容」从口头承诺变成发布门禁。只要还有超过发布门槛的构建落在 unsupported,新配置就不能 promote;如果某个构建落在 deprecated,发布系统要给出退役日期,而不是让它无限期留着。
灰度判据:看配置相关指标,不只看大盘
配置灰度的样本必须来自「真正加载了新配置的客户端」,而不是全量大盘。否则配置错误会被其他版本的正常数据稀释,指标看起来一切正常。
比率统一用整数基点(万分之一)计算,避免浮点在不同平台上的舍入差异。方舟的基线口径是崩溃率 0.12%、ANR 率 0.03%,即 12bp 和 3bp;灰度判据只允许在基线之上留很小增量。
| 判据 | 阈值 | 不满足时 |
|---|---|---|
| 样本量 | 少于 5,000 台 | hold,继续观察,不扩大 |
| 配置加载失败 | 高于 100bp | rollback |
| 崩溃率增量 | 高于基线 +5bp | rollback |
| ANR 增量 | 高于基线 +2bp | rollback |
| 配置加载 P95 | 高于 1,200ms | rollback |
hold 和 rollback 必须分开:样本不足时回滚会误伤一个可能正常的版本,样本足够但指标越界时继续放量则是拿玩家做实验。回滚动作本身也要记录版本号和原因,下一篇会把这条写进发布流水线。
可运行模型:table.ts
下面的 table.ts 在 schema 之上实现校验、引用完整性、孤儿检测、版本矩阵和灰度判据。所有计数都是整数,比率用基点表示;测试覆盖了每个错误码、矩阵边界和三种灰度决策。这里是模型,不是线上监控结论。
// file: src/config/table.ts
// tier: L1
// verify: node --test test/config/table.test.ts
import { fail, ok, type Result } from "../core/result.ts";
import {
acceptsClient,
type ConfigTableName,
type SchemaRegistry,
type SchemaSet,
type TableSchema,
} from "./schema.ts";
export const CONFIG_TABLES: readonly ConfigTableName[] = ["level", "card", "activity"];
export interface ConfigRow { readonly [key: string]: unknown; }
export interface ConfigSet {
readonly schemaVersion: number;
readonly dataVersion: number;
readonly tables: Readonly<Record<ConfigTableName, readonly ConfigRow[]>>;
readonly entryLevelIds: readonly string[];
}
export type IssueCode =
| "missing-required"
| "wrong-type"
| "out-of-range"
| "unknown-enum"
| "duplicate-id"
| "unknown-ref"
| "orphan-row"
| "invalid-window";
export interface Issue {
readonly code: IssueCode;
readonly table: ConfigTableName;
readonly rowId: string;
readonly field: string;
readonly message: string;
}
export interface ValidationReport { readonly ok: boolean; readonly issues: readonly Issue[]; readonly errorCount: number; }
function idOf(row: ConfigRow): string {
return typeof row.id === "string" && row.id.length > 0 ? row.id : "<missing-id>";
}
function isInt(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value);
}
function issue(code: IssueCode, table: ConfigTableName, rowId: string, field: string, message: string): Issue {
return { code, table, rowId, field, message };
}
function checkField(table: ConfigTableName, rowId: string, field: string, spec: TableSchema["fields"][string], value: unknown, issues: Issue[]): void {
if (value === undefined || value === null) {
if (spec.required) issues.push(issue("missing-required", table, rowId, field, `${field} is required`));
return;
}
switch (spec.kind) {
case "string":
if (typeof value !== "string" || value.length === 0) issues.push(issue("wrong-type", table, rowId, field, `${field} must be a non-empty string`));
break;
case "int":
if (!isInt(value)) {
issues.push(issue("wrong-type", table, rowId, field, `${field} must be an integer`));
break;
}
if (spec.min !== undefined && value < spec.min) issues.push(issue("out-of-range", table, rowId, field, `${field}=${value} below ${spec.min}`));
if (spec.max !== undefined && value > spec.max) issues.push(issue("out-of-range", table, rowId, field, `${field}=${value} above ${spec.max}`));
break;
case "bool":
if (typeof value !== "boolean") issues.push(issue("wrong-type", table, rowId, field, `${field} must be boolean`));
break;
case "enum":
if (typeof value !== "string" || spec.values === undefined || !spec.values.includes(value)) {
issues.push(issue("unknown-enum", table, rowId, field, `${field}=${String(value)} is not allowed`));
}
break;
case "ref":
if (typeof value !== "string" || value.length === 0) issues.push(issue("wrong-type", table, rowId, field, `${field} must be a row id`));
break;
case "ref-list":
if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
issues.push(issue("wrong-type", table, rowId, field, `${field} must be a string array`));
break;
}
if (spec.min !== undefined && value.length < spec.min) issues.push(issue("out-of-range", table, rowId, field, `${field} has fewer than ${spec.min} items`));
if (spec.max !== undefined && value.length > spec.max) issues.push(issue("out-of-range", table, rowId, field, `${field} has more than ${spec.max} items`));
break;
}
}
function idSets(tables: ConfigSet["tables"]): Map<ConfigTableName, Set<string>> {
const sets = new Map<ConfigTableName, Set<string>>();
for (const table of CONFIG_TABLES) {
const ids = new Set<string>();
for (const row of tables[table]) if (typeof row.id === "string") ids.add(row.id);
sets.set(table, ids);
}
return sets;
}
function checkRefs(table: ConfigTableName, row: ConfigRow, schema: TableSchema, ids: Map<ConfigTableName, Set<string>>, issues: Issue[]): void {
const rowId = idOf(row);
for (const [field, spec] of Object.entries(schema.fields)) {
if (spec.table === undefined) continue;
const targets = spec.kind === "ref-list" && Array.isArray(row[field]) ? (row[field] as unknown[]) : [row[field]];
for (const target of targets) {
if (typeof target === "string" && !ids.get(spec.table)?.has(target)) {
issues.push(issue("unknown-ref", table, rowId, field, `${field} points to missing ${spec.table} ${target}`));
}
}
}
}
function checkOrphans(set: ConfigSet, issues: Issue[]): void {
const levels = new Map<string, ConfigRow>();
for (const row of set.tables.level) if (typeof row.id === "string") levels.set(row.id, row);
const visited = new Set<string>();
const queue: string[] = [];
for (const id of set.entryLevelIds) {
if (levels.has(id)) queue.push(id);
else issues.push(issue("unknown-ref", "level", "entryLevelIds", "entryLevelIds", `entry level ${id} does not exist`));
}
while (queue.length > 0) {
const id = queue.shift() as string;
if (visited.has(id)) continue;
visited.add(id);
const next = levels.get(id)?.nextLevelId;
if (typeof next === "string") queue.push(next);
}
for (const id of levels.keys()) {
if (!visited.has(id)) issues.push(issue("orphan-row", "level", id, "nextLevelId", `level ${id} is unreachable from entry levels`));
}
const cards = new Set<string>();
for (const row of set.tables.card) if (typeof row.id === "string") cards.add(row.id);
const reached = new Set<string>();
const pending: string[] = [];
for (const id of visited) {
const row = levels.get(id);
const enemies = row?.enemyCardIds;
if (Array.isArray(enemies)) for (const enemy of enemies) if (typeof enemy === "string") pending.push(enemy);
if (typeof row?.bossCardId === "string") pending.push(row.bossCardId);
}
for (const row of set.tables.activity) if (typeof row.featuredCardId === "string") pending.push(row.featuredCardId);
while (pending.length > 0) {
const id = pending.shift() as string;
if (reached.has(id) || !cards.has(id)) continue;
reached.add(id);
const next = set.tables.card.find((row) => row.id === id)?.evolvesTo;
if (typeof next === "string") pending.push(next);
}
for (const id of cards) {
if (!reached.has(id)) issues.push(issue("orphan-row", "card", id, "id", `card ${id} is not referenced by any reachable level or activity`));
}
}
export function validateSet(set: ConfigSet, schemaSet: SchemaSet): Result<ValidationReport, string> {
if (set.schemaVersion !== schemaSet.version) {
return fail("schema-version-mismatch", `bundle declares v${set.schemaVersion} but schema set is v${schemaSet.version}`);
}
const issues: Issue[] = [];
const ids = idSets(set.tables);
for (const table of CONFIG_TABLES) {
const seen = new Map<string, number>();
for (const row of set.tables[table]) {
const rowId = idOf(row);
if (typeof row.id === "string") {
const count = (seen.get(row.id) ?? 0) + 1;
seen.set(row.id, count);
if (count === 2) issues.push(issue("duplicate-id", table, rowId, "id", `${table} id ${rowId} appears more than once`));
}
for (const [field, spec] of Object.entries(schemaSet.tables[table].fields)) {
checkField(table, rowId, field, spec, row[field], issues);
}
checkRefs(table, row, schemaSet.tables[table], ids, issues);
}
}
for (const row of set.tables.activity) {
if (isInt(row.startAtMs) && isInt(row.endAtMs) && row.endAtMs <= row.startAtMs) {
issues.push(issue("invalid-window", "activity", idOf(row), "endAtMs", "activity must end after it starts"));
}
}
checkOrphans(set, issues);
issues.sort((a, b) => `${a.table}/${a.rowId}/${a.field}/${a.code}`.localeCompare(`${b.table}/${b.rowId}/${b.field}/${b.code}`));
return ok({ ok: issues.length === 0, issues, errorCount: issues.length });
}
export interface MatrixRow { readonly clientBuild: number; readonly schemaVersion: number | null; readonly state: string; readonly accepted: boolean; }
export function compatibilityMatrix(registry: SchemaRegistry, clientBuilds: readonly number[]): readonly MatrixRow[] {
return clientBuilds.map((clientBuild) => {
const resolved = registry.resolve(clientBuild);
if (!resolved.ok) return { clientBuild, schemaVersion: null, state: "unsupported", accepted: false };
const verdict = acceptsClient(resolved.value, clientBuild);
return { clientBuild, schemaVersion: resolved.value.version, state: verdict.state, accepted: verdict.accepted };
});
}
export interface CanaryBaseline { readonly crashBps: number; readonly anrBps: number; }
export interface CanaryPolicy { readonly minClients: number; readonly maxConfigFailureBps: number; readonly maxCrashDeltaBps: number; readonly maxAnrDeltaBps: number; readonly maxLoadP95Ms: number; }
export interface CanarySample { readonly clients: number; readonly configFailures: number; readonly crashes: number; readonly anr: number; readonly loadP95Ms: number; }
export type CanaryDecision = "hold" | "promote" | "rollback";
export interface CanaryVerdict { readonly decision: CanaryDecision; readonly reasons: readonly string[]; readonly configFailureBps: number; readonly crashBps: number; readonly anrBps: number; }
export const ARK_CANARY_POLICY: CanaryPolicy = {
minClients: 5_000,
maxConfigFailureBps: 100,
maxCrashDeltaBps: 5,
maxAnrDeltaBps: 2,
maxLoadP95Ms: 1_200,
};
export const ARK_CANARY_BASELINE: CanaryBaseline = { crashBps: 12, anrBps: 3 };
export function rateBps(count: number, total: number): number {
if (!Number.isInteger(count) || count < 0 || !Number.isInteger(total) || total < 0) {
throw new RangeError(`rate inputs must be non-negative integers: ${count}/${total}`);
}
return total === 0 ? 0 : Math.floor((count * 10_000) / total);
}
export function decideCanary(sample: CanarySample, baseline: CanaryBaseline, policy: CanaryPolicy = ARK_CANARY_POLICY): CanaryVerdict {
const configFailureBps = rateBps(sample.configFailures, sample.clients);
const crashBps = rateBps(sample.crashes, sample.clients);
const anrBps = rateBps(sample.anr, sample.clients);
const base = { configFailureBps, crashBps, anrBps };
if (sample.clients < policy.minClients) {
return { decision: "hold", reasons: [`sample ${sample.clients} is below minimum ${policy.minClients}`], ...base };
}
const reasons: string[] = [];
if (configFailureBps > policy.maxConfigFailureBps) reasons.push(`config failure ${configFailureBps}bps above ${policy.maxConfigFailureBps}bps`);
if (crashBps - baseline.crashBps > policy.maxCrashDeltaBps) reasons.push(`crash ${crashBps}bps exceeds baseline+${policy.maxCrashDeltaBps}bps`);
if (anrBps - baseline.anrBps > policy.maxAnrDeltaBps) reasons.push(`anr ${anrBps}bps exceeds baseline+${policy.maxAnrDeltaBps}bps`);
if (sample.loadP95Ms > policy.maxLoadP95Ms) reasons.push(`config load p95 ${sample.loadP95Ms}ms above ${policy.maxLoadP95Ms}ms`);
return reasons.length > 0
? { decision: "rollback", reasons, ...base }
: { decision: "promote", reasons: ["all gates passed"], ...base };
}
配套测试 test/config/table.test.ts 覆盖 13 条路径,包括重复 id、悬空引用、孤儿关卡与卡牌、版本矩阵、基点边界和四类回滚原因。运行命令是 node --test test/config/table.test.ts。
双案例:一周活动与多构建共存
消消的配置以周活动为主,表少、生命周期短,灰度样本可以按活动入口快速积累;它的重点是「错误内容不要发出去」,校验失败就拦在发布前。
方舟的配置跨关卡、卡牌、活动多张表,线上同时存在多个构建,版本矩阵和灰度判据都必须更严格;它宁可多 hold 一天,也不让配置错误进入赛季结算。
与相邻章节的关系
5.1 定义了 schema 与兼容窗口,本节把 schema 变成可执行的校验与发布门禁,5.3 会把导入、校验、评审、发布与回滚串成流水线。数据校验的基本思路可以参考数据校验 ,线上问题定位可以参考线上问题定位闭环 。
小结
- 配置校验的目标不是「能解析」,而是「能被玩法层和表现层安全消费」。
- 每个错误都要有稳定的错误码,报告要定位到表、行、字段,并按确定顺序排序。
- 引用完整性必须两遍做:先收集 id 集合,再检查引用;孤儿行用可达性判断。
- 客户端构建号与 schema 版本矩阵是发布门禁,不是展示用的报表。
- 灰度判据用整数基点,样本不足时 hold,指标越界时 rollback,两者不能混为一谈。
阅读导航:上一节:5.1 配置 schema 与版本化 · 下一节:5.3 策划工作流:从表格到线上 。
常见错误与边界
- 只校验 JSON 能否解析。解析成功只说明语法正确,字段类型、引用和业务窗口仍然可能全错。
- 边读表边做引用检查。目标表尚未到达时会把合法引用误判为悬空;必须先用完整 id 集合做第二遍。
- 把孤儿行当成无害数据。孤儿不崩溃,但会让内容不可达、配置体积白涨,也会掩盖入口配置错误。
- 用浮点计算灰度比率。不同平台的舍入会让阈值比较在边界上抖动;统一用整数计数和基点。
- 样本不足就回滚。这会把正常版本误杀,也会训练团队忽略灰度信号;样本不足应先 hold。
- 边界:本节不实现配置平台、CDN 下发和真实监控采集。
CanarySample是模型输入,线上指标口径需要与埋点字典对齐。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。