《互联网产品的客户端架构·内容、社交与实时互动》13.3 质量场景与风险清单:进入卷三

把首屏、弱网、断线、内存、审核与多端一致写成六条质量属性场景,每条给出刺激、响应、度量口径、预算与证据等级,用风险分级函数排出优先级,明确本卷只验证到 L2、哪些结论必须留给卷三的交易一致性与真机基线,并复盘一次把测试全绿当成质量结论的教训。

收口不是总结

13.3 不再新增实现,也不做性能基准。它把卷二讲过的东西压缩成六条质量属性场景:首屏、弱网、断线、内存、审核与多端一致。每条场景都写清刺激、响应、度量口径、预算与证据等级,再用一个纯函数排出风险优先级。

这一节最重要的不是“六条场景”,而是“证据等级”。前面十二章里有 L1 纯函数、L2 进程内流程,也有只能在浏览器、真机或真实端口里回答的问题。把它们混成一句“测试全绿”,是客户端质量复盘里最常见的自欺。

六条质量属性场景

场景刺激期望响应度量口径本卷证据
首屏可见冷启动后从通知进入首页骨架与缓存首屏先出现,正文后补首屏可交互时间、骨架稳定时间无(未跑浏览器/真机)
弱网可用300ms 往返、1% 丢包,连续下拉翻页保留已加载条目,失败只影响当前页分页成功率、单页重试次数L2(13.2 慢网适配器)
断线恢复长连接断开 60 秒,期间 30 条消息退避重连、按高水位补拉、去重剩余空洞、重连耗时、重复应用L2(13.2 补拉)
长会话内存连续滑动 30 分钟窗口淘汰,去重集合同步清理窗口条目数、JS 堆增长L2(1.2 容器淘汰)
审核态发布内容命中审核后被下架占位显示,不提前渲染正文正文泄露次数、占位抖动无(只有状态模型)
多端一致两端同时收到同一批通知以服务端 ackSeq 为准,旧快照不回退角标差值、旧快照回退次数无(单端模型)

六条场景里只有三条有本卷的进程内证据。首屏、审核态与多端一致没有,因为它们的验证对象分别是浏览器渲染、审核链路与多端协议,不在 social-lab 的 L2 范围内。

风险分级:影响优先,概率其次

风险分用“可能性 × 影响”,各取 1 到 5。15 分及以上为高,8 到 14 为中,7 分及以下为低。阈值不是行业标准,而是一个团队可以写进评审表的约定。

按这个口径,首屏 20 分最高,弱网 16 分,断线与审核各 15 分,内存 9 分,多端一致 8 分。首屏排第一不是因为概率最高,而是因为一旦慢,用户在第一屏就流失;审核态排在高位,是因为正文泄露的后果不可逆。

风险分级不能替代实测。它只回答“先查哪一条”,不回答“现在到底多少毫秒”。把风险分当成性能数字,是另一种形式的越级承诺。

可运行代码

// file: src/summary/quality.ts
// tier: L1
// verify: node --test test/summary/quality.test.ts

export type QualityAttribute =
  | "first-paint"
  | "weak-network"
  | "disconnect"
  | "memory"
  | "moderation"
  | "multi-device";

export type EvidenceLevel = "L1" | "L2" | "L3" | "none";
export type RiskLevel = "low" | "medium" | "high";

export interface QualityScenario {
  id: QualityAttribute;
  name: string;
  stimulus: string;
  response: string;
  metric: string;
  budget: string;
  evidence: EvidenceLevel;
  evidenceNote: string;
  likelihood: 1 | 2 | 3 | 4 | 5;
  impact: 1 | 2 | 3 | 4 | 5;
  mitigation: string;
}

