学习目标
学完本章你应该能够:
- 用一句话区分熔断、降级、限流三者,并说清它们为什么通常要配合使用。
- 讲出服务降级的核心思想——“宁可返回不完美的结果,也不要让用户看到错误”。
- 在 Kitex 客户端用 Middleware 实现声明式降级,把降级逻辑按方法名注册并统一拦截。
- 把熔断和降级联动起来:熔断器打开时自动走兜底,而不是把错误甩给用户。
- 针对读多写少场景,用本地缓存降级给出实战可用的兜底方案,并说清它的时效性与坑。
前置知识:
- 了解 Kitex 客户端的基本用法(
client.WithMiddleware、RPC 调用)。 - 知道什么是 Middleware(中间件):在真实调用前后插入一段逻辑。
- 已看过熔断、限流相关笔记更好,但不强制(本章会从"熔断是手段、降级是目的"讲起)。
本章你会动手做的事:
- 写一个
FallbackProvider,给GetUserInfo、QueryProductList注册各自的兜底函数。 - 把熔断 Middleware 和降级 Middleware 串到同一个 client 上,模拟下游故障时走兜底。
- 用本地缓存包一层
CachedClient,让远程调用超时后返回旧数据而不是报错。
概述
服务降级(Fallback / Degradation) 是指当服务依赖的下游出现故障、超时或熔断器打开时,不再执行正常的业务逻辑,而是返回一个预设的兜底结果,以保证核心链路的可用性。
降级的核心思想:宁可返回不完美的结果,也不要让用户看到错误。
类比:你去餐厅点招牌菜,后厨偏偏停气做不了。普通处理是直接告诉你"做不了,您走吧"(报错);降级则是服务员说"招牌菜暂时做不了,我送您一份免费的例汤和米饭(兜底数据),您先吃着别饿着"。菜不完美,但你不至于空着肚子离开——体验保住了,核心链路(别让用户崩)保住了。
与熔断的区别:
- 熔断关注的是"切断调用,防止故障扩散"
- 降级关注的是"返回兜底结果,保证用户体验"
两者通常配合使用:熔断是手段,降级是目的。
请求进入
↓
┌──────────────┐
│ 熔断检查 │── 熔断打开 ──→ 进入降级逻辑
└──────┬───────┘
↓ 允许
┌──────────────┐
│ 正常调用 │── 成功 ──→ 返回正常结果
└──────┬───────┘
↓ 失败/超时
┌──────────────┐
│ 降级逻辑 │── 返回兜底数据
└──────────────┘
下面用一张流程图把"请求 → 熔断检查 → 正常调用 → 失败兜底"的完整分支画清楚:
flowchart TD
Req[请求进入] --> CB{熔断检查}
CB -->|熔断打开| F[降级逻辑
返回兜底结果]
CB -->|允许通过| Call[正常调用下游]
Call -->|成功| Ok[返回正常结果]
Call -->|失败或超时| F⚠️ 新手必踩的坑:把降级当成"万能药"。降级数据大概率是旧数据或假数据,和实时状态不一致。如果直接返回却不告诉用户"这是兜底数据",用户可能基于错误信息做决策(比如看到旧库存下单)。所以降级数据一定要标注来源和时间(见后文"降级数据时效性标注")。
降级策略分类
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 默认值降级 | 返回固定的默认值或空值 | 列表查询、配置读取 |
| 缓存降级 | 返回本地/远程缓存中的旧数据 | 商品详情、用户信息 |
| Mock 降级 | 返回模拟数据 | 非核心功能、开发测试环境 |
| 页面降级 | 返回简化版页面或静态页 | 前端展示类服务 |
| 组合降级 | 多级降级:实时→缓存→默认值 | 高可用要求高的核心链路 |
白话:降级不是只有一种姿势。能拿到旧数据就返回旧数据(缓存降级),旧数据也没有就返回"暂无/默认值"(默认值降级),实在不行连个友好页面都行(页面降级)。组合降级就是一条退路链:实时拿不到就退到缓存,缓存也没有就退到默认值,层层兜底,永远给用户一个"还能用"的结果。
flowchart TD
R[请求] --> RT[实时数据]
RT -->|成功| Ret[返回实时结果]
RT -->|失败| C[缓存旧数据]
C -->|命中| Ret2[返回缓存结果]
C -->|未命中| D[默认值]
D --> Ret3[返回默认值
保证可用]方案一:Middleware 实现通用降级
在 Kitex 客户端通过 Middleware 统一拦截,实现声明式降级。
降级处理器接口
package fallback
import (
"context"
)
// FallbackFunc 降级函数签名
type FallbackFunc func(ctx context.Context, req, err error) (resp interface{}, fallbackErr error)
// FallbackProvider 降级提供者
type FallbackProvider struct {
funcMap map[string]FallbackFunc // 按方法名映射降级函数
}
// NewFallbackProvider 创建降级提供者
func NewFallbackProvider() *FallbackProvider {
return &FallbackProvider{
funcMap: make(map[string]FallbackFunc),
}
}
// Register 注册特定方法的降级逻辑
func (fp *FallbackProvider) Register(method string, fn FallbackFunc) {
fp.funcMap[method] = fn
}
// Get 获取指定方法的降级函数
func (fp *FallbackProvider) Get(method string) FallbackFunc {
return fp.funcMap[method]
}
降级 Middleware
func FallbackMW(fp *FallbackProvider) endpoint.Middleware {
return func(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, req, resp interface{}) (err error) {
// 步骤 1:执行正常调用
err = next(ctx, req, resp)
// 步骤 2:如果调用成功,直接返回
if err == nil {
return nil
}
// 步骤 3:提取方法名
method := getMethodName(ctx)
// 步骤 4:查找是否有对应的降级逻辑
fallbackFn := fp.Get(method)
if fallbackFn == nil {
// 步骤 5:没有降级逻辑,向上返回错误
return err
}
// 步骤 6:执行降级
fallbackResp, fallbackErr := fallbackFn(ctx, req, err)
if fallbackErr != nil {
// 降级也失败了,返回原始错误
return err
}
// 步骤 7:将降级结果写回 resp(需要类型断言)
if respVal, ok := resp.(interface{ Reset() }); ok {
respVal.Reset()
}
// 具体写入方式取决于 RPC 框架的 resp 结构
_ = fallbackResp
return nil
}
}
}
使用示例
// 步骤 1:创建降级提供者
fallbackProvider := NewFallbackProvider()
// 步骤 2:注册降级逻辑
fallbackProvider.Register("GetUserInfo", func(ctx context.Context, req interface{}, err error) (interface{}, error) {
// 从本地缓存读取用户信息
cacheKey := req.(*GetUserInfoRequest).UserId
userInfo := getUserInfoFromCache(cacheKey)
if userInfo != nil {
return &GetUserInfoResponse{
User: userInfo,
Fallback: true, // 标记为降级数据
}, nil
}
// 缓存也没有,返回默认值
return &GetUserInfoResponse{
User: &UserInfo{
Name: "未知用户",
},
Fallback: true,
}, nil
})
fallbackProvider.Register("QueryProductList", func(ctx context.Context, req interface{}, err error) (interface{}, error) {
// 返回缓存的商品列表
list := getProductListFromCache()
return &QueryProductListResponse{
Products: list,
Fallback: true,
}, nil
})
// 步骤 3:注入客户端
client, err := shop.NewClient(
"dqq.shop",
client.WithMiddleware(FallbackMW(fallbackProvider)),
client.WithMiddleware(CircuitBreakerMW(cb)), // 与熔断配合
client.WithRPCTimeout(200*time.Millisecond),
)
方案二:熔断 + 降级联动
这是最常见的生产实践:熔断器打开时自动走降级逻辑。
白话:熔断和降级是一对好搭档。熔断负责"判断是否危险"(下游错误率太高,先别打了),降级负责"危险时给什么"(返回兜底)。把两者串起来,效果就是:下游挂了 → 熔断打开 → 自动返回兜底数据,用户全程无感。
func CircuitBreakerWithFallback(cb *CircuitBreaker, fp *FallbackProvider) endpoint.Middleware {
return func(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, req, resp interface{}) (err error) {
// 步骤 1:熔断检查
if !cb.AllowRequest() {
// 熔断打开,走降级
method := getMethodName(ctx)
fallbackFn := fp.Get(method)
if fallbackFn != nil {
fallbackResp, _ := fallbackFn(ctx, req, ErrCircuitBreakerOpen)
if respVal, ok := resp.(interface{ Reset() }); ok {
respVal.Reset()
}
_ = fallbackResp
return nil
}
// 无降级逻辑,返回熔断错误
return ErrCircuitBreakerOpen
}
// 步骤 2:正常调用
err = next(ctx, req, resp)
if err != nil {
cb.RecordFailure()
} else {
cb.RecordSuccess()
}
// 步骤 3:调用失败且存在降级逻辑
if err != nil {
method := getMethodName(ctx)
fallbackFn := fp.Get(method)
if fallbackFn != nil {
fallbackResp, fallbackErr := fallbackFn(ctx, req, err)
if fallbackErr == nil {
if respVal, ok := resp.(interface{ Reset() }); ok {
respVal.Reset()
}
_ = fallbackResp
return nil
}
}
}
return err
}
}
}
下面用一张时序图把"熔断打开 / 允许但失败"两种分支画清楚:
sequenceDiagram
participant C as 调用方
participant CB as 熔断器
participant D as 下游服务
participant F as 降级逻辑
C->>CB: 发起请求
alt 熔断打开
CB-->>F: 直接走降级
F-->>C: 返回兜底数据
else 允许调用
C->>D: 正常调用下游
D-->>C: 调用失败
C->>F: 存在降级逻辑
F-->>C: 返回兜底数据
end方案三:Sentinel 降级规则
如果使用 Apache Sentinel,可以通过规则配置实现声明式降级:
import (
"github.com/alibaba/sentinel-golang/core/degrade"
)
// 降级规则:当错误率达到阈值时触发降级
rule := degrade.Rule{
Resource: "dqq.shop/Service.GetUserInfo",
Strategy: degrade.StrategyErrorRatio,
Threshold: 0.5, // 错误率 50%
MinRequestNumber: 10, // 最少 10 个请求
StatIntervalMs: 10000, // 统计窗口 10s
RestoreTimeoutMs: 30000, // 降级恢复时间 30s
}
degrade.PutRule(rule)
// Sentinel 支持三种降级策略:
// StrategyExceptionCount — 异常数阈值
// StrategyErrorRatio — 异常比率阈值
// StrategySlowRequestRatio — 慢调用比率阈值
⚠️ 新手必踩的坑:阈值配得太松或太紧都不行。
MinRequestNumber设太小(比如 2),偶尔两次失败就触发降级,正常波动也会被误伤;RestoreTimeoutMs设太短,下游还没恢复就放流量,又是一波失败。生产上要结合真实流量调,并配好监控观察触发频率。
Sentinel 配合 FallbackFunction 可以实现更细粒度的降级控制:
// Sentinel 的 FlowProtection 支持 fallback 回调
sentinel.SeatProtection("resource_name",
sentinel.WithFallback(func(ctx context.Context, err error) {
// 降级逻辑
return fallbackResponse, nil
}),
)
方案四:本地缓存降级(实战常用)
对于读多写少的场景,本地缓存是最实用的降级手段:
类比:本地缓存降级就像收银台旁边放一本"常用商品价格手写本"。正常时系统里有实时价;系统卡了查不到,店员翻翻手边的小本子(本地缓存)也能报个价——可能不是最新的,但交易能继续。比直接跟顾客说"系统挂了算不了账"强太多。
type CachedClient struct {
inner Client // 真实的 Kitex 客户端
cache *localcache.Cache // 本地缓存
ttl time.Duration
stats *FallbackStats // 降级统计
}
func (cc *CachedClient) GetUserInfo(ctx context.Context, req *GetUserInfoRequest) (*GetUserInfoResponse, error) {
// 步骤 1:先查本地缓存
if cached := cc.cache.Get(req.UserId); cached != nil {
cc.stats.IncCacheHit()
resp := cached.(*GetUserInfoResponse)
resp.Fallback = true
return resp, nil
}
// 步骤 2:缓存未命中,调用远程服务
resp, err := cc.inner.GetUserInfo(ctx, req)
if err != nil {
cc.stats.IncFallback()
// 步骤 3:远程调用失败,返回缓存的旧数据(如果有的话)
if cached := cc.cache.Get(req.UserId); cached != nil {
resp := cached.(*GetUserInfoResponse)
resp.Fallback = true
return resp, nil
}
return nil, err
}
// 步骤 4:成功则写入缓存
cc.cache.Set(req.UserId, resp, cc.ttl)
return resp, nil
}
降级标记与可观测性
降级数据应当与正常数据区分,以便排查问题和监控:
响应中标记降级
type BaseResponse struct {
Fallback bool `json:"fallback,omitempty"` // 是否为降级数据
FallbackReason string `json:"fallback_reason,omitempty"` // 降级原因
}
// 在降级函数中设置
return &GetUserInfoResponse{
BaseResponse: BaseResponse{
Fallback: true,
FallbackReason: "downstream_timeout",
},
User: userInfo,
}, nil
降级指标上报
type FallbackStats struct {
totalRequests prometheus.Counter
fallbackCalls prometheus.Counter
cacheHits prometheus.Counter
fallbackErrors prometheus.Counter
}
func (fs *FallbackStats) IncFallback() {
fs.fallbackCalls.Inc()
}
func (fs *FallbackStats) IncCacheHit() {
fs.cacheHits.Inc()
}
// 在 Middleware 中集成
func FallbackWithMetricsMW(fp *FallbackProvider, stats *FallbackStats) endpoint.Middleware {
return func(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, req, resp interface{}) (err error) {
err = next(ctx, req, resp)
if err != nil {
method := getMethodName(ctx)
fallbackFn := fp.Get(method)
if fallbackFn != nil {
stats.IncFallback()
_, fallbackErr := fallbackFn(ctx, req, err)
if fallbackErr != nil {
stats.IncFallbackErrors()
}
}
}
return err
}
}
}
降级 vs 熔断 vs 限流
| 机制 | 位置 | 触发条件 | 行为 | 目的 |
|---|---|---|---|---|
| 限流 | 服务端 | QPS/并发超限 | 拒绝新请求 | 保护自身 |
| 熔断 | 客户端 | 下游错误率过高 | 切断调用,快速失败 | 防止故障扩散 |
| 降级 | 客户端 | 熔断打开 / 调用失败 | 返回兜底结果 | 保证用户体验 |
三者协作关系:
限流(服务端) 熔断(客户端) 降级(客户端)
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 保护自身 │ │ 切断调用 │ │ 兜底响应 │
│ 拒绝请求 │──────→ │ 快速失败 │──────→ │ 返回默认 │
│ │ │ │ │ 数据 │
└──────────┘ └──────────┘ └──────────┘
第一道防线 第二道防线 最后一道防线
白话:这三道防线的顺序是"由外到内、层层兜底"。限流是家门口的保安,流量太大直接拦在门外(保护服务端自己);熔断是发现下游邻居着火了,先把自己和邻居之间的门焊死(防止故障蔓延);降级是门焊死了但你还想给客人点东西,于是端出备用方案(保证体验)。三道防线缺一不可。
flowchart LR
L[限流
服务端 保护自身
拒绝请求] --> B[熔断
客户端 防止扩散
快速失败] --> F[降级
客户端 保证体验
返回兜底]最佳实践
1. 分级降级策略
按业务重要性分层设计降级方案:
| 级别 | 策略 | 示例 |
|---|---|---|
| P0 核心 | 实时 → 缓存 → 默认值 → 友好提示 | 用户登录、支付 |
| P1 重要 | 实时 → 缓存 → 默认值 | 商品列表、订单查询 |
| P2 一般 | 实时 → 缓存 → 跳过 | 推荐、评论 |
| P3 边缘 | 直接跳过 | 消息通知、日志上报 |
2. 降级数据时效性标注
所有降级数据必须标注来源和时间,避免误导用户:
type UserInfoResponse struct {
User *UserInfo `json:"user"`
Fallback bool `json:"fallback"`
CacheTime string `json:"cache_time,omitempty"` // 缓存时间
DataAge string `json:"data_age,omitempty"` // 数据距今多久
}
3. 降级开关
通过配置中心控制降级开关,便于紧急场景手动触发:
var fallbackEnabled atomic.Bool
func init() {
fallbackEnabled.Store(true) // 默认开启
// 监听配置中心变更
config.Watch("fallback.enabled", func(val bool) {
fallbackEnabled.Store(val)
})
}
func FallbackMW(fp *FallbackProvider) endpoint.Middleware {
return func(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, req, resp interface{}) (err error) {
err = next(ctx, req, resp)
if err == nil {
return nil
}
if !fallbackEnabled.Load() {
return err // 降级关闭,直接返回错误
}
// ... 降级逻辑
}
}
}
4. 避免降级雪崩
- 降级逻辑本身也要加超时和限流,避免降级函数阻塞
- 降级返回的数据应尽可能轻量,避免二次调用重型依赖
- 定期评估降级覆盖率,确保核心链路都有兜底方案
5. 灰度降级
对部分用户开启降级,观察效果后再全量:
func FallbackWithGrayMW(fp *FallbackProvider, grayRate float64) endpoint.Middleware {
return func(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, req, resp interface{}) (err error) {
err = next(ctx, req, resp)
if err == nil {
return nil
}
// 按用户 ID 哈希决定是否走降级
userID := extractUserID(req)
if hash(userID)%100 >= int(grayRate*100) {
return err // 不走降级,返回真实错误
}
// 走降级逻辑
fallbackFn := fp.Get(getMethodName(ctx))
if fallbackFn != nil {
fallbackResp, _ := fallbackFn(ctx, req, err)
_ = fallbackResp
}
return nil
}
}
}
⚠️ 新手必踩的坑:降级函数里又调了远程服务。降级本是为了"下游挂了也能返回",结果你的兜底逻辑去查另一个远程服务,那个服务也挂了 → 降级本身失败 → 连锁故障。所以降级逻辑必须简单、本地、可快速返回,最好只读本地缓存或返回常量。
注意事项
- 降级不是万能药:降级数据可能与实时数据不一致,需告知用户
- 降级逻辑要简单:避免在降级函数中调用其他远程服务,否则可能引发连锁故障
- 注意缓存穿透:降级返回的缓存数据可能是空的,需设置合理的 TTL
- 监控覆盖率:统计降级触发次数和比例,及时发现异常
- 测试降级场景:通过混沌工程模拟下游故障,验证降级是否生效
相关笔记
- [[Kitex/熔断]] — 熔断是降级的前置条件,两者通常配合使用
- [[Kitex/限流]] — 限流保护服务端,降级保护客户端体验
- [[Kitex/超时]] — 超时会触发降级逻辑
- [[Kitex/中间件]] — 降级通过 Middleware 机制嵌入调用链
- [[Kitex/重试]] — 重试失败后再走降级
自测题与动手练习
自测题(合上书能答出来,才算懂):
- 用一句话区分熔断、降级、限流,并各举一个"它解决什么"的例子。
- 服务降级的核心思想是什么?为什么说"降级是目的、熔断是手段"?
FallbackProvider.Register("GetUserInfo", ...)这一步在做什么?Middleware 是怎么在调用失败时用上它的?- 方案二中,熔断器"打开"和"允许但下游调用失败"两种情况下,分别怎么走降级?
- 本地缓存降级(方案四)和默认值降级相比,各适合什么场景?它的主要风险是什么?
动手练习(建议真做一遍):
- 仿照文中示例,给
QueryOrderList写一个降级函数:缓存里有就返回缓存,没有就返回空列表并标记Fallback=true。 - 用
FallbackMW+CircuitBreakerMW串到同一个 client,写个单测模拟下游返回 error,验证最终拿到的是兜底数据而非原始 error。 - 给
CachedClient加一个"数据年龄"字段(DataAge),在返回缓存旧数据时填上与当前时间的差值,观察降级数据时效性的体现。
本章小结
- 降级 = 返回兜底结果:核心思想是"宁可返回不完美,也不让用户看到错误";熔断是手段,降级是目的。
- Middleware 通用降级:用
FallbackProvider按方法名注册兜底函数,Middleware 在调用失败后统一拦截、回写resp。 - 熔断 + 降级联动是生产标配:熔断打开或调用失败都自动走兜底,用户无感。
- 本地缓存降级最适合读多写少场景,但要注意数据时效性和"降级逻辑别再调远程"这两个坑。
- 限流(服务端)→ 熔断(客户端防扩散)→ 降级(客户端保体验)是层层兜底的三道防线。
下一篇可以接着看 [[Kitex/熔断]] 与 [[Kitex/限流]],把三道防线串成一个完整的稳定性防护体系。