降级详解

降级(Degradation) 是高可用系统应对故障的最后一道体验防线:当熔断已经跳闸、限流已经拒绝、下游确实调不通时,降级决定「这一刻给用户看到什么」。它不解决问题,它管的是「出了问题之后,用户能不能接受」。

限流是「我主动控制流量」,熔断是「下游病了我不再打扰它」,降级是「出了岔子,先给用户一个能用的兜底」——三者合起来就是稳定性三板斧。建议和「限流详解」「熔断详解」对照着看,这篇是这三部曲的收尾。

本文脉络:
一 为什么需要降级(故障不可避免,但体验可以兜住)
二 降级的本质:用「有损」换「可用」
三 降级的触发时机(谁来喊降级)
四 三类降级:读降级 / 写降级 / 非核心降级
五 降级的数据与缓存设计(来源 / key 构建 / 内容去重 / 更新过期)
六 与熔断、限流、重试的协同(稳定性三板斧)
七 生产实践与常见陷阱
八 面试速答

一、为什么需要降级

分布式系统里有个残酷的事实:故障不是「会不会」的问题,是「什么时候」的问题。下游会挂、网络会抖、数据库会慢、第三方 API 会限流——这些你拦不住。

你能做的是两件事:

  • 尽量不让故障发生(限流、熔断、重试、集群、容灾)
  • 故障发生时,把对用户的伤害降到最低(降级)

没有降级会怎样

商品详情页调用「推荐服务」:
推荐服务挂了 → 接口超时

不降级:整个详情页 500,用户什么都看不到,直接流失
降级: 推荐位空着(或放热门商品),主体信息正常展示

用户照样能看商品、能下单,只是少了个推荐模块

核心区别:不降级是「一处故障,全盘不可用」;降级是「一处故障,主体功能照常」。前者用户骂街,后者用户可能根本没察觉。

降级 ≠ 兜底返回错误

很多人把降级理解为「报错了就给个默认值」,这只对了一半。降级的完整定义是:在系统出现异常或压力时,主动牺牲部分非核心功能或数据新鲜度,换取核心功能的可用性。

它有两种触发方式:

  • 自动降级:熔断打开、限流触发、超时、异常 → 框架自动走降级逻辑
  • 人工降级:运营/值班通过开关主动关掉某个非核心功能(大促前关推荐、关评论)

二、降级的本质:用「有损」换「可用」

降级的本质是一笔交易:承认「做不到 100% 完美」,但保证「核心可用」

正常状态:    全功能,数据最新,体验满分(100%)
↓ 故障发生
降级状态: 部分功能缺失 / 数据略旧 / 体验打折(60%~80%)

不降级状态: 要么报错白屏(0%),要么全站不可用(更糟)

关键认知:降级不是「坏掉」,是「有意识地选择放弃什么、保住什么」。一个设计良好的系统,在故障时应该「优雅降级」(graceful degradation),而不是「直接崩溃」。

「有损」的三个维度

降级可以有损,但有损的方式有讲究:

有损维度 做法 例子
功能有损 关掉非核心模块 推荐位、评论区、个性化内容先不展示
数据有损 返回旧数据/近似数据 缓存兜底(旧数据)、热门榜单兜底
性能有损 牺牲实时性,转异步 下单不实时返回,写 MQ 后提示「处理中」

底线:核心交易链路(下单、支付)绝不能「假成功」,宁可转异步延迟告知,也不能骗用户说成功了。


三、降级的触发时机(谁来喊降级)

降级本身不做判断,它听命于「谁来喊」。按触发方式,降级分两大类:自动降级(系统感知异常后自己走兜底)和手动降级(人通过开关主动切)。

┌──────────────────────────────────────────────────────┐
│ 自动降级 │
│ 服务出现异常时,自动返回降级结果 │
│ 核心价值:避免用户看到「无结果的空窗」(白屏/报错) │
├──────────────────────────────────────────────────────┤
│ 手动降级 │
│ 人通过降级开关主动切,支持按比例灰度 │
│ 核心价值:大促预案、故障应急,可控可灰度 │
└──────────────────────────────────────────────────────┘

