降级(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) { 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); }
private List<String> buildGridKeys(double lng, double lat, int p) { double lngBase = floorTo(lng, p); double latBase = floorTo(lat, p); List<String> keys = new ArrayList<>(); for (int dx = -1; dx <= 2; dx++) { for (int dy = -1; dy <= 2; dy++) { keys.add(fmt(lngBase + dx * unit(p)) + ":" + fmt(latBase + dy * unit(p))); } } return keys; }
|
这个案例揭示了降级 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) { String uuid = "uuid:" + DigestUtils.md5Hex(serialize(result));
cache.set(uuid, result, TTL_1H);
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);
for (String key : keys) { cache.set(key, uuid, TTL_1H); } }
|
降级查找时多一步「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); if (uuid != null) { List<Shop> result = cache.get(uuid); if (result != null) return result; } } } 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 的分层写入):
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);
cache.set("search:" + keyword + ":" + grid5(lng, lat), uuid, TTL_10MIN); cache.set("search:" + keyword + ":" + grid4(lng, lat), uuid, TTL_30MIN); cache.set("search:" + keyword + ":" + grid3(lng, lat), uuid, TTL_1HOUR); }
|
一句话总结这一节:降级缓存不是「写进去就完了」,得回答三个问题——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 → 静态默认值 → 热门榜单 → 空占位,分层兜底。 |
| 降级怎么触发? |
自动(熔断/限流/异常触发)+ 人工(功能开关主动切)。日常靠自动,重大靠人工。 |
| 降级和限流的关系? |
限流是「拒绝请求」,降级是「被拒绝后给兜底」。两者配套:限流 + 降级 = 既控流量又保体验。 |
| 降级常见的坑? |
写假成功、降级调同一故障源、数据没预热、没演练、不告警。 |