结论先行:别把「降级」塞进 error rate——它该是一等公民指标,而不是 error rate 的补丁。 你「降级越深、error rate 信号越弱」的根因,是把 4 级离散降级压进了一个「非 2xx 请求数」的分子里;正确做法是拆成三个正交字段:depth(连续值,算分位数)+ level(枚举,切分与告警)+ reason(枚举,对齐后端),三者分别走独立指标流,不混进真实 request 的耗时。下面按你四个子问题给可落地方案。
一、指标定义:level 做切分、depth 做分位数,两个都要(别二选一)
你问「枚举还是连续值」——都要,各管一件事。方案 B「虚拟请求污染 P99」的病根就是让 depth 走 request 流;把 depth 拆成独立指标流后,0ms 尖刺问题自然消失。
type DegradeLevel = 'L0' | 'L1' | 'L2' | 'L3';
type DegradeReason = 'timeout' | 'http_5xx' | 'network' | 'abort' | 'client_error';
interface DegradeEvent {
sessionId: string; // 去重键 → UV 分母
pagePath: string; // 维度
level: DegradeLevel; // 枚举:切分 + 告警
depth: number; // 连续 0~100:算 P50/P95
cacheAgeMs?: number; // 仅 L1
reason: DegradeReason; // 枚举:对齐后端
backendRequestId?: string; // 对齐后端(见第三节)
ts: number;
}
L1 的 depth 必须按 cacheAge 细分——这直接回答你「cacheAge>24h 要不要单独一档」:要,而且要分四档,否则「3 天前缓存」和「刚超时」在 depth 上是一回事,SLO 就没了抓手:
function l1Depth(cacheAgeMs: number): number {
const h = cacheAgeMs / 3.6e6;
if (h <= 1) return 20; // 新鲜缓存,体验接近 L0
if (h <= 24) return 34; // 当天缓存
if (h <= 72) return 50; // 3 天内,「数据可能非实时」的边界
return 60; // 超 3 天,体验已接近 L2 空态
}
const DEPTH = { L0: 0, L2: 67, L3: 100 }; // L1 走 l1Depth()
有了连续 depth,「降级深度 P50/P95」这种分位数指标才成立——你问「有没有人做过分位数」,答案是 depth 连续化之后它天然可算,不需要额外发明。
二、SLO:分母用 UV、窗口用 1h、L1 陈旧度单独一档
分母用 UV(sessionId 去重),不用 PV。 你 L1 期间用户停留 2 小时、可能触发几十次 fetch,PV 分母会把「一个用户的一次降级」放大成几十次——SLO 数字失真且不可行动。这和你 B 端「低频高价值」的场景匹配。
窗口用 1h,不用 5min。 你 L1 事件稀疏,5min 窗口内样本太少、分位数噪声大。注意:你后端 99.9%/5min 是 C 端高 QPS 场景,两边窗口不必对齐——该对齐的是「事件可关联」(第三节),不是「窗口相同」。
把 availability 和 stale 拆成两个独立 SLO,直接解决你「数字难看没抓手」:
availability = 1 − (depth ≥ 67 的 UV) / 活跃 UV
# 只卡 L2+(用户真的看不到数据)→ 你问的「L1 以上 99.5%」该这样落地
stale_rate = (L1 且 cacheAge > 72h 的 UV) / (L1 的 UV)
# 单独一档:用户看的是旧数据 → 你问的「cacheAge>24h 单独算」
两个 SLO 各管一件事:availability 管「有没有数据」,stale_rate 管「数据新不新」。数字都好看、都有抓手。error budget 用 availability 的:(1 − 99.5%) × 活跃 UV,超预算就冻结发布、进降级链路专项——这就是标准 SRE error budget,depth 连续化让它能直接纳入(你问「有没有人纳入」:可以,前提是 depth 连续)。
三、前后端对齐:对齐「事件可关联」,不是「数字相等」
你三套数字(后端 0.3% / RUM 1.2% / 用户感知)对不上,根因是量纲不同:后端记 request 维度、RUM 记 session 维度、用户记感知。别试图让两个数字相等,要做的是共享 reason 枚举 + 透传 requestId 让它们能 join:
- 前后端共用上面的
DegradeReason枚举。⚠️ 你 RUM error rate 1.2% 虚高的来源大概率是abort(用户切页触发的 abort 被算成失败)——对齐后把abort从 error rate 分子剔除。 - 后端每个响应带
X-Request-Id(不是你方案 C 推不动的X-Server-Degraded——那个只覆盖后端主动降级,抓不到你 60% 的前端自降级);前端降级事件带上触发它的backendRequestId。 - 一条口径约定写进文档:
「前端降级率」以 UV + reason 枚举为准;「后端 error rate」以 请求 + status 为准;两者通过 requestId 关联,而非数字对齐。
join 之后,同一张宽表里「前端一次降级」和「后端一次 5xx」能对上,值班同学一眼看出「这次 L1 是后端 503 还是网络 timeout」。
四、告警分级(B 端跑通的骨架)
按 depth × 页面权重 加权,不是单看 L1 降级率。给 260 个路由分级(core / standard / long-tail),core 页(你 12 条工单集中的「订单列表」这类)阈值收紧 3~5 倍:
P0 L3 错误页(depth=100):core 页 5min 内 ≥3 UV → 立即唤醒
P1 L2 空态率(depth≥67 的 UV 占比)> 2% 持续 1h → 电话
P2 stale_rate(L1 且 cacheAge>72h)> 5% 持续 1h → IM 工单
P3 非核心页 L1 降级率 > 10% → 日报
这套「等级×权重×窗口」正好是 depth(连续)+ level(枚举)+ pagePath(维度)三个字段各发挥用场的地方——三个字段不是冗余,是分别喂给分位数、告警切分、权重加权三套逻辑。
一句话小结
- depth 连续值 → 算 P50/P95 分位数(独立指标流,别混进 request 耗时);
- level 枚举 → 切分 + 告警分级;
- reason 枚举 + requestId 透传 → 和后端对齐「事件可关联」,而非「数字相等」;
- SLO 分母 UV、窗口 1h、L1 陈旧度单独一档、availability 与 stale_rate 拆成两个 SLO。
你「数字难看没抓手」的根因,是把 4 级降级压进了 1 个 error rate 分子——拆开就顺了。
参考:889《谁来监控监控》的「自证健康指标」思路我借鉴为「SDK 上报自身心跳 + depth 双上报校验」;847《告警分层 P0/P1/P2》的骨架我直接复用,把「降级深度」映射进 P0/P1/P2 三档。