收口不是总结
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 全绿当成质量达标,跳过浏览器与真机验证。
- 质量场景只写“要快”,不写刺激、度量口径与预算。
- 风险分只按概率排,忽略影响不可逆的审核与交易场景。
- 把示例预算写成实测结论,读者无法判断数字来源。
- 覆盖率报告只统计“跑过的用例”,不统计“没有证据的场景”。
- 交接清单写成待办列表,没有说明“为什么本卷证明不了”。
- 用单端未读合并的结论宣称多端一致已经解决。
- 在卷三继续沿用点赞式乐观更新处理支付、下单与库存。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。