3.1 自动降级:异常发生时无缝兜底

自动降级的核心目的就一个:服务出异常的瞬间,直接返回降级结果,让用户感受不到「无结果的空窗」

没有自动降级:
下游超时 → 用户等了几秒 → 看到 500 / 空数据 / 转圈圈 → 流失

有自动降级:
下游超时 → 框架立刻捕获 → 返回缓存/默认值 → 用户只感觉「今天有点慢」

自动降级由框架在请求链路里自动触发,主要听命于三个信号:

┌──────────────────────────────────────────────────────┐
│ ① 熔断触发 → 熔断器 Open,所有请求走 Fallback │
├──────────────────────────────────────────────────────┤
│ ② 限流触发 → 被限流的请求,返回兜底而非报错 │
├──────────────────────────────────────────────────────┤
│ ③ 异常/超时 → 调用失败、抛异常 → 走 Fallback │
└──────────────────────────────────────────────────────┘

它的特点是毫秒级、无人工介入——故障发生的那一瞬,用户就已经拿到了兜底数据。代价是它只能处理「框架能感知的异常」,且逻辑写错会全员降级。

3.2 手动降级:开关 + 比例灰度

手动降级不依赖异常信号,而是人主动决定「现在要降级」。它的杀手锏是支持按比例灰度——不是一关到底,而是 10% → 30% → 50% → 70% → 90% → 100% 逐步放量。

为什么需要「按比例」而不是「一刀切」:

大促前想把「推荐」降级,把算力让给交易主链路:
❌ 一上来就 100% 关推荐 → 突然所有用户看不到推荐,可能引发客诉
✅ 先 10% 用户降级 → 观察 QPS、成功率、客诉 → 没问题再放大
10% → 30% → 50% → 70% → 90% → 100%

故障应急时也同理:先小比例试水,确认兜底数据正常,再扩大范围

比例降级的实现:每个请求按 userId 哈希到一个 0~99 的桶,开关配置「降级比例 = N%」,落在前 N 个桶的用户走降级。

降级开关配置(配置中心动态推送,不重启):

config.degradation.recommendation.ratio = 10 # 10% 用户降级推荐
config.degradation.comment.ratio = 0 # 评论不降级
config.degradation.search.mode = SIMPLIFIED # 全量简化搜索

请求判定逻辑:
hash(userId) % 100 < ratio → 该用户走降级
否则 → 正常逻辑

例:ratio = 30,则 userId 哈希落在 0~29 桶的 30% 用户被降级
调到 50 → 立刻变成 50%,配置秒级生效

比例灰度的三个好处

好处 说明
风险可控 先小流量验证降级数据是否正常,出问题能迅速回退到低比例
观察窗口 每一级停留观察 QPS/错误率/客诉,确认无异常再放大
紧急兜底 真出大故障时,一条命令拉到 100%,秒级全量降级

3.3 自动 vs 手动:怎么配合

维度 自动降级 手动降级
触发 框架感知异常自动走 人通过开关/比例主动切
速度 毫秒级 秒级(配置推送即生效)
场景 单点故障、瞬时抖动、下游超时 大促预案、机房故障、有预期的大流量
粒度 全量(命中异常就降) 可按比例(10%~100%)灰度
风险 逻辑写错会全员降级 人判断失误(误降级核心功能)

实践:日常故障靠自动降级(快,无需人介入),重大事件/预案靠手动降级(可控,能灰度)。两者都要有——纯自动在边界 case 上可能误判,纯手动响应太慢、且没法应对瞬时抖动。最佳组合是:自动兜瞬时故障,手动管预案和灰度


四、三类降级:读降级 / 写降级 / 非核心降级

这是降级设计里最重要的一章,按请求类型分三类,每类的降级手法完全不同。

4.1 读降级(最好做)

读操作天然适合降级——返回旧一点、差一点的数据,用户大多能接受

