大促当天上游超时,降级开关按预案打开了。监控里错误率从 12% 掉到 0.3%,值班群里松了口气。半小时后发现客服工单暴增:页面能打开,商品列表也能显示,但点"加入购物车"没有任何反应——降级逻辑返回了一个空列表,前端把它渲染成了"当前无可购买商品"。
降级被当成"别让页面崩",可用户要的不是不崩的页面,是能完成的动作。这篇把兜底拆成三个问题:兜到什么程度算可用、兜底本身怎么被验证、兜底什么时候会悄悄变成主路径。
1. 判据不是"没崩",而是核心动作能不能完成
"服务可用"在事故现场常被偷换成"HTTP 200 比例高"。这两个指标之间隔着用户的全部意图。
| 判定口径 | 上升时说明什么 | 掩盖了什么 |
|---|---|---|
| HTTP 200 比例 | 网关没挂 | 200 里可能是空壳页面 |
| 错误率 | 没有返回 5xx | 降级返回的默认值也是 200 |
| P99 延迟 | 响应够快 | 快可能是因为什么都没查 |
| 核心动作完成率 | 用户真的做成了事 | 基本不掩盖,但需要埋点 |
| 核心动作时长 | 完成得是否可忍受 | 慢但可完成也算可用 |
可用的判定标准必须能一行写成:某类用户在某个入口,完成某个动作的成功率不低于某个阈值。写不出来,说明降级本身没有目标,只是在延迟一次报错。
// 可执行的可用性判据,而不是形容词
availability_criteria:
entry: "商品详情页"
core_action: "加入购物车"
success_event: "cart_add_success"
min_success_rate: 0.90 // 低于此值视为不可用
p95_max_ms: 3000
window: 5m
这份判据的意义在于:降级方案评审时,可以直接问"这个降级路径下,cart_add_success 还能不能打点"。打不了点,就等于这个降级路径无法被判定为可用。
2. 兜底的三层,与三层的用户感受
兜底不是一件事,是三种完全不同的东西。它们的用户感受差得很远,运维成本也差得很远。
| 层级 | 做法 | 用户感受 | 兑现的承诺 | 主要风险 |
|---|---|---|---|---|
| L1 旧数据 | 返回缓存或上一次成功结果 | "有点旧,但能用" | 可完成核心动作 | 数据新鲜度失控 |
| L2 裁剪结果 | 丢掉部分字段/模块,保留主干 | "少了点东西" | 可完成核心动作 | 裁剪规则与页面耦合 |
| L3 明确失败 | 返回可读的失败态与重试入口 | "现在不行,一会儿再来" | 诚实 | 转化直接归零 |
关键判断:L1 和 L2 兑现的是"能做事",L3 兑现的是"不骗人"。三者没有优劣,只有适用条件。
- 核心动作依赖上游某字段 → L2 不能裁掉它,只能让位给 L3;
- 核心动作只读不写 → L1 通常够用;
- 核心动作是写且需实时校验 → L3 是唯一诚实的选择。
把 L3 做成"看起来像 L2"的页面是最坏组合:用户以为能点,点了没反应。
3. 兜底路径平时不运行,所以它是最少被验证的代码
降级分支在生产上执行次数接近零。它没被压测过、没被集成测试覆盖过、没经过真实流量,甚至连日志格式都可能与主路径不一致。等到真要用它的那天,你验证的是它的第一次执行。
| 验证手段 | 覆盖什么 | 成本 | 频率 |
|---|---|---|---|
| 用例里强制抛异常 | 分支能否进入、返回值结构 | 低 | 每次提交 |
| 契约测试对齐主路径 | 响应字段与前端约定一致 | 中 | 每次提交 |
| 定时演练自动开降级 | 真实链路、超时、开关联动 | 高 | 按季度 |
| 故障注入 | 上游超时、半死、慢速三种形态 | 中高 | 按月 |
| 演练后核对核心动作 | 判据是否真的可达成 | 低 | 每次演练 |
最容易漏的是第三行与第五行的组合:开关能打开,不代表降级后核心动作能完成。演练验收不是"看到降级生效",而是"降级期间完成率仍达标"。
─── 演练脚本的骨架:开降级、打流量、核判据、必关
just_open_degrade "$MODULE" "$REASON_ID" // 带工单号,禁止裸开
sleep "${DRILL_WINDOW:-300}" // 观察窗口
curl -s "$METRIC_API/core_action_rate" | jq '.rate'
just_close_degrade "$MODULE" // 无论成功失败都要关
4. 兜底需要被监控,否则它会悄悄变成主路径
这是兜底逻辑最隐蔽的失效方式:它不报错、不告警、不占事故编号,只是执行次数从 0 缓慢爬升到 30%,然后稳定在那里。理论上系统全程"可用",实际上所有用户都在读过期数据。
必须埋的四个数:
| 指标 | 用来回答 | 异常信号 |
|---|---|---|
| 兜底路径执行次数 | 走了多少次 | 非零且单调上升 |
| 走到了哪一层 | L1/L2/L3 的分布 | L1 长期为主 = 上游从没真恢复 |
| 兜底持续时间 | 已经降级多久 | 超过阈值无人处理 |
| 掩盖故障数 | 有多少真实故障被降级吃掉 | 上游故障复盘里找不到对应记录 |
第二项最容易被忽略。只统计"触发降级次数"的系统,看不到某条链路其实已经从 L1 变成常态——因为 L1 的成功率太高,永远不会触发阈值告警。
// 兜底里打点,不是在主路径里推断
if (fallbackResult.isPresent()) {
// 层级标签是排查的关键:L1 旧数据 / L2 裁剪 / L3 明确失败
Metrics.counter("fallback.execute",
"layer", fallbackResult.get().getLayer(),
"module", moduleName).increment();
}
还有一条纪律:兜底返回不能伪装成正常结果。响应带 degraded: true 或等价标记,前端才能显示"数据可能有延迟";没有标记,前端只能猜。
5. 触发与恢复:自动容易误触发,人工容易错过窗口
| 触发方式 | 反应速度 | 主要失效 | 适用 |
|---|---|---|---|
| 全自动(阈值驱动) | 秒级 | 抖动导致降级/恢复反复切换 | 高频链路、影响面明确 |
| 全人工(值班决定) | 分钟到十分钟 | 错过最佳窗口,或不敢开 | 影响面大、副作用不明确 |
| 半自动(自动建议+人工确认) | 分钟级 | 停在建议页没人看 | 前后端耦合强的模块 |
自动触发的坑在抖动:错误率在阈值上下浮动,开关被反复切换,页面忽好忽坏,比稳定降级更糟。必要措施是滞回(降级阈值高于恢复阈值)加最短保持时间。
恢复的判断是同一个问题的另一半,而且更容易出错:上游恢复了吗? 靠探测还是靠告警?
| 恢复信号 | 可靠性 | 说明 |
|---|---|---|
| 上游健康检查通过 | 中 | 健康但慢,仍会继续超时 |
| 错误率回落 | 中高 | 需要足够长的观察窗口 |
| 告警自动清除 | 低 | 告警清除条件常比真实恢复宽松 |
| 主动试探真实请求 | 高 | 成本是要做灰度恢复 |
推荐渐进恢复:按比例放量,每步看完成率。一次恢复到底,等于自己制造瞬时流量尖峰。
// 带滞回与最短保持时间的触发规则
trigger:
open_error_rate: 0.05 // 5% 触发降级
close_error_rate: 0.01 // 1% 才允许恢复,滞回防抖
min_hold: 10m // 最短保持,禁止反复切换
recover:
ramp: [1, 10, 50, 100] // 按比例渐进放量
step_hold: 5m // 每档观察时长
6. 兜底数据的保质期、开关的归属,以及一条不能越的线
旧数据有保质期。"降级返回上次成功结果"这句话没说清"上次"是多久以前。一小时前是对的,一天前的价格可能已经作废。
| 数据类型 | 陈旧容忍度(典型量级) | 超期后的正确做法 |
|---|---|---|
| 商品库存 | 极低 | 不得降级,宁可明确失败 |
| 商品价格 | 低 | 展示但禁止下单,走 L3 |
| 商品标题/图片 | 高 | 可长时降级 |
| 推荐列表 | 高 | 可长时降级 |
| 用户账户余额 | 无 | 严禁降级返回旧值 |
"降级开关"本身也需要被管理:谁有权开、开了多久、怎么关。开关没有归属人,就会变成永久状态——没人敢关,因为没人能确认关了会怎样。
// 开关登记项:没有归属人与到期时间的开关不应该存在
{
"switch": "order.preview.fallback",
"owner": "ORDER_TEAM",
"opened_by": "ONCALL_ID",
"opened_at": "TIMESTAMP",
"auto_close_after": "PT4H",
"reason_ticket": "INCIDENT_ID"
}
最后是一条不可越过的边界:兜底不得绕过安全控制。降级可以少查数据、可以返回旧值、可以拒绝写入,但不能跳过鉴权、不能跳过租户隔离、不能跳过脱敏。以"提升可用性"为由在降级分支里放宽权限,是把一次可恢复的可用性事故换成一个不可逆的数据事故。
团队层面的度量与边界:
| 度量 | 采集方式 | 关注点 |
|---|---|---|
| 兜底执行总次数 | 埋点计数 | 是否在上升 |
| 兜底期间核心动作完成率 | 判据事件 | 降级是否真的"可用" |
| 兜底持续时间 | 开关日志 | 是否无人处理 |
| 被降级掩盖的故障数 | 与上游复盘交叉比对 | 有多少事故从未被登记 |
不该投入完整降级体系:非核心功能(推荐位、运营位)、纯内部系统(影响面只有同事)、影响面极小且上游自带重试、以及数据实时性本身就是产品价值的场景——那里降级等于交付错误答案。
7. 排错
| 现象 | 常见原因 | 处理方向 |
|---|---|---|
| 降级后页面能开但无法交互 | 判据缺位,只对齐了状态码 | 补核心动作埋点,重定判据 |
| 降级开关反复开关 | 无滞回、无最短保持时间 | 加阈值间隔与保持窗口 |
| 上游早已恢复,降级没关 | 恢复信号依赖告警清除 | 改渐进恢复 + 主动试探 |
| 用户投诉数据不对但监控全绿 | 兜底返回未标记 | 响应带 degraded 标记 |
| 降级期间鉴权报错 | 降级分支缺上下文 | 明确禁止降级跨安全边界 |
排错顺序:先确认走的是哪一层,再确认该层的判据是否可达成,最后才怀疑开关与触发逻辑。多数"降级没用"的问题出在第一层——根本没记录走了哪条路。
8. 自测
- 为什么"HTTP 200 比例高"不能作为可用的判定口径?
- L1、L2、L3 三层兜底分别兑现了什么承诺,各自的失效条件是什么?
- 为什么降级分支是系统里最少被验证的代码?
- 只统计"触发降级次数"的监控,会漏掉哪种形态的故障?
- 自动降级为什么要带滞回和最短保持时间?
- 降级绝对不该越过哪一条边界?