《移动游戏产品的架构·客户端与移动运行时》12.3 线上问题定位闭环

告警只说明整体指标异常,定位要靠版本、机型、渠道、区域四维切片。本节把指纹归一、统计门槛、切片分母与灰度止损串成闭环,用可运行模型演示小样本不误报、坏切片能排序、灰度能回滚,并给出修复验证的回归判据、无分母事件的单列规则与回到原切片的复核方法。

事故:告警响了,但不知道是谁在崩

凌晨的告警只看一个总数:崩溃率超过基线的 1.5 倍。值班同学打开看板,整体曲线确实抬头,但无法回答三个问题:是新版本还是旧版本、是低端机还是高端机、是哪个渠道包。等人工按维度导出数据,已经过去两个小时。

案例设定里,《潮汐消消》DAU 80 万、Android 占 60%、4GB RAM 以下低端机占 35%,整体指标很容易被大盘稀释。告警负责「有事」,切片负责「是谁」,灰度负责「停不停」,回归负责「是否真的好了」。 四件事缺一不可。

告警先过统计门槛

定位的第一步不是切片,而是把重复上报压成可比较的事件。同一处崩溃会带不同的内存地址、会话标识与订单号,必须先归一成指纹:地址替换为占位符,UUID 折叠,六位以上的高基数数字替换为 #,保留文件名与行号。指纹相同才合并计数。

合并之后才谈告警。只看「比基线高」会天天误报:样本少时一次崩溃就能让比率翻倍。模型要求三个条件同时满足:分母达到最小样本、点估计达到基线的倍数、Wilson 区间下界仍高于基线。

判定条件动作
样本不足分母小于最小样本保持观察,不告警
未达阈值低于基线倍数,或区间下界覆盖基线不告警
回归倍数达标且区间下界高于基线告警并进入切片

案例设定的崩溃率基线是 0.18%,ANR 率是 0.05%。两者分母都是已启动会话,不能混用;Jank 率的分母是渲染帧,切片时必须切换分母单位,否则 60fps 的方舟会被 30fps 的消消口径带偏。

四维切片:版本、机型、渠道、区域

维度常见问题切片价值注意
版本新版本引入回归区分新旧包灰度期版本号可能相同,需带构建号
机型低端机内存或 GPU 兼容找到硬件相关性机型别名要归一,避免同机多名称
渠道渠道包 SDK 冲突定位分发差异渠道包可能延迟上架,曝光量不同
区域网络、合规与语言差异定位本地化问题区域要与版本、渠道交叉看

切片的分子必须落在同一分母切片内。某个版本有崩溃事件但没有曝光会话,这个事件只能记为「无分母事件」,不能当作零崩溃,也不能塞进其他切片。模型把这类事件单独计数,避免切片结果被污染。

四维全交叉会产生大量小切片,每个都做显著性检验必然出现假阳性。做法是先用一维或二维缩小范围,再对候选切片要求最小样本与置信区间;不要在全量切片里挑一个最高的就宣布根因。

从灰度到回归

灰度不是「发 5% 看看」,而是控制组与实验组的对照。两组都要达到最小会话量,比较崩溃率的区间:实验组区间下界高于「基线加容差」才回滚;点估计在容差内则继续放量;点估计超了但区间仍重叠,保持当前比例继续观察。

判定条件动作
继续实验组点估计不超过基线加容差按计划放量
观察点估计超过,但区间下界未超过阈值暂停放量,累积样本
回滚区间下界超过基线加容差停止放量并回退

修复验证用同一套判据,只是把控制组换成修复前基线、实验组换成修复后版本。验证必须回到最初的切片:整体回到基线不代表低端机切片也恢复。回归用例要固化到自动化里,否则下一次改同一模块还会复发。

案例设定里,方舟的节奏与消消不同:方舟每两周一次版本、每周活动,单场 8–12 分钟,强联网战斗让日志与崩溃上下文更重;消消每周二活动更新,单局 90 秒,样本积累快但版本更频繁。两者共用灰度平台,止损阈值应按各自基线设置,不能直接套用对方的倍数。

可运行模型:event.ts

下面的模型把指纹归一、Wilson 区间、告警判定、四维切片与灰度止损放在一个文件里。它只处理已聚合的计数与维度,不采集设备数据;真实链路中的曝光口径、渠道映射与告警通道需要平台侧接入,这里是模型,不是线上系统。

// file: src/obs/event.ts
// tier: L1
// verify: node --test test/obs/event.test.ts

export type ObsKind = "crash" | "anr" | "jank" | "error";

export interface Dimensions {
  readonly version: string;
  readonly model: string;
  readonly channel: string;
  readonly region: string;
}

export type DimensionKey = keyof Dimensions;

export const DIMENSION_KEYS: readonly DimensionKey[] = ["version", "model", "channel", "region"];

export interface ObsEvent {
  readonly tsMs: number;
  readonly kind: ObsKind;
  readonly signature: string;
  readonly dims: Dimensions;
  readonly weight?: number;
}

export interface Exposure {
  readonly dims: Dimensions;
  readonly sessions: number;
  readonly renderedFrames?: number;
}