商品详情页:
主信息(价格、库存、标题)→ 必须实时,不降级
推荐(猜你喜欢) → 降级:返回热门商品 / 空着
评论列表 → 降级:返回缓存的前 N 条 / "加载中"
个性化标签 → 降级:返回通用标签 / 不展示

用户体验:核心信息齐全,周边功能打折,照样能看能买。
读降级手段 做法 适用
缓存兜底 实时数据拿不到,返回缓存(即使过期) 详情页、列表
默认值 返回预设的合理默认值 配置类、标签类
热门兜底 个性化拿不到,返回热门/排行榜 推荐、广告
降级空 该模块直接不展示 评论区、侧边栏

读降级的安全感:数据「略旧」几乎不会有业务事故,所以读降级可以大胆做。

4.2 写降级(最要小心)

写操作(下单、支付、提交)绝对不能假装成功——这是降级最大的雷区。

❌ 错误写降级(致命):
下单接口调用库存服务失败 → 降级返回「下单成功」
→ 实际库存没扣、订单没建 → 资损 + 客诉灾难

✅ 正确写降级(转异步):
下单接口调用库存服务失败 → 写入 MQ → 提示「订单提交中,稍后查看」
→ 库存服务恢复后异步消费,真正建单扣库存
→ 即使失败也能通过对账发现并补偿
写降级手段 做法 风险
转异步 写 MQ,异步处理,先回「处理中」 低,有据可查可补偿
日志兜底 落本地日志/DB,稍后回放 中,需补偿机制
拒绝 直接告诉用户「稍后重试」 低,但体验差
❌ 假成功 返回成功但实际没做 致命,禁止使用

铁律:写降级可以「延迟告知」,可以「转异步」,但不能「无中生有」。宁可让用户等,也不能骗用户。

4.3 非核心降级(最常用)

把系统功能按重要性分级,故障时先砍非核心,保核心

功能重要性分级:
P0 核心:下单、支付、登录 → 死保,不降级
P1 重要:搜索、商品详情 → 尽量保,可轻度降级
P2 辅助:推荐、评论、个性化 → 优先降级对象
P3 边缘:积分、勋章、动态 → 第一时间砍掉

故障时的动作顺序(反着来):
压力大时,先降 P3 → 再降 P2 → 死保 P0/P1

大促前的典型人工降级清单:关推荐、关评论、关积分计算、搜索退化成简化版——把算力全部让给交易主链路。


五、降级的数据与缓存设计

降级要返回「有用的东西」,那这些东西从哪来?这是降级能否落地的关键。

降级数据来源(按新鲜度从高到低):

① 本地缓存(Caffeine/Guava) 最快,但容量小、重启丢失
② 分布式缓存(Redis) 跨实例共享,生产主流
③ 静态兜底数据(配置/代码里) 永远可用,但最粗
④ 热门/排行榜预计算 近似但稳定
⑤ 空/默认占位 最差,但不会报错

设计降级数据的两个原则

原则一:降级数据要「预热」,不能临阵磨枪

❌ 错误:故障时才去 Redis 拿缓存 → 缓存可能已过期/不存在
✅ 正常:正常时就维护好缓存,故障时直接返回(即使旧)

原则二:降级链要有兜底,不能一条道走到黑

实时数据拿不到 → 查 Redis → Redis 也挂 → 查本地缓存 → 本地没有 → 返回默认值

每一层都有兜底,绝不抛错给用户

5.1 降级缓存的 key 怎么构建(以 LBS 搜索为例)

降级发生在「实时调用失败」之后,这时你手上只有原始请求参数,所以缓存 key 必须能用请求参数现拼出来。这里有个生产里很典型的案例——类似商户团购这种强依赖 LBS(地理位置)的搜索服务

挑战:搜索请求带着精确经纬度(如 116.404, 39.915),每个用户的坐标都不同,如果直接拿原始经纬度做 key,缓存命中率几乎为零——降级时根本查不到东西。

解法:对原始经纬度保留不同的小数位数,再配合邻域偏移吸附到一组网格点上,实现「按精度逐级退化」的降级链。

