3.2 降级方案:兜底与默认值
熔断打开后流量不会消失,它落在降级上。降级是保命层的另一半:熔断负责「不跟下游一起死」,降级负责「用户还能拿到一个过得去的结果」。这一篇按 err_code 逐条配兜底,给每个接口的兜底数据来源与保鲜机制,并复盘 2 次兜底翻车。降级的方法论与 go-micro-cps-guide 的兜底章节对照,本篇重点在数据来源的取舍与保鲜纪律。
场景:熔断期间用户体验不能掉零
| 接口 | 无降级时 | 降级后 |
|---|---|---|
| 下单 | 整单失败,502 | 排队加预估延迟,可继续 |
| 查单 | 查不到,报错 | 返回最近快照加「数据延迟」标 |
| 库存展示 | 空白 | 展示 5 分钟前的缓存值 |
| 推荐位 | 消失 | 展示默认热榜 |
| 支付 | 失败 | 拒绝受理,返回「稍后重试」 |
四条原则:可写的降级为「可重试态」,可查的降级为「缓存态」,展示类降级为「默认值」,支付类不降级——资金链路宁可拒绝不可兜底。支付拒绝的话术也固定:不解释具体原因(防用户绕),只给「稍后重试」加一个重试提示时间(通常 30s),重试时间对应 3.3 的重试退避节奏。
兜底方案立项时最容易犯的错是「按接口配」而不是「按字段配」。我们第一版按接口给兜底,上线后发现查单接口里订单金额与下单时间一起被兜成快照值,金额这种资金字段绝不能进兜底,后来把「字段白名单」做成配置:每个降级接口声明可兜底字段列表,列表外的字段一律不降级。
方案:兜底数据来源三选一
| 来源 | 适用 | 保鲜机制 |
|---|---|---|
| 本地缓存 | 库存、价格等读多写少 | 后台 30s 刷新,失败保留旧值 |
| etcd 共享 | 全局开关类配置 | 变更时即时推 |
| 默认值常量 | 推荐位、文案类 | 发布时随包更新 |
配兜底先按 2.2 的错误码表逐条过 err_code:
| err_code 段 | 语义 | 兜底动作 |
|---|---|---|
| 1xxx 订单 | 订单侧业务态 | 查单走快照,下单走排队态 |
| 2xxx 支付 | 资金态 | 一律拒绝,不兜底 |
| 3xxx 库存 | 库存态 | 读走缓存,扣减排队 |
| 系统态 UNAVAILABLE | 下游不可用 | 熔断加降级全接管 |
| 系统态 DEADLINE | 超时 | 走 3.3 的重试或兜底 |
原则:业务态能精确兜底(知道为什么错,知道兜什么),系统态只能粗粒度兜底(走全局降级级别)。错误码不显式化的服务做不了这张表,2.2 的纪律在 3.2 才有了回报。
降级态的响应也要有统一外壳:每个接口的降级响应带 degrade_level(对应开关级别)与 degraded_at,前端按 level 决定交互——level 1 显示「数据可能延迟」,level 2 显示默认态加「恢复中」,level 3 全默认。外壳写进 govkit 的响应封装,各服务不用各造一套文案。
本地缓存在 Redis 侧的布局:cache:{svc}:{key},TTL 60s,后台 goroutine 每 30s 刷新 20% 的 key(错峰防惊群),刷新失败保留旧值并打点 cache_stale=1。订单快照缓存 snap:order:{id} 每次下单成功异步写,保留 24h,查单降级时直接读快照。
三选一的选择依据是数据的一致性要求:读多写少且允许分钟级滞后的(库存、价格)走本地缓存加 Redis 双份;全局一致要求高的(开关、配置)走 etcd 即时推;基本不变的(文案、默认榜单)用包内常量,降级路径零外部依赖,外部依赖全断时仍可用。本地缓存与 Redis 双份的分工:本地缓存抗「Redis 也挂了」的极端情况,容量限制在 10000 条 LRU,够覆盖热点 key 即可。
// 降级读取顺序:熔断中则跳过下游,按序取兜底
func (s *Svc) Stock(ctx context.Context, id string) (int, error) {
if breaker.Opened() {
if v, ok := s.local.Get(id); ok { return v, ErrDegraded } // 缓存
return defaultStock, ErrDefault // 默认值
}
return s.rpc.Stock(ctx, id) // 正常
}
返回错误用专门的 ErrDegraded 与 ErrDefault,调用方按错误类型决定展示层文案,而不是把降级当普通失败 502 出去。兜底字段白名单在代码里是编译期约束:生成代码时把白名单编进 struct tag,序列化器跳过白名单外字段,白名单漂移要在 proto 评审时发现。
配置与代码:降级开关要可热切
所有兜底走一个全局开关表放 etcd 的 gov/degrade/{svc},值是各接口的降级级别(0 正常、1 缓存、2 默认值、3 全默认)。开关由 4.1 的灰度控制器与 4.3 的告警通道共同维护:下游错误率连续 5 分钟超 30% 自动升一级,回落后 10 分钟降一级。人手也能提,但必须填工单号。
热切的实测:改 etcd 值到 17 个调用方全部生效约 1.2s(watch 传播 300ms 加本地重建),这比发版快两个数量级,是大促保命的最后开关。
降级级别还带自动回落的对账:升级靠告警 5 分钟窗口,回落后 10 分钟才降一级,升降级不对称是刻意的——回落后立刻降会撞上「回落后二次抖动」,10 分钟观察窗把这类抖动挡在兜底内。
踩坑:两次兜底翻车
第一,兜底数据过期没标。库存兜底用缓存值时没带「数据延迟」标记,用户按 5 分钟前的库存下单,下单后扣减失败,客诉 6 起。修法是降级响应统一带 degraded_at 时间戳,前端按时间差显示「数据可能延迟」,兜底的数据必须自带保鲜度说明。
第二,默认值兜住了不该兜的。推荐位默认值兜住了「接口挂了」,但兜不住「默认值本身就是错的」——一次热榜配置写错默认值,降级期间全量用户看到同一个错误榜单 40 分钟。默认值进了 etcd 后同样要有变更校验:发布前与当前线上值 diff,差异超限要二次确认,防「默认值静默腐烂」。配置管理的手法与 tencent-cloud-practice 里配置中心一节相通,核心都是「变更要留痕、差异要可见」。
还有一个边界:降级不能降数据。查单降成快照可以,但订单金额、支付状态这类资金字段禁止兜底,错了比没有更贵。这条写进了 3.2 的 checklist,评审降级方案时逐条过。
降级的可观测性也进 checklist:每次降级命中打一条 degrade=hit level=1 src=cache age=45s,4.4 检索时按 age 聚合能直接看到兜底数据的新鲜度分布。一次演练中发现 age 超 300s 的 key 占 11%,追到是刷新 goroutine 在 Redis 抖动期集体失败,补了刷新失败的退避重试,age 超 300s 占比降到 1%。
降级演练:开关切换全流程
大促前每个季度做一次降级演练,流程定死:
| 步骤 | 动作 | 时长 |
|---|---|---|
| 1 | 提工单,声明降级对象与级别 | 10 分钟 |
| 2 | 低峰窗口改 etcd 开关 | 即时 |
| 3 | 看板确认 17 实例全切 | 2 分钟 |
| 4 | 打真实流量验证兜底路径 | 10 分钟 |
| 5 | 回切开关,观察 30 分钟 | 30 分钟 |
演练里兜底路径被打穿过一次:查单快照在极端降级时 Redis 也进了只读模式(Redis 故障演练与降级演练撞了窗口),快照读失败。修复后快照加本地 1000 条热单兜底,Redis 全断也能撑核心路径。演练的排期纪律:与 5.2 的容灾演练错开,同一天最多一类演练,防多开关同时动。
演练还要验「降级中恢复」的边界:开关回正常态后,缓存里的陈旧值要按 TTL 自然过期,不能立即全量回源。一次回切时缓存 6 万 key 同时回源,把下游读接口打出小尖峰。修法:回切后 5 分钟内刷新节奏压到每 30s 刷 5% 的 key,把回源摊平。
小结
降级三选一定来源(本地缓存、etcd 共享、默认值常量),资金字段不降级、兜底必须带保鲜度、降级级别升降不对称。开关热切 1.2s 全链生效是保命开关,季度演练把兜底路径打进真流量。兜底命中与新鲜度打点进看板,兜底本身也要被监控——兜底数据腐烂比没有兜底更危险。下一篇 3.3 处理超时与重试,它们是降级的上游,超时没有兜住,降级就轮不到出场。
延伸阅读
- 降级模式的更多变体,见 go-micro-cps-guide。
- 降级事件与缓存状态进日志检索,见 rust-logpipe-guide。
- 兜底配置与灰度控制器联动,见 tencent-cloud-practice。