export function hash32(text: string): number {
  let hash = 0x811c9dc5;
  for (let index = 0; index < text.length; index += 1) {
    hash ^= text.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash >>> 0;
}

export function normalizeForFingerprint(message: string, topFrame: string): string {
  return `${message} @ ${topFrame}`
    .replace(/0x[0-9a-f]+/gi, "0x?")
    .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<uuid>")
    .replace(/\b\d{6,}\b/g, "#")
    .replace(/\s+/g, " ")
    .trim();
}

export function fingerprint(message: string, topFrame: string): string {
  return hash32(normalizeForFingerprint(message, topFrame)).toString(16).padStart(8, "0");
}

export interface Interval {
  readonly low: number;
  readonly high: number;
}

export function wilsonInterval(successes: number, total: number, z = 1.96): Interval {
  if (total <= 0) {
    return { low: 0, high: 1 };
  }
  const p = successes / total;
  const z2 = z * z;
  const denominator = 1 + z2 / total;
  const center = (p + z2 / (2 * total)) / denominator;
  const margin = (z * Math.sqrt((p * (1 - p) + z2 / (4 * total)) / total)) / denominator;
  return { low: Math.max(0, center - margin), high: Math.min(1, center + margin) };
}

export interface AlertConfig {
  readonly baseline: number;
  readonly minSessions: number;
  readonly minLift: number;
  readonly z: number;
}

export const DEFAULT_ALERT_CONFIG: AlertConfig = {
  baseline: 0.0018,
  minSessions: 2000,
  minLift: 1.5,
  z: 1.96,
};

export type AlertReason = "ok" | "insufficient-sample" | "below-threshold" | "regression";

export interface AlertDecision {
  readonly alert: boolean;
  readonly reason: AlertReason;
  readonly rate: number;
  readonly lift: number;
  readonly interval: Interval;
}

export function evaluateAlert(
  count: number,
  denominator: number,
  config: AlertConfig = DEFAULT_ALERT_CONFIG,
): AlertDecision {
  if (!Number.isInteger(count) || !Number.isInteger(denominator)) {
    throw new RangeError("count and denominator must be integers");
  }
  if (count < 0 || denominator < 0 || count > denominator) {
    throw new RangeError("count must be within [0, denominator]");
  }
  const interval = wilsonInterval(count, denominator, config.z);
  const rate = denominator === 0 ? 0 : count / denominator;
  const lift = config.baseline === 0 ? (rate > 0 ? Infinity : 0) : rate / config.baseline;
  if (denominator < config.minSessions) return { alert: false, reason: "insufficient-sample", rate, lift, interval };
  if (rate < config.baseline * config.minLift || interval.low <= config.baseline) return { alert: false, reason: "below-threshold", rate, lift, interval };
  return { alert: true, reason: "regression", rate, lift, interval };
}

export interface SliceStat {
  readonly values: Readonly<Record<string, string>>;
  readonly count: number;
  readonly denominator: number;
  readonly rate: number;
  readonly lift: number;
  readonly decision: AlertDecision;
}

export type DenominatorUnit = "sessions" | "frames";

export interface SliceReport {
  readonly unit: DenominatorUnit;
  readonly slices: readonly SliceStat[];
  readonly orphanEvents: number;
}

export class SliceAnalyzer {
  readonly #exposures: Exposure[] = [];
  readonly #events: ObsEvent[] = [];

  addExposure(exposure: Exposure): void {
    if (!Number.isInteger(exposure.sessions) || exposure.sessions < 0) {
      throw new RangeError("sessions must be a non-negative integer");
    }
    this.#exposures.push(exposure);
  }

  addEvent(event: ObsEvent): void {
    const weight = event.weight ?? 1;
    if (!Number.isInteger(weight) || weight < 0) {
      throw new RangeError("event weight must be a non-negative integer");
    }
    this.#events.push(event);
  }

  analyze(
    keys: readonly DimensionKey[],
    kind: ObsKind,
    config: AlertConfig = DEFAULT_ALERT_CONFIG,
  ): SliceReport {
    if (keys.length === 0) {
      throw new RangeError("slice needs at least one dimension");
    }
    const unit: DenominatorUnit = kind === "jank" ? "frames" : "sessions";
    const buckets = new Map<string, { values: Record<string, string>; sessions: number; frames: number; count: number }>();

    for (const exposure of this.#exposures) {
      const values = pick(exposure.dims, keys);
      const key = keyOf(values, keys);
      const bucket = buckets.get(key) ?? { values, sessions: 0, frames: 0, count: 0 };
      bucket.sessions += exposure.sessions;
      bucket.frames += exposure.renderedFrames ?? 0;
      buckets.set(key, bucket);
    }

    let orphanEvents = 0;
    for (const event of this.#events) {
      if (event.kind !== kind) {
        continue;
      }
      const weight = event.weight ?? 1;
      const values = pick(event.dims, keys);
      const bucket = buckets.get(keyOf(values, keys));
      if (!bucket) {
        orphanEvents += weight;
        continue;
      }
      bucket.count += weight;
    }

    const slices: SliceStat[] = [];
    for (const bucket of buckets.values()) {
      const denominator = unit === "frames" ? bucket.frames : bucket.sessions;
      if (denominator <= 0) {
        orphanEvents += bucket.count;
        continue;
      }
      const decision = evaluateAlert(bucket.count, denominator, config);
      slices.push({
        values: bucket.values,
        count: bucket.count,
        denominator,
        rate: decision.rate,
        lift: decision.lift,
        decision,
      });
    }
    slices.sort((left, right) => right.lift - left.lift || right.count - left.count);
    return { unit, slices, orphanEvents };
  }
}

function pick(dims: Dimensions, keys: readonly DimensionKey[]): Record<string, string> {
  const values: Record<string, string> = {};
  for (const key of keys) {
    values[key] = dims[key];
  }
  return values;
}

function keyOf(values: Record<string, string>, keys: readonly DimensionKey[]): string {
  return keys.map((key) => values[key]).join("\u0001");
}

export interface GrayArm {
  readonly crashes: number;
  readonly sessions: number;
}

export interface GrayConfig {
  readonly baseline: number;
  readonly tolerance: number;
  readonly minSessionsPerArm: number;
  readonly z: number;
}

export const DEFAULT_GRAY_CONFIG: GrayConfig = {
  baseline: 0.0018,
  tolerance: 0.0004,
  minSessionsPerArm: 5000,
  z: 1.96,
};

export type GrayVerdict = "continue" | "hold" | "rollback";

export interface GrayDecision {
  readonly verdict: GrayVerdict;
  readonly reason: string;
  readonly control: { readonly rate: number; readonly interval: Interval };
  readonly treatment: { readonly rate: number; readonly interval: Interval };
}

export function evaluateGray(
  control: GrayArm,
  treatment: GrayArm,
  config: GrayConfig = DEFAULT_GRAY_CONFIG,
): GrayDecision {
  for (const arm of [control, treatment]) {
    if (!Number.isInteger(arm.crashes) || !Number.isInteger(arm.sessions)) {
      throw new RangeError("gray arms need integer counts");
    }
    if (arm.crashes < 0 || arm.sessions < 0 || arm.crashes > arm.sessions) {
      throw new RangeError("gray arm crashes must be within [0, sessions]");
    }
  }
  const controlInterval = wilsonInterval(control.crashes, control.sessions, config.z);
  const treatmentInterval = wilsonInterval(treatment.crashes, treatment.sessions, config.z);
  const controlRate = control.sessions === 0 ? 0 : control.crashes / control.sessions;
  const treatmentRate = treatment.sessions === 0 ? 0 : treatment.crashes / treatment.sessions;
  const arms = {
    control: { rate: controlRate, interval: controlInterval },
    treatment: { rate: treatmentRate, interval: treatmentInterval },
  };
  if (control.sessions < config.minSessionsPerArm || treatment.sessions < config.minSessionsPerArm) return { verdict: "hold", reason: "insufficient-sample", ...arms };
  const threshold = config.baseline + config.tolerance;
  if (treatmentInterval.low > threshold) return { verdict: "rollback", reason: "treatment-ci-above-threshold", ...arms };
  if (treatmentRate <= threshold) return { verdict: "continue", reason: "within-tolerance", ...arms };
  return { verdict: "hold", reason: "point-above-but-ci-overlaps", ...arms };
}

与相邻章节的关系

12.1 定义了崩溃、ANR 与 Jank 的分母,本节直接消费这些口径;12.2 的日志脱敏决定切片维度里能保留哪些字段。机型矩阵的验收可以参考兼容性机型矩阵 ,埋点与事件命名可以参考埋点规范 。

小结

  • 告警先归一指纹,再比较比率;地址、UUID 与高基数数字不参与分组。
  • 样本不足、未达倍数、区间覆盖基线都不应告警;统计门槛比阈值本身更重要。
  • 切片按版本、机型、渠道、区域逐层缩小,分子必须落在同一分母内,无分母事件单独计数。
  • 灰度用控制组与实验组的区间对照,继续、观察、回滚三态比「发不发」更可执行。
  • 修复验证回到最初的切片,并把回归用例固化;整体恢复不代表问题真的关闭。

阅读导航:上一节:12.2 日志分级、采样与隐私脱敏 · 下一节:13.1 模块矩阵与依赖报告 。

常见错误与边界

  • 看到整体上涨就回滚。整体可能被大盘稀释或放大,先切片再决定;否则会误伤正常版本。
  • 不做指纹归一。同一崩溃因地址或订单号不同被拆成上千条,告警噪声掩盖真实回归。
  • 样本不足就宣布根因。几次崩溃在低流量切片里就能翻倍,必须要求最小分母与置信区间。
  • 分子分母不匹配。Jank 用会话作分母、崩溃用帧作分母都会得到无意义的结果。
  • 在全量切片里挑最高值。多维交叉会制造假阳性,应先降维再检验候选切片。
  • 灰度只看点估计。区间仍重叠时贸然回滚会错过真实有效的版本,应保持比例继续观察。
  • 修复后只看整体。最初出问题的机型或渠道切片没有恢复,就不能关闭问题。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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