以经度 116.404213221 为例,保留不同小数位:

保留位数 截断后的值 含义
──────── ────────────── ──────────────────
原始 116.404213221 用户真实坐标(每人不同,无法复用)
保留 5 位 116.40421 亚米级,仍很细
保留 4 位 116.4042 约几十米粒度
保留 3 位 116.404 约 100 米粒度
保留 2 位 116.40 约 1 公里粒度

关键:截断后还要做「邻域偏移」,不然命中率还是上不去。

为什么单纯截断不够:
116.404213221 截断 3 位 → 116.404
116.404877661 截断 3 位 → 116.404 ← 同一个 key,能命中 ✅
但 116.404500000 截断 3 位 → 116.404
116.405500000 截断 3 位 → 116.405 ← 相邻网格,key 不同,miss ❌
↑ 这俩实际只差几十米,却被分到两把 key

邻域偏移:保留 3 位时,不是只取一个 116.404,而是把相邻几个网格点都算上:
116.403, 116.404, 116.405, 116.406 ...

即对坐标做「向网格点吸附 + 取邻域」:
base = floor(lng, 3位) = 116.404
偏移集合 = { base-1, base, base+1, base+2 }
= { 116.403, 116.404, 116.405, 116.406 }

降级 key 用「keyword + 这些网格点」组合,命中范围一下扩大到 4 倍
纬度同理(39.915 的邻域:39.914, 39.915, 39.916, 39.917)

经纬度的邻域组合,构成一个「网格块」,落在同一块里的坐标都能命中同一把 key。

降级时的查找顺序——精度从高到低逐级退化:

请求:搜「火锅」,坐标 (116.40421, 39.91521)

正常:search:火锅:116.40421:39.91521 ← 原始精度(最贴合,命中率低)
↓ miss
降级1:保留 4 位 + 邻域偏移
search:火锅:116.4041:39.9151 (~116.4042:39.9152 ...)
↓ miss
降级2:保留 3 位 + 邻域偏移
search:火锅:116.403:39.914 (~116.404:39.915, 116.405:39.916 ...)
↓ miss
兜底:search:火锅:{cityId} ← 完全甩开经纬度,城市粒度的热门结果
↑ 与具体坐标无关,命中率最高,永远有数据

为什么这样设计有效

① 截断小数位 = 扩大粒度:保留位数越少,一个 key 覆盖的地理范围越大
② 邻域偏移 = 提升命中:避免坐标落在网格边界附近时「明明很近却 key 不同」,
把相邻几个网格点都纳入候选,命中率成倍提升
③ 城市粒度兜底是「保底中的保底」:它与经纬度完全无关,
一个城市只需要一份「火锅热门榜单」,几乎 100% 能命中
代价是结果可能离用户几公里,但「搜到了东西」远好过「白屏报错」

代码示意(降级查找链):

public List<Shop> searchFallback(String keyword, double lng, double lat, String cityId) {
// 从高精度到低精度逐级查,命中即返回
int[] precisions = {5, 4, 3, 2};
for (int p : precisions) {
// 保留 p 位 + 邻域偏移(±1 ~ +2),枚举网格点组合
for (String gridKey : buildGridKeys(lng, lat, p)) {
List<Shop> cached = cache.get("search:" + keyword + ":" + gridKey);
if (cached != null) return cached; // 退化到更粗的精度,但能用
}
}
// 最终兜底:城市粒度,与坐标无关
return cache.get("search:" + keyword + ":" + cityId);
}

// 保留 p 位小数 + 邻域偏移,生成一组网格 key(经纬度组合)
private List<String> buildGridKeys(double lng, double lat, int p) {
double lngBase = floorTo(lng, p); // 116.40421 → p=3 → 116.404
double latBase = floorTo(lat, p);
List<String> keys = new ArrayList<>();
for (int dx = -1; dx <= 2; dx++) { // 邻域偏移:base-1, base, base+1, base+2
for (int dy = -1; dy <= 2; dy++) {
keys.add(fmt(lngBase + dx * unit(p)) + ":" + fmt(latBase + dy * unit(p)));
}
}
return keys; // 如 p=3 时:116.403:39.914, 116.403:39.915, 116.404:39.915 ...
}

