《移动游戏产品的架构·客户端与移动运行时》5.2 校验、引用完整性与灰度

能通过 JSON.parse 的配置照样能让活动打不开。本节把类型、范围、枚举、重复、引用与孤儿行变成带错误码的校验报告,跨表检查引用完整性,再用客户端构建号与配置版本矩阵决定谁能读哪一版;最后给出以整数基点计算的灰度判据,明确 hold、promote 与 rollback 的边界。

事故:一份合法 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,000v2deprecated允许,但记录退役倒计时
10,240v2deprecated允许,进入灰度观察
10,500v3supported正常发布
10,700v3supportedv2 已退役,不再回退

矩阵的意义是把「兼容」从口头承诺变成发布门禁。只要还有超过发布门槛的构建落在 unsupported,新配置就不能 promote;如果某个构建落在 deprecated,发布系统要给出退役日期,而不是让它无限期留着。

灰度判据:看配置相关指标,不只看大盘

配置灰度的样本必须来自「真正加载了新配置的客户端」,而不是全量大盘。否则配置错误会被其他版本的正常数据稀释,指标看起来一切正常。

比率统一用整数基点(万分之一)计算,避免浮点在不同平台上的舍入差异。方舟的基线口径是崩溃率 0.12%、ANR 率 0.03%,即 12bp 和 3bp;灰度判据只允许在基线之上留很小增量。

判据阈值不满足时
样本量少于 5,000 台hold,继续观察,不扩大
配置加载失败高于 100bprollback
崩溃率增量高于基线 +5bprollback
ANR 增量高于基线 +2bprollback
配置加载 P95高于 1,200msrollback

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 是模型输入,线上指标口径需要与埋点字典对齐。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查