Kitex服务降级

2024-01-17T14:21:02+08:00 | 12分钟阅读 | 更新于 2024-01-17T14:21:02+08:00

@

学习目标

学完本章你应该能够:

  1. 用一句话区分熔断、降级、限流三者,并说清它们为什么通常要配合使用。
  2. 讲出服务降级的核心思想——“宁可返回不完美的结果,也不要让用户看到错误”。
  3. 在 Kitex 客户端用 Middleware 实现声明式降级,把降级逻辑按方法名注册并统一拦截。
  4. 熔断降级联动起来:熔断器打开时自动走兜底,而不是把错误甩给用户。
  5. 针对读多写少场景,用本地缓存降级给出实战可用的兜底方案,并说清它的时效性与坑。

前置知识

  • 了解 Kitex 客户端的基本用法(client.WithMiddleware、RPC 调用)。
  • 知道什么是 Middleware(中间件):在真实调用前后插入一段逻辑。
  • 已看过熔断、限流相关笔记更好,但不强制(本章会从"熔断是手段、降级是目的"讲起)。

本章你会动手做的事

  • 写一个 FallbackProvider,给 GetUserInfoQueryProductList 注册各自的兜底函数。
  • 把熔断 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
		}
	}
}

⚠️ 新手必踩的坑:降级函数里又调了远程服务。降级本是为了"下游挂了也能返回",结果你的兜底逻辑去查另一个远程服务,那个服务也挂了 → 降级本身失败 → 连锁故障。所以降级逻辑必须简单、本地、可快速返回,最好只读本地缓存或返回常量。


注意事项

  1. 降级不是万能药:降级数据可能与实时数据不一致,需告知用户
  2. 降级逻辑要简单:避免在降级函数中调用其他远程服务,否则可能引发连锁故障
  3. 注意缓存穿透:降级返回的缓存数据可能是空的,需设置合理的 TTL
  4. 监控覆盖率:统计降级触发次数和比例,及时发现异常
  5. 测试降级场景:通过混沌工程模拟下游故障,验证降级是否生效

相关笔记

  • [[Kitex/熔断]] — 熔断是降级的前置条件,两者通常配合使用
  • [[Kitex/限流]] — 限流保护服务端,降级保护客户端体验
  • [[Kitex/超时]] — 超时会触发降级逻辑
  • [[Kitex/中间件]] — 降级通过 Middleware 机制嵌入调用链
  • [[Kitex/重试]] — 重试失败后再走降级

自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 用一句话区分熔断、降级、限流,并各举一个"它解决什么"的例子。
  2. 服务降级的核心思想是什么?为什么说"降级是目的、熔断是手段"?
  3. FallbackProvider.Register("GetUserInfo", ...) 这一步在做什么?Middleware 是怎么在调用失败时用上它的?
  4. 方案二中,熔断器"打开"和"允许但下游调用失败"两种情况下,分别怎么走降级?
  5. 本地缓存降级(方案四)和默认值降级相比,各适合什么场景?它的主要风险是什么?

动手练习(建议真做一遍)

  1. 仿照文中示例,给 QueryOrderList 写一个降级函数:缓存里有就返回缓存,没有就返回空列表并标记 Fallback=true
  2. FallbackMW + CircuitBreakerMW 串到同一个 client,写个单测模拟下游返回 error,验证最终拿到的是兜底数据而非原始 error。
  3. CachedClient 加一个"数据年龄"字段(DataAge),在返回缓存旧数据时填上与当前时间的差值,观察降级数据时效性的体现。

本章小结

  • 降级 = 返回兜底结果:核心思想是"宁可返回不完美,也不让用户看到错误";熔断是手段,降级是目的。
  • Middleware 通用降级:用 FallbackProvider 按方法名注册兜底函数,Middleware 在调用失败后统一拦截、回写resp
  • 熔断 + 降级联动是生产标配:熔断打开或调用失败都自动走兜底,用户无感。
  • 本地缓存降级最适合读多写少场景,但要注意数据时效性和"降级逻辑别再调远程"这两个坑。
  • 限流(服务端)→ 熔断(客户端防扩散)→ 降级(客户端保体验)是层层兜底的三道防线。

下一篇可以接着看 [[Kitex/熔断]] 与 [[Kitex/限流]],把三道防线串成一个完整的稳定性防护体系。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!