这个案例揭示了降级 key 设计的核心思想:降级不是「换一把 key」,而是「准备一条逐级退化的 key 链」。每一级牺牲一点精度、扩大一点邻域,换来更高的命中率和更强的兜底能力——从「精确到亚米」一路退到「全城热门」,总有一级能兜住。

5.2 降级缓存的内容去重:用 UUID 做内容指纹

5.1 的方案有个存储上的浪费:不同粒度的 key,背后的结果数据可能完全一样

一个用户在「国贸商圈」搜「火锅」,坐标 116.40421, 39.91521:

search:火锅:116.40421:39.91521 → [历史结果] ← 保留 5 位(亚米级)
search:火锅:116.4042:39.9152 → [历史结果] ← 保留 4 位(几十米)
search:火锅:116.404:39.915 → [历史结果] ← 保留 3 位(约百米)

国贸商圈就这么大,三个粒度的结果其实一模一样。
但如果每把 key 都存一份完整的店铺列表 → 同一份数据存了 3 份 → 内存浪费
(再加上邻域偏移产生的多个网格 key,重复更严重)

流量越大、粒度组合越多,这种重复存储越严重(同一个城市可能有上万种 keyword × 经纬度粒度 × 邻域 组合,但去重后的真实结果集小得多)。

优化思路:把「逻辑 key」和「结果数据」拆成两层,中间用 UUID(内容指纹)做映射

写入时(一次请求构建多个粒度的 key):

真实结果 [历史结果]
↓ 计算内容指纹
uuid = hash([搜索结]) = "r7k3x9"
↓ 多个逻辑 key 都指向同一个 uuid
search:火锅:116.40421:39.91521 ──┐
search:火锅:116.4042:39.9152 ──┼──► uuid:r7k3x9 ──► [历史结果]
search:火锅:116.404:39.915 ──┘ (映射层) (真实结果,只存一份)

读取(降级)时:
逻辑 key → 查映射拿到 uuid → 用 uuid 查真实结果

为什么用 UUID 当内容指纹

① 内容相同 → 指纹相同:结果集 [店A,店B,店C] 无论被多少把 key 命中,
算出来的 uuid 都是同一个,天然就完成了去重

② 多 key 共享一份存储:上万把逻辑 key 可以共用几十个 uuid,
存储从「O(key 数量 × 结果大小)」降到「O(真实结果集数量 × 结果大小)」

③ 结果更新只动一处:商家上下架导致结果变了,只需写一份新的 uuid+结果,
所有指向它的逻辑 key 自动生效(或更新映射)

代码示意(写入时的多粒度 key → UUID 映射):

public void cacheSearchResult(String keyword,
double lng, double lat, String cityId,
List<Shop> result) {
// 1. 计算结果的内容指纹
String uuid = "uuid:" + DigestUtils.md5Hex(serialize(result));

// 2. 真实结果只存一份(uuid → 结果)
cache.set(uuid, result, TTL_1H);

// 3. 一次请求,构建多个粒度的逻辑 key,全部指向同一个 uuid
List<String> keys = new ArrayList<>();
for (int p : new int[]{5, 4, 3, 2}) { // 保留不同小数位
for (String gridKey : buildGridKeys(lng, lat, p)) { // 含邻域偏移
keys.add("search:" + keyword + ":" + gridKey);
}
}
keys.add("search:" + keyword + ":" + cityId); // 城市兜底 key

for (String key : keys) {
cache.set(key, uuid, TTL_1H); // 逻辑 key → uuid(映射层,体积极小)
}
}

降级查找时多一步「key → uuid → 结果」的二次查询:

public List<Shop> searchFallback(String keyword, double lng, double lat, String cityId) {
int[] precisions = {5, 4, 3, 2};
for (int p : precisions) {
for (String gridKey : buildGridKeys(lng, lat, p)) { // 含邻域偏移
String logicKey = "search:" + keyword + ":" + gridKey;
String uuid = cache.get(logicKey); // ① 逻辑 key → uuid
if (uuid != null) {
List<Shop> result = cache.get(uuid); // ② uuid → 真实结果
if (result != null) return result;
}
}
}
// 城市粒度兜底(同样走 uuid 映射)
String uuid = cache.get("search:" + keyword + ":" + cityId);
return uuid != null ? cache.get(uuid) : Collections.emptyList();
}

这个设计划算在哪

维度 直接存结果(5.1) UUID 指纹去重(5.2)
存储占用 每把 key 存完整结果,重复多份 真实结果只存一份,key 只存 uuid
写入开销 每个 key 写一份大数据 多 key 共享,写入量骤减
结果更新 要找到所有相关 key 逐个改 改一处 uuid 对应的结果即可
查询开销 一次查询 两次查询(key→uuid→结果)

权衡点:用「多一次缓存查询」换「大幅降低存储」。当结果数据较大(如店铺列表带各种字段)、且粒度组合爆炸时,这个 trade-off 非常划算——这也是大厂 LBS/搜索类降级缓存的常见做法。如果结果本身很小(就几个 ID),去重的收益不大,直接存反而更简单。

5.3 降级数据的更新和过期机制

前面两节解决了「key 怎么建」「数据怎么存」,但还有个绕不开的问题:降级缓存里的数据,会不会过期?什么时候刷新? 这直接决定了降级时返回的数据「能不能用」。

降级缓存和数据新鲜度天生有矛盾:

降级要求数据「尽量新」  →  故障兜底时,给用户的应该是较新的结果
缓存天然会让数据「变旧」 → 存进缓存那一刻就开始过时
故障可能持续很久 → 几小时都恢复不了,靠的全程是缓存里的旧数据

所以必须设计「过期 + 更新」机制,让缓存数据在「新鲜度」和「可用性」之间取得平衡。

过期策略:TTL 设多长

降级缓存一般用 TTL(Time To Live) 控制过期,但 TTL 的取值和数据类型强相关:

数据类型 建议 TTL 理由
强时效(库存、价格、活动状态) 几十秒~几分钟 过期了宁可降级到默认值,也别返回错误库存
一般业务(商品详情、搜索结果) 几十分钟~几小时 略旧可接受,故障时返回昨天的商品信息也无伤大雅
准静态(分类、配置、热门榜单) 几小时~一天 变化慢,TTL 可以长,缓存命中率最高
城市兜底(全城热门) 一天或更长 本身就是「最粗的兜底」,旧一点完全 OK

一个反直觉的点:降级缓存的 TTL 通常要比「加速缓存」更长

加速缓存(为性能):TTL 短(如 30s),追求新鲜,过期了重新查 DB
降级缓存(为兜底):TTL 长(如 1~6h),追求「故障时有东西可给」

为什么?降级缓存的核心目的是「实时挂掉时还能返回数据」。
如果 TTL 太短,故障发生时缓存可能早就过期了 —— 你想兜底,却发现没数据可兜。
宁可返回「几小时前的旧数据」,也比「白屏报错」强。

但 TTL 也不能无限长,否则数据陈旧到误导用户(比如返回了一家已停业的店)。所以 TTL 要配「更新机制」一起设计。

更新策略:谁来刷新缓存

缓存不会自己变新,得有人刷新。主流有四种方式,各有取舍:

┌─────────────────────────────────────────────────────────────┐
│ ① 写时更新(Write-through / 顺手预热) │
│ 正常请求成功 → 顺手把结果写进降级缓存 │
│ 优点:零额外成本,数据天然新鲜 │
│ 缺点:冷启动时没数据;低频 key 长期不会被写 │
├─────────────────────────────────────────────────────────────┤
│ ② 定时刷新(后台任务批量预热) │
│ 定时任务定期查 DB / 算热门榜,批量回写缓存 │
│ 优点:覆盖冷门 key;故障前数据始终新鲜 │
│ 缺点:消耗 DB/算力;要选准预热哪些 key(全量不现实) │
├─────────────────────────────────────────────────────────────┤
│ ③ 事件驱动(数据变更时主动失效/更新) │
│ 商家上下架 → 发 MQ → 消费者更新对应缓存 │
│ 优点:准实时,数据最新 │
│ 缺点:架构复杂;消息丢失会不一致 │
├─────────────────────────────────────────────────────────────┤
│ ④ 懒加载(读时 miss 再回源) │
│ 读缓存 miss → 回源查 DB → 回写缓存 │
│ 优点:实现简单,按需加载 │
│ 缺点:降级场景下「回源」可能也挂了 → 失效 │
├─────────────────────────────────────────────────────────────┤
│ ⚠️ 注意:④ 在降级场景里常常不适用—— │
│ 降级发生正是因为「回源链路挂了」,懒加载回源只会一起失败 │
│ 所以降级缓存主要靠 ①②③ 提前把数据备好,而非 ④ 临场回源 │
└─────────────────────────────────────────────────────────────┘

生产实践:组合使用,互为补充

正常流量下:
① 写时更新 → 高频 key 自然新鲜(主路径成功就预热)
② 定时刷新 → 兜底低频 key + 准静态数据(如城市热门榜单每小时刷一次)

数据变更时:
③ 事件驱动 → 关键数据准实时更新(如商品下架立即失效缓存)

故障期间:
④ 懒加载 退场 → 因为回源链路就是挂的那个,靠 ①②③ 早就备好的数据兜底

一个关键设计:分层 TTL + 兜底链

结合 5.1 的精度退化链,不同层级的缓存用不同 TTL,构成一条「越往下越旧但越稳定」的兜底链:

正常路径(实时)           TTL = 0(永远现算)
↓ 失败
高精度降级缓存 TTL = 10 分钟 ← 最新,但覆盖范围小
↓ miss
中/低精度降级缓存 TTL = 1 小时 ← 略旧,命中率高
↓ miss
城市兜底缓存 TTL = 1 天 ← 最旧,但几乎 100% 命中
↓ miss
静态默认值(代码里写死) TTL = ∞ ← 永不过期,最后防线

为什么越往下 TTL 越长:低精度/兜底层的数据变化慢(城市热门榜单一天变不了多少),而且它是「最后防线」,必须保证故障时有数据——TTL 长 = 命中率高 = 不容易 miss。高精度层 TTL 短,是为了保证「正常降级时返回的数据不至于太旧」。

设计口诀:
高精度层:TTL 短,数据新,但容易 miss
低精度层:TTL 长,数据旧,但几乎必中
静态兜底:不过期,永远在,是最后的尊严

代码示意(带 TTL 的分层写入):

// 不同精度 + 不同 TTL 的分层预热
public void warmCache(String keyword, double lng, double lat, String cityId) {
List<Shop> realtime = searchService.query(keyword, lng, lat); // 实时算
String uuid = "uuid:" + md5(realtime);

cache.set(uuid, realtime, TTL_10MIN); // 结果 10 分钟

cache.set("search:" + keyword + ":" + grid5(lng, lat), uuid, TTL_10MIN); // 高精度,短 TTL
cache.set("search:" + keyword + ":" + grid4(lng, lat), uuid, TTL_30MIN); // 中精度
cache.set("search:" + keyword + ":" + grid3(lng, lat), uuid, TTL_1HOUR); // 低精度,长 TTL
// 城市兜底单独预热(定时任务刷,不依赖单次请求)
// cache.set("search:" + keyword + ":" + cityId, cityHotUuid, TTL_1DAY);
}

一句话总结这一节:降级缓存不是「写进去就完了」,得回答三个问题——TTL 多长(数据能旧到什么程度)、谁来刷新(怎么保持新鲜)、分层怎么配 TTL(兜底链越往下越稳)。设计好了,故障时给用户的是「昨天的正确数据」;设计不好,给的是「空」或「错」。


