场景不等于风险
“弱网下要能打开”是场景,“弱网打开可能让用户在 5 秒内看不到任何内容,而团队直到投诉才知道”是风险。前者描述期望,后者描述偏离期望的后果、概率与可见性。评审时只列场景,讨论会停在“应该做”;把场景转成风险,才能排序、分配责任、判断什么时候必须关闭。
四个场景贯穿卷一:弱网打开、进程回收恢复、支付回调重复、包体超限。它们分别对应性能、可靠性、安全与可维护性,也分别落在数据层、状态层、写路径与构建产物上。把它们放进同一张清单,是为了用同一把尺子比较,而不是让每个专项各自定义“严重”。
风险条目必须可观察。触发条件写成“2G 下打开发现页”,期望写成“5 秒内出现可读首屏”,缓解写成“缓存优先渲染加请求预算”。如果一条风险写不出触发条件,它大概率不是风险,而是担忧。
三个维度与阈值
评分用三个 1 到 5 的整数:
| 维度 | 1 分 | 5 分 | 为什么单独计分 |
|---|---|---|---|
| 影响 impact | 可忽略 | 阻断核心任务 | 决定后果上限 |
| 概率 likelihood | 罕见 | 几乎每天发生 | 决定复现频率 |
| 发现难度 detectability | 一眼可见 | 只能靠投诉 | 决定反馈延迟 |
分数是三者相乘,范围 1 到 125。阈值是案例假设值,不是行业标准:>= 60 为 high,>= 25 为 medium,其余为 low。把发现难度单独乘一次,是因为“用户先发现”的风险不能按低概率处理;一个影响 5、概率 3、但用户第二天才投诉的问题,实际暴露时间远大于它的概率。
等级只是排序工具,结论还看状态。mitigated 与 accepted 的高风险不阻塞发布,但 accepted 必须写明谁接受、接受多久;open 的高风险只要存在一条,评审结论就是 fail。这条规则故意做得硬:允许团队关闭风险,不允许团队假装看不见。
四个场景的初始评分
| 编号 | 场景 | 影响 | 概率 | 发现难度 | 分数 | 等级 | 状态 |
|---|---|---|---|---|---|---|---|
| R-01 | 弱网打开 | 4 | 4 | 3 | 48 | medium | open |
| R-02 | 进程回收恢复 | 4 | 3 | 5 | 60 | high | open |
| R-03 | 支付回调重复 | 5 | 4 | 4 | 80 | high | open |
| R-04 | 包体超限 | 3 | 5 | 2 | 30 | medium | mitigated |
支付回调排第一,不是因为它最常发生,而是因为影响与发现难度都高:重复扣款一旦发生,用户会先发现,团队后知道。进程回收排第二,因为它的发现难度是 5,用户往往只描述“刚才写的东西没了”,很难复现。包体超限虽然有 5 的概率,但影响与发现难度都低,且已经有体积预算门禁,所以状态是已缓解。
这张表要在评审前发给所有参与者,而不是在会上现场填。分数可以争论,但争论必须落到某一个维度:是影响被低估,还是概率没有数据,还是发现手段缺失。笼统地说“我觉得这个更严重”,无法进入排序。
评分、排序与输出
下面的文件实现三件事:校验维度、生成带排名的清单、输出固定文本。它不读埋点,也不连监控,所有输入由调用方提供。
// file: src/summary/risk-register.ts
// tier: L1
// verify: node --test test/summary/risk-register.test.ts
export type QualityAttribute =
| "performance"
| "reliability"
| "security"
| "maintainability";
export type RiskStatus = "open" | "mitigated" | "accepted";
export type RiskLevel = "high" | "medium" | "low";
export type Score = 1 | 2 | 3 | 4 | 5;
export interface RiskScenario {
id: string;
title: string;
attribute: QualityAttribute;
/** 可观察的触发条件与期望响应 */
scenario: string;
/** 影响面:1 可忽略,5 阻断核心任务 */
impact: Score;
/** 发生概率:1 罕见,5 几乎每天发生 */
likelihood: Score;
/** 发现难度:1 一眼可见,5 只能靠用户投诉 */
detectability: Score;
mitigation: string;
owner: string;
status: RiskStatus;
}
export interface ScoredRisk extends RiskScenario {
score: number;
level: RiskLevel;
rank: number;
}
export interface RiskRegister {
entries: ScoredRisk[];
counts: Record<RiskLevel, number>;
openHigh: string[];
verdict: "pass" | "fail";
}
const HIGH_SCORE = 60;
const MEDIUM_SCORE = 25;
function isScore(value: number): value is Score {
return Number.isInteger(value) && value >= 1 && value <= 5;
}
function levelOf(score: number): RiskLevel {
if (score >= HIGH_SCORE) return "high";
if (score >= MEDIUM_SCORE) return "medium";
return "low";
}
/**
* 把质量属性场景转成可比较的分数:影响 × 概率 × 发现难度。
* 发现难度单独乘一次,是因为“用户先发现”的风险不能按低概率处理。
*/
export function scoreRisk(input: RiskScenario): Omit<ScoredRisk, "rank"> {
if (input.id.trim().length === 0 || input.title.trim().length === 0) {
throw new Error("风险条目必须有 id 与 title");
}
if (input.mitigation.trim().length === 0) {
throw new Error(`风险 ${input.id} 必须写明缓解措施`);
}
for (const [name, value] of [
["impact", input.impact],
["likelihood", input.likelihood],
["detectability", input.detectability],
] as const) {
if (!isScore(value)) {
throw new Error(`风险 ${input.id} 的 ${name} 必须是 1-5 的整数,收到 ${value}`);
}
}
const score = input.impact * input.likelihood * input.detectability;
return { ...input, score, level: levelOf(score) };
}
/** 按分数降序、同分按 id 升序生成排名;只有 open 的高风险会阻塞结论。 */
export function buildRiskRegister(scenarios: RiskScenario[]): RiskRegister {
const seen = new Set<string>();
for (const scenario of scenarios) {
if (seen.has(scenario.id)) throw new Error(`重复风险编号: ${scenario.id}`);
seen.add(scenario.id);
}
const entries: ScoredRisk[] = scenarios
.map(scoreRisk)
.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
.map((risk, index) => ({ ...risk, rank: index + 1 }));
const counts: Record<RiskLevel, number> = { high: 0, medium: 0, low: 0 };
for (const entry of entries) counts[entry.level] += 1;
const openHigh = entries
.filter((entry) => entry.status === "open" && entry.level === "high")
.map((entry) => entry.id);
return {
entries,
counts,
openHigh,
verdict: openHigh.length === 0 ? "pass" : "fail",
};
}
/** 固定格式的评审输出:第一行是分布,中间是排名,最后一行是结论。 */
export function formatRiskRegister(register: RiskRegister): string[] {
const { high, medium, low } = register.counts;
const lines = [
`风险清单:${register.entries.length} 条,high ${high} / medium ${medium} / low ${low}`,
];
for (const entry of register.entries) {
lines.push(
`${entry.rank}. [${entry.level} ${entry.score}] ${entry.id} ${entry.title} / ${entry.attribute} / ${entry.status} / owner=${entry.owner}`,
);
}
lines.push(
register.verdict === "pass"
? "评审结论:pass"
: `评审结论:fail,未关闭高风险:${register.openHigh.join(",")}`,
);
return lines;
}
实现有几个刻意的限制。
第一,维度越界直接抛错,不截断到 1 或 5。静默截断会让一条“影响 9”的错误输入变成“影响 5”,评审者以为分数可信,实际上输入已经坏了。
第二,重复编号在排序前拒绝。风险编号是引用锚点,评审记录、工单和发布说明都会引用它;两个 R-03 会让“关闭 R-03”这句话失去意义。
第三,排序规则固定为分数降序、同分按编号升序。同分时按编号而不是按标题,是为了让两次运行的输出可直接比较;标题会改,编号不该改。
第四,结论只看 open 的高风险。这避免两个极端:把所有高风险都算成阻塞,团队永远发不了版;或者把“已接受”当成“已解决”,风险从清单上消失但后果还在。
测试与真实输出
测试覆盖排序与计数、高风险关闭后的结论、非法维度、重复编号、固定输出格式。完整测试在 test/summary/risk-register.test.ts。
单文件命令:
node --test test/summary/risk-register.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 四个场景按影响×概率×发现难度排序,只有 open 高风险阻塞结论 (0.958917ms)
✔ 高风险全部关闭后结论转为 pass,medium 未关闭不阻塞 (0.101084ms)
✔ 评分维度越界或非整数时直接拒绝,不静默截断 (0.277292ms)
✔ 重复风险编号在进入排名前被拒绝 (0.075167ms)
✔ 评审输出固定为分布、排名、结论三段 (0.111875ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 176.362208
输出格式固定为三段:第一行是等级分布,中间每个风险一行,最后一行是结论。下面的文本由同一份测试数据生成:
风险清单:4 条,high 2 / medium 2 / low 0
1. [high 80] R-03 支付回调重复 / security / open / owner=支付组
2. [high 60] R-02 进程回收恢复 / reliability / open / owner=客户端组
3. [medium 48] R-01 弱网打开 / performance / open / owner=客户端组
4. [medium 30] R-04 包体超限 / maintainability / mitigated / owner=构建组
评审结论:fail,未关闭高风险:R-03,R-02
一次基线评审记录
时间:卷一收口评审。参与:客户端、支付、构建、测试。输入:13.1 的 L2 基线、13.2 的依赖图、上面四个场景。
第一个争议是 R-03 的概率。支付组认为回调重复“服务端已经去重”,概率应该降到 1。测试追问:客户端重复提交同一订单时,服务端按什么键去重?回答是订单号加用户 id,但客户端在超时重试时会生成新的请求 id。于是概率维持 4,缓解措施改成“客户端复用幂等键,服务端按幂等键去重”,责任留在支付组。
第二个争议是 R-02 的发现难度。产品认为“用户会反馈”,应该降到 3。客户端展示基线记录:页面栈没有持久化,进程回收后用户回到 feed 根页,草稿有队列但页面位置丢失;这类问题在埋点里只表现为“会话变短”,不会自动告警。发现难度维持 5,新增一条缓解:恢复失败时上报 restore_miss 事件,带上缺失的层级。
第三个结论是 R-04 可以关闭。构建组出示体积预算门禁:首包超过 2MB 时 CI 直接失败,最近三次构建都在 1.7MB 左右。评审把状态改为 mitigated,但保留在清单里,每季度复核一次阈值。
最终结论:fail,两条 open 高风险。发布条件写成三条:支付幂等键联调通过;进程回收恢复至少覆盖页面栈与草稿;两条风险的关闭证据进入发布说明。评审没有把“基线测试全绿”当成发布依据,因为基线只覆盖 L2。
风险清单怎么用
清单不是评审当天的一次性文档。每次发布前重新跑一遍评分,状态变化要写明证据:测试报告、灰度数据、线上告警。分数变了要能解释是哪个维度变了,而不是“感觉好一点”。
清单也不该无限增长。关闭的风险保留一个季度后归档,新增风险必须能追溯到场景或事故。超过二十条 open 风险的清单通常说明没有排序,而不是风险真的多。
最重要的是责任人。没有 owner 的风险会在评审后自然消失,因为它不属于任何人的待办。owner 不一定是修复者,但必须是能在发布评审上说“这条现在是什么状态”的人。
小结
质量属性场景描述期望,风险条目描述偏离期望的后果、概率与可见性。三维评分把三者压成一个可排序的分数,阈值与状态决定它是否阻塞发布;固定输出让两次评审可以比较,而不是各说各话。
卷一的四个场景最终收敛为两条 open 高风险与一条已缓解的中风险。清单的价值不在于分数本身,而在于它逼团队把“应该做”翻译成“谁在什么时候拿出什么证据关闭它”。
阅读导航:上一节:13.2 模块划分与依赖图 · 下一节:目录与学习路径 。
常见错误与边界
- 只列场景不列风险。没有后果、概率与可见性,就无法排序。
- 把发现难度漏掉。用户先发现的问题,实际暴露时间远大于概率估计。
- 维度越界时静默截断。坏输入会伪装成可信分数。
- 允许重复编号。引用锚点一旦重复,“关闭某条风险”就失去意义。
- 把 accepted 当成 resolved。接受是有人承担后果,不是问题消失。
- 用高风险数量制造恐慌。只看 open 状态,关闭要有证据。
- 清单只增不减。归档旧风险,新增必须可追溯。
- 没有 owner。评审结束后风险会自然消失,因为没人对它负责。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。