// 六条场景只描述“刺激—响应—度量”,不承诺任何未跑过的数字。
export const SCENARIOS: readonly QualityScenario[] = [
  {
    id: "first-paint",
    name: "首屏可见",
    stimulus: "冷启动后从通知进入首页,网络可用",
    response: "先渲染骨架与缓存中的首屏条目,再补全正文与计数",
    metric: "首屏可交互时间、骨架稳定时间",
    budget: "P75 ≤ 2.5s(示例值,本卷未实测)",
    evidence: "none",
    evidenceNote: "本卷只有场景模型,未跑浏览器或真机,不能引用为实测结论",
    likelihood: 4,
    impact: 5,
    mitigation: "首屏拆成骨架、缓存快照与增量三段,分别埋点",
  },
  {
    id: "weak-network",
    name: "弱网可用",
    stimulus: "往返时延 300ms、1% 丢包,用户连续下拉与翻页",
    response: "保留已加载条目,分页失败进入重试,不把整页清空",
    metric: "分页成功率、单页重试次数、首屏缓存命中率",
    budget: "P95 单页重试 ≤ 3 次(示例值)",
    evidence: "L2",
    evidenceNote: "13.2 的慢网适配器验证排队与重放,不模拟真实 RTT 与丢包",
    likelihood: 4,
    impact: 4,
    mitigation: "缓存快照兜底,失败只影响当前页,不触发全量刷新",
  },
  {
    id: "disconnect",
    name: "断线恢复",
    stimulus: "长连接断开 60 秒,期间产生 30 条新消息",
    response: "退避重连,按连续高水位补拉,补拉与实时推送共用去重容器",
    metric: "恢复后剩余空洞数、重连耗时、重复应用条数",
    budget: "可补空洞为 0,重复应用为 0(示例值)",
    evidence: "L2",
    evidenceNote: "13.2 用假时钟与脚本化响应验证补拉,未跑真实 socket",
    likelihood: 3,
    impact: 5,
    mitigation: "补拉报告保留 remainingGaps,补不全时交给 UI 明示",
  },
  {
    id: "memory",
    name: "长会话内存",
    stimulus: "连续滑动 30 分钟,跨越数千条内容",
    response: "窗口按上限淘汰,淘汰条目同步移出去重集合,锚点不被误删",
    metric: "窗口条目数、去重集合大小、JS 堆增长",
    budget: "窗口 ≤ 200 条,堆增长 ≤ 20%(示例值)",
    evidence: "L2",
    evidenceNote: "1.2 的容器淘汰用例验证计数与去重集合,未测真机堆与图片缓存",
    likelihood: 3,
    impact: 3,
    mitigation: "上限可配置,淘汰与去重清理放在同一个方法里",
  },
  {
    id: "moderation",
    name: "审核态不闪正文",
    stimulus: "发布内容命中审核,随后被下架",
    response: "详情显示审核占位,信息流显示标记,不提前渲染正文",
    metric: "审核态正文泄露次数、占位切换抖动次数",
    budget: "正文泄露 0 次(示例值)",
    evidence: "none",
    evidenceNote: "本卷只有内容状态模型,没有审核 UI 与申诉链路的实测",
    likelihood: 3,
    impact: 5,
    mitigation: "渲染前先判 state,审核态只走占位分支",
  },
  {
    id: "multi-device",
    name: "多端一致",
    stimulus: "两端同时收到同一批通知,其中一端先读",
    response: "以服务端 ackSeq 为准,旧快照不回退角标,已读事件幂等",
    metric: "两端未读角标差值、旧快照回退次数",
    budget: "最终差值 0,回退 0 次(示例值)",
    evidence: "none",
    evidenceNote: "第 10 章的未读合并只在单端建模,跨端一致性未在本卷验证",
    likelihood: 2,
    impact: 4,
    mitigation: "快照带 ackSeq,端上只接受不小于当前水位的响应",
  },
];

export function riskScore(scenario: QualityScenario): number {
  return scenario.likelihood * scenario.impact;
}

export function riskLevel(score: number): RiskLevel {
  if (!Number.isInteger(score) || score < 1 || score > 25) {
    throw new RangeError("风险分必须是 1 到 25 的整数");
  }
  if (score >= 15) return "high";
  if (score >= 8) return "medium";
  return "low";
}

export interface RankedRisk {
  id: QualityAttribute;
  name: string;
  score: number;
  level: RiskLevel;
}

export function rankedRisks(scenarios: readonly QualityScenario[] = SCENARIOS): RankedRisk[] {
  return scenarios
    .map((scenario) => {
      const score = riskScore(scenario);
      return { id: scenario.id, name: scenario.name, score, level: riskLevel(score) };
    })
    .sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
}

export interface CoverageReport {
  total: number;
  verified: number;
  unverified: number;
  byEvidence: Record<EvidenceLevel, number>;
  unverifiedIds: QualityAttribute[];
  topRisk: QualityAttribute | null;
}

export function coverage(scenarios: readonly QualityScenario[] = SCENARIOS): CoverageReport {
  const byEvidence: Record<EvidenceLevel, number> = { L1: 0, L2: 0, L3: 0, none: 0 };
  const unverifiedIds: QualityAttribute[] = [];
  for (const scenario of scenarios) {
    byEvidence[scenario.evidence] += 1;
    if (scenario.evidence === "none") unverifiedIds.push(scenario.id);
  }
  const risks = rankedRisks(scenarios);
  return {
    total: scenarios.length,
    verified: scenarios.length - unverifiedIds.length,
    unverified: unverifiedIds.length,
    byEvidence,
    unverifiedIds,
    topRisk: risks.length > 0 ? risks[0].id : null,
  };
}

export function scenarioOf(id: QualityAttribute): QualityScenario {
  const found = SCENARIOS.find((scenario) => scenario.id === id);
  if (!found) throw new Error(`未知质量场景: ${id}`);
  return { ...found };
}

// 交接不是“以后再说”,而是把本卷证明不了的部分写成卷三的输入。
export const VOLUME3_HANDOFF: readonly string[] = [
  "交易一致性会收紧乐观更新:支付、下单与库存不能沿用点赞的本地先行(见 5.3 的四档策略)",
  "多端一致需要服务端 ackSeq、版本号与冲突解决,端上合并只能保证单端收敛",
  "审核态需要真实审核链路、申诉状态机与证据留存,本卷只有内容状态模型",
  "首屏、内存与弱网预算需要真机与浏览器基线,L2 假时钟不能替代",
];