六、与熔断、限流、重试的协同(稳定性三板斧)

这一节和「限流详解」第八章、「熔断详解」第七章呼应,三篇对着看更清楚。

6.1 四者的职责分工

┌──────────────────────────────────────────────────────┐
│ 限流(Rate Limiting) │
│ 主动控制「进来多少」——看流量 │
├──────────────────────────────────────────────────────┤
│ 熔断(Circuit Breaker) │
│ 被动响应「下游挂没挂」——看错误率 │
├──────────────────────────────────────────────────────┤
│ 降级(Degradation) ← 本文主角 │
│ 「挂了/限了/断了之后给什么」——看兜底数据 │
├──────────────────────────────────────────────────────┤
│ 重试(Retry) │
│ 「失败了再试一次」——看瞬时抖动 │
└──────────────────────────────────────────────────────┘

6.2 降级是「结果」,熔断/限流是「原因」

很多人分不清降级和熔断的关系,其实一句话:熔断和限流是「触发条件」,降级是「触发后的处理动作」

正常调用链:
请求 → 限流检查 → 调下游 → 成功返回

下游故障?

熔断打开 → 快速失败 → 【降级】返回兜底

或者:限流触发 → 拒绝 → 【降级】返回兜底

所以:
熔断/限流决定「要不要调」
降级决定「不调的话,给用户什么」

6.3 一句话区分四个概念

概念 一句话 看什么
限流 我主动控制流量 流量/QPS
熔断 下游病了我不再打扰它 错误率/超时率
降级 出了岔子先给个能用的兜底 兜底数据
重试 失败了再试一次,但要有节制 瞬时抖动

七、生产实践与常见陷阱

7.1 最佳实践

① 降级数据要预热:正常时就维护好缓存/默认值,别等故障了才发现没数据可降
② 降级链要分层:实时 → Redis → 本地 → 默认值,每层都有兜底
③ 写操作慎降级:宁可转异步,绝不假成功
④ 降级要可监控:降级触发量激增是重要告警,别让故障「静默」
⑤ 开关要可控:人工降级的开关要支持动态调整、灰度切换
⑥ 演练:定期做降级演练,别等真故障才发现降级逻辑写错了

7.2 常见陷阱

陷阱 后果 对策
写降级假成功 资损、客诉、数据不一致 写操作转异步,绝不返回假成功
降级调用同一故障源 降级也失败,等于没降级 降级走独立数据源(缓存/本地)
降级数据没预热 故障时缓存为空,无数据可降 正常时就维护缓存,定期刷新
降级链无兜底 Redis 也挂时直接抛错 每层都有 fallback,最终有默认值
降级逻辑写错没演练 真故障时降级不生效或返回错数据 定期混沌演练,验证降级链
降级不告警 故障被「静默掩盖」 降级触发量纳入监控告警

八、面试速答

问题 速答要点
降级是什么? 故障/压力时,主动牺牲非核心功能或数据新鲜度,保核心可用。本质是「有损换可用」。
降级和熔断的区别? 熔断是「触发条件」(看错误率),降级是「触发后的处理」(给兜底数据)。熔断/限流决定「调不调」,降级决定「不调给什么」。
降级分哪几类? 读降级(返回缓存/默认值)、写降级(转异步)、非核心降级(关掉 P2/P3 功能保 P0)。
写操作怎么降级? 转 MQ 异步处理,提示「处理中」;绝不假成功。宁让用户等,不骗用户。
降级数据从哪来? 本地缓存 → Redis → 静态默认值 → 热门榜单 → 空占位,分层兜底。
降级怎么触发? 自动(熔断/限流/异常触发)+ 人工(功能开关主动切)。日常靠自动,重大靠人工。
降级和限流的关系? 限流是「拒绝请求」,降级是「被拒绝后给兜底」。两者配套:限流 + 降级 = 既控流量又保体验。
降级常见的坑? 写假成功、降级调同一故障源、数据没预热、没演练、不告警。