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 处理超时与重试,它们是降级的上游,超时没有兜住,降级就轮不到出场。

延伸阅读