quality.ts 是 L1:没有时钟、网络与浏览器,只把场景目录、风险分级与覆盖率报告写成纯函数。coverage() 的 unverifiedIds 是交接清单的机器可读版本,评审时可以直接对照。

测试与真实输出

七条用例覆盖目录完整性、字段完整性、风险边界、排序稳定性、覆盖率、场景副本与交接清单。本机 Node 26.5.0 输出如下:

$ node --test test/summary/quality.test.ts
✔ 质量场景目录包含六条且 id 唯一 (1.298416ms)
✔ 每条场景都给出刺激、响应、度量、预算与证据说明 (0.288125ms)
✔ 风险等级在 8 与 15 两个边界切换 (0.378834ms)
✔ 风险排序按分数降序,同分时 id 字典序稳定 (8.907541ms)
✔ 覆盖率报告区分已验证与未验证场景 (0.220791ms)
✔ 按 id 取场景返回副本,未知 id 直接报错 (0.126584ms)
✔ 交接到卷三的风险清单非空且覆盖交易一致性 (0.079667ms)
ℹ tests 7
ℹ suites 0
ℹ pass 7
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 139.879709

这些用例证明的是“场景表与分级函数自洽”,不是“产品达到了预算”。first-paint 的预算写着“示例值,本卷未实测”,测试只检查它非空,不检查 2.5 秒是否达成。

本卷验证到哪、没验证什么

结论本卷证据不能外推到
信息流容器能去重、淘汰、保位置L2 进程内用例真实滚动容器的稳定性
点赞在乱序响应下收敛L2 状态存储用例服务端计数分片与防刷
写操作可按回滚成本分档L1 判定函数真实支付渠道与库存系统
断线后可按高水位补拉L2 假时钟用例真实 socket、NAT 超时与后台回收
实时消息可去重、可缓冲L1/L2 顺序门用例多端并发写入的冲突解决
审核态有内容状态模型L1 模型审核 UI、申诉链路与证据留存

这张表是卷二的质量边界。它不贬低 L2 的价值:L2 能抓住“忘了 await”“状态归属错”“重复投递双计”这类组合错误,而且快、稳定、可回归。它只是不能回答“真机上到底怎样”。

交接到卷三

卷三会把三条约束收紧。

第一,交易一致性。点赞可以本地先行,支付、下单与库存不行。5.3 的四档策略会在卷三里变成更硬的规则:凡是涉及资金、库存与对外通知的写操作,本地最多展示“进行中”,完成态必须等服务端事实。

第二,多端一致。第 10 章的未读合并只保证单端收敛;多端还需要服务端 ackSeq、版本号与冲突解决。端上能做的只是拒绝旧快照回退,不能代替服务端的仲裁。

第三,平台化与实验。首屏预算、弱网策略与审核态在不同端、不同渠道上不一样。卷三会把“一个端上的判断”扩展成“多端可配置的策略”,那时本节的风险表要按端拆分,而不是共用一张。

VOLUME3_HANDOFF 里保留了四条交接项。它们不是待办列表,而是“本卷没有证据、不要在下一卷重复假设”的边界声明。

失败复盘:测试全绿不等于质量达标

「回声」的一次版本发布前,social-lab 的 L1/L2 用例全绿。团队据此认为内容端“质量达标”,跳过了浏览器与真机检查。上线后同时出现两个问题:低端机首屏要四秒多;审核态内容在详情页闪了一下正文才变成占位。

第一个问题不在任何单测的断言范围里:容器、去重、补拉都正确,但首屏要等接口返回才渲染,没有骨架与缓存快照。第二个问题也不是状态模型的错:state 字段判断正确,但渲染层先画了正文再判审核态。两个问题都发生在 L2 之外。

复盘时我们把“测试全绿”改成了三句话:哪些结论有 L1 证据,哪些有 L2 证据,哪些根本没有证据。第一句对应纯函数与状态机,第二句对应进程内流程,第三句必须写进风险表并安排浏览器或真机验证。这个改动没有增加一条测试,却让下一次发布前的讨论从“都绿了”变成“还缺哪一类证据”。

小结

卷二收口不是把前面章节再讲一遍,而是把“验证到哪”说清楚。六条质量场景、一张证据表、一个风险分级函数,构成进入卷三前的质量边界。L2 能证明组合后的状态收敛,不能证明真机体验;交易一致性、多端一致与平台化策略,必须由卷三继续收紧。

卷三见。

阅读导航:上一节:13.2 故障注入:断网、慢网与断线重连 · 下一节:《互联网产品的客户端架构》卷二 · 目录与章节索引 。

常见错误与边界

  • 把 L2 全绿当成质量达标,跳过浏览器与真机验证。
  • 质量场景只写“要快”,不写刺激、度量口径与预算。
  • 风险分只按概率排,忽略影响不可逆的审核与交易场景。
  • 把示例预算写成实测结论,读者无法判断数字来源。
  • 覆盖率报告只统计“跑过的用例”,不统计“没有证据的场景”。
  • 交接清单写成待办列表,没有说明“为什么本卷证明不了”。
  • 用单端未读合并的结论宣称多端一致已经解决。
  • 在卷三继续沿用点赞式乐观更新处理支付、下单与库存。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查