第14章 · 服务治理:熔断、限流、降级(正式版)

2023-03-03T14:11:02+08:00 | 62分钟阅读 | 更新于 2023-03-03T14:11:02+08:00

@

写在最前面:这一版和普通讲义有什么不同

这是《内容社区》课程第 14 章。第 12 章我们让服务之间能互相找到对方,第 13 章解决了「找到多个实例后请求发给谁」。这一章回答一个更残酷的问题:万一对方挂了、慢了,或者自己被流量冲垮了,怎么办?

生产环境里没有一个服务是永远健康的:数据库会抖、网络会丢包、依赖的第三方会超时。如果你不设防,一个慢节点就能像多米诺骨牌一样拖死整条调用链——这叫服务雪崩,本章的一切都围绕「阻止雪崩」展开。

这一版做了三件事:

  1. 每一步都讲透再动手。熔断、限流、降级、令牌桶、滑动窗口这些名词,你大概率只是「听过」,本章全部就地补「补课胶囊」,不让你去搜。
  2. 用类比先让你看见。空气开关、餐厅精简菜单、游乐场检票口、水库泄洪——先用生活场景建立直觉,再上代码和状态机图。
  3. 结尾有 12 道面试问答。按「基础 → 进阶 → 追问」三档,每题告诉你一句话结论、怎么展开、面试官想听什么词、新手最容易答错在哪。

如果你想要「高密度速查版」,可以看原版第 14 章 ;两版知识点完全一致,只是讲法不同。


一、本章导读

上一章我们做完了负载均衡:post 服务能从 etcd 拿到 user 服务的 3 个实例地址,还能把请求合理地分发出去。到这里,「正常情况下怎么调用」已经全部打通了。

但分布式系统有一个无法回避的现实:正常情况不是常态。网络会抖、节点会挂、依赖的下游会突然变慢。想象一个场景:user 服务的数据库执行了一条慢查询,每次请求都要卡 10 秒。会发生什么?

  1. post 服务调用 user 服务,等 10 秒才返回——post 服务自己的请求也被卡住 10 秒;
  2. post 服务的每个请求都占着一个 goroutine 在干等,请求越积越多,内存和 CPU 被耗光;
  3. post 服务自己也开始超时、无响应;
  4. 调用 post 服务的上游也被拖住……

一个数据库慢查询,最终拖死了整条链路上的所有服务。这就是雪崩:单个故障沿着调用链向上传染,越传越猛。

这一章我们要建立保护系统的「三板斧」:

  • 熔断:发现下游不行了,就别再调它,快速失败,把故障隔离在最小范围内;
  • 限流:自己别接太多请求,超过承受能力的直接拒绝,宁可拒绝一部分也不能全垮;
  • 降级:撑不住时返回一个「够用的替代品」(旧缓存、默认值),保住核心体验。

学完这一章,你将拥有保护系统不被拖垮的能力——这也是面试里「稳定性」话题的全部主菜。下一章我们把这套治理能力用在一个最敏感的业务上:支付。

开读前先看一眼全局:熔断、限流、降级是防雪崩的"三板斧",各管一段——限流挡住超出容量的流量,熔断在下游不健康时果断停止调用,降级在断供时给个"能用但打折"的答案。看懂下面这张图的顺序,就看懂了本章的小节顺序。

flowchart LR
    A["上游流量涌入"] --> B["限流:令牌桶 / 滑动窗口
超出容量的直接拒绝"] B --> C["调用下游依赖"] C --> D{"下游还健康吗?"} D -->|"健康"| E["正常返回"] D -->|"持续报错 / 超时"| F["熔断器打开
一段时间内不再调用"] F --> G["降级:返回默认值 / 缓存数据
保住核心链路"] F -.->|"半开状态试探恢复"| D

这张图怎么读:流量从左到右走——「上游流量涌入」先过「限流」这道闸(令牌桶 / 滑动窗口,超出容量的直接拒绝),放下来的请求才去「调用下游依赖」;随后在「下游还健康吗?」这个菱形上分岔:健康就「正常返回」收尾;持续报错 / 超时则「熔断器打开」,一段时间内不再真调下游,改由「降级」返回默认值或缓存数据,保住核心链路。底部虚线是恢复路径:熔断打开期间到点转半开,放少量试探请求回到菱形重新判断,试探成功才逐步放开流量。


二、本章知识点大纲

先把这张地图看一遍,知道这一章要往哪走、走到哪算够。

必会(学完必须能独立写出来)

  1. 用「多米诺骨牌 / 空气开关」类比,向一个完全不懂的人讲清楚服务雪崩是怎么发生的、为什么「加机器」救不了雪崩。
  2. 画出熔断器的三态状态机(Closed / Open / Half-Open),标清楚每条状态转移的触发条件,并手写一个三态熔断器。
  3. 手写至少两种限流算法:计数器(限并发数)和令牌桶(限 QPS),并解释各自的适用场景。
  4. 说清降级和熔断的区别(降级「尽可能返回响应」vs 熔断「快速失败」),写出缓存降级的流程图。
  5. 用 UnaryServerInterceptor 给 gRPC 服务端接入限流拦截器(全应用 / 服务级 / 业务字段级三种粒度),用客户端拦截器接入熔断。

理解(能讲清原理和取舍,不要求背代码)

  1. 服务治理的「故障四阶段」框架:预防 → 检测 → 处理 → 恢复,以及熔断 / 限流 / 降级在这个框架里的位置。
  2. 五种限流算法的取舍:计数器、固定窗口、滑动窗口、令牌桶、漏桶——尤其固定窗口的边界突刺问题和令牌桶 vs 漏桶在「是否允许积压」上的差异。
  3. 「试探 + 逐步放开流量」的通用故障恢复策略,以及 Half-Open 状态如何落地它。
  4. gRPC 的链路超时自动缩短机制:为什么服务端收到的 ctx 已经是「剩余超时时间」。
  5. 限流阈值怎么定:压测曲线上的 A / B / C 三个点各代表什么,没法压测时怎么办。
  6. 错误数阈值 vs 错误率阈值的差异,为什么生产级熔断器要加「最小请求数」。

了解(知道有这回事,面试能说出名字和一句话定位)

  1. go-kratos/aegis:Kratos 的服务治理子项目,提供熔断器和基于 BBR 的自适应限流,不依赖 Kratos 框架可以单独用。
  2. 舱壁隔离(线程池 / 信号量隔离)、分组路由、优雅退出等服务治理的边缘话题。
  3. 动态限流 / 动态熔断:用 CPU、响应时间等实时指标代替静态阈值(面试加分项,实践中少用)。

前置依赖与补课清单

需要你已掌握在哪章学过本章还会补补在哪一节
gRPC 基本使用(grpc.Dial、grpc.NewServer、proto 生成代码)第 10 章❌ 不补—
负载均衡、Failover、重试放大效应第 13 章❌ 不补—
Go 并发基础(sync.Mutex、sync.Map)第 01 章❌ 不补—
context.WithTimeout 超时控制第 04 / 10 章❌ 不补—
Gin 中间件(middleware)的概念第 02 章❌ 不补(Interceptor 类比它)—
什么是服务雪崩 / 级联故障未讲过✅ 补3.1 节
熔断器、保险丝类比未讲过✅ 补3.2 节
QPS / 并发数 / 响应时间这些度量词未讲过✅ 补3.4 节
令牌桶 / 漏桶 / 滑动窗口未讲过✅ 补3.4 节

不需要你提前懂的:熔断器中间件生态(Sentinel / Hystrix 的历史沿革)、分布式限流的 Redis + Lua 完整实现——前者面试会被问到名字,本章「了解」部分给一句话定位;后者属于进阶方向,本章讲清思路即可。

预计时长与学习成果

  • 预计时长:3 小时(精读 + 跟着敲熔断器 / 限流器代码);如果只求主干,1.5 小时可过一遍概念。

  • 学完你能跑出来的东西(这是本章的验收标准,做到才算过关):

    一个 pkg/circuitbreaker 三态熔断器 + 一个 pkg/ratelimit 令牌桶限流器:测试里连续制造失败,观察熔断器从 Closed 转 Open、到期转 Half-Open、试探成功回 Closed 的完整闭环;限流器把阈值设为 5,第 6 次请求被拒绝;最后把两者挂到 gRPC 拦截器上,go build ./... 通过。

flowchart LR
    A["3.1 雪崩与治理框架"] --> B["3.2 熔断
三态状态机"] B --> C["3.3 降级
快慢路径"] C --> D["3.4 限流
五种算法"] D --> E["3.5 gRPC 接入
Interceptor"] E --> F["3.6 超时控制"] F --> G["3.7 阈值怎么定"] G --> H["3.8 可观测性接入"] H --> I["第四节 跟着写"] I --> J["第七节 面试问答"]

这张图怎么读:主线是「先原理、后接入」——从「3.1 雪崩与治理框架」出发,依次过三板斧的原理(3.2 熔断三态状态机 → 3.3 降级快慢路径 → 3.4 限流五种算法),再落到工程接入(3.5 gRPC Interceptor),最后补齐配套三件套(3.6 超时控制、3.7 阈值怎么定、3.8 可观测性);之后转入「第四节 跟着写」,以「第七节 面试问答」收口。每条边就是「读完上一节再读下一节」的线性顺序。

服务治理治理目标防止雪崩故障四阶段流量放大熔断三态状态机错误率阈值Half-Open降级快慢路径缓存降级跨服务降级限流窗口/计数器令牌桶/漏桶三种粒度gRPC 接入Interceptor熔断限流降级链路超时

三、正文

3.1 服务治理到底在治理什么

先补课:什么是服务雪崩

补课胶囊 · 服务雪崩(级联故障) 一句话:一个服务的故障顺着调用链向上传染,最终拖垮整条链路甚至整个系统。 类比:多米诺骨牌。user 服务是中间的一张牌,它倒了会推倒调用它的 post 服务,post 再推倒调用它的网关——只要有一张牌没立稳,整排骨牌全躺下。 最小示例:本章导读里那个「DB 慢查询 → user 卡 10 秒 → post 请求堆积 → post 也卡死」的推演,就是一次完整的雪崩过程。 回到主线:熔断、限流、降级三板斧,全部是为了在「某张骨牌开始晃」时把它扶住或抽走,别让它推倒下一张。

服务治理的范围

服务治理是一个非常宽泛的问题,主要涵盖:

  • 熔断、限流、降级(本章主角,也是高频面试点)
  • 超时控制
  • 隔离(线程池隔离、信号量隔离——把不同业务的资源隔开,别互相拖累)
  • 分组和路由
  • 优雅退出(第 09 章讲过的优雅停机)

微服务架构要在面试里讲出亮点,必须有一整套从前端到后端的服务治理方案,本章先把最核心的三板斧立起来。

故障处理三段论:一个统一框架

服务治理本质上在讨论两件事:

  1. 怎么保证系统不出现故障(预防)
  2. 万一故障了:怎么尽快发现?怎么处理?怎么恢复?

把这两件事展开,就得到故障处理的四阶段框架——这张表是本章的总纲,后面每个知识点都能挂进去:

阶段思路对应手段
故障预防不管有没有问题,到阈值就限限流
故障检测静态检测(阈值)+ 动态检测(实时指标)错误率统计、响应时间监控
故障处理同步转异步 / 执行特殊代码 / 请求转发熔断、降级
故障恢复固定等待 / 实时计算 / 试探法 + 灰度Half-Open 状态、逐步放量
flowchart TD
    P[故障预防: 限流] --> D[故障检测: 阈值/实时指标]
    D --> H[故障处理: 熔断/降级/异步/转发]
    H --> R[故障恢复: 等待/试探/灰度放量]
复习提示:关键认知:熔断、限流、降级本质上是一回事——都是「故障处理」的不同表现形式。面试时如果你能先抛出这个框架,再把三个手段挂进去,会比背三个孤立定义高一个档次。

为什么雪崩会「越滚越大」:流量放大

一个用户请求在微服务中可能引发多次内部调用。比如用户打开一个帖子详情页:

flowchart TD
    U[用户请求] --> A[网关]
    A --> B[post 服务]
    A --> D[user 服务]
    B --> C[comment 服务]
    B --> E[user 服务
查作者信息] C --> F[user 服务
查评论者信息]

一个请求,背后是 6 次服务间调用。当请求量翻倍,下游服务的实际负载增长不止翻倍——这就是流量放大。如果 comment 服务出故障,被卡住的请求会把 post、user 的资源全部占满,单点故障级联放大成全局雪崩。

复习提示:⚠️ 新手必踩的坑:以为「加机器就能解决雪崩」。雪崩的根因是「调用链放大 + 单点故障传播」,单纯扩容只会让更多请求冲进故障点,反而加速崩溃。必须先用熔断 / 限流把故障「隔离」住,扩容才有意义。就好比多米诺骨牌倒了,正确做法是把那张正在倒的牌抽走,而不是加更多的牌。

故障恢复的通用策略:试探 + 逐步放开流量

故障恢复是最容易被忽视的一环,它要回答两个问题:怎么确定服务已经恢复? 以及 怎么避免「刚恢复又被打挂」的抖动?

判定恢复的三种方式:

  1. 固定时间等待:比如等 1 分钟,就认为已经恢复了——最简单,也最粗糙;
  2. 实时计算:按照故障检测的算法(响应时间、错误率等)实时计算节点状态;
  3. 试探法:尝试处理少量请求,根据处理结果判断是否恢复。

几乎通用的恢复策略是「试探 + 逐步放开流量」(也叫灰度恢复)。以「故障期间所有请求都返回默认值」为例:

  1. 触发故障处理后,间隔一段时间,试探性地放行 1 个真实请求;
  2. 这个请求被正常处理 → 加大流量(比如放行 10%);没被正常处理 → 继续返回默认值;
  3. 加大流量的过程中如果又失败 → 减少流量,甚至退回到全部返回默认值;
  4. 不断重复,直到 100% 的流量都被正常处理。
flowchart TD
    F[故障处理中 全部返回默认值] --> W[间隔一段时间]
    W --> T[试探放行少量真实请求]
    T -->|处理成功| U[逐步加大流量]
    T -->|处理失败| F
    U -->|又失败| D[减少流量或退回默认值]
    U -->|持续成功| N[全部流量正常 恢复完成]

为什么要这么麻烦?想象服务刚从故障中缓过来,资源还很紧张。如果此时直接 100% 放行,刚恢复的服务瞬间又被流量打挂,系统就会陷入「一会好、一会崩」的抖动循环——恢复也需要温柔。

实现上可以复用「随机数 + 阈值」的流量控制思路:维护一个放行概率(比如从 1% 开始),每个请求生成随机数决定走真实处理还是默认值,再根据处理结果实时调整这个阈值。

复习提示:熔断器的 Half-Open 状态(下一节)就是这套策略的落地:halfOpenMaxCalls 控制试探请求数,试探成功才回 Closed。如果恢复处理做得不好,系统就会出现「一会好、一会崩」的抖动——这也是为什么不能从 Open 直接跳回 Closed 全量放行。

小收口:这一节建立了「预防 → 检测 → 处理 → 恢复」的总框架。接下来三板斧逐一登场:先讲熔断(最典型的状态机),再讲降级(熔断的「温柔版」),最后讲限流(唯一的事前预防手段)。


3.2 熔断:发现下游不行了就别再调

先补课:什么是熔断

补课胶囊 · 熔断(Circuit Breaker) 一句话:调用方监测下游的错误率,超过阈值就「跳闸」,一段时间内直接拒绝请求、不再真调下游。 类比:家里配电箱的空气开关。正常时随便用电;一旦短路(下游疯狂报错),开关「啪」地跳开,立刻断电保护整条线路;过一会儿你试着把开关推一半(半开),没问题就彻底合上,还跳闸就继续断开。它保护的是「线路」——也就是调用方自己不被下游拖死。 最小示例:post 服务调 user 服务,统计到最近 100 次调用失败了 60 次(错误率 60% > 阈值 50%),熔断器跳闸:接下来 30 秒内所有对 user 服务的调用直接返回错误,一个网络请求都不发。 回到主线:熔断的核心是「快速失败」——与其让每个请求等 10 秒超时,不如立刻返回错误,把资源省下来。

熔断器的三态状态机

熔断器是一个有限状态机(在几个固定状态之间按条件切换的模型,第 09 章的分布式任务调度里也用过这个概念)。它有三个状态:

状态中文名行为
Closed闭合正常处理所有请求,同时统计错误率(默认状态)
Open打开直接拒绝所有请求,返回特定错误(快速失败)
Half-Open半开放入少量请求试探,成功则转 Closed,失败则转 Open

Half-Open 就是「恢复过程中的状态」——它就是 3.1 节「试探 + 逐步放开流量」的落地。

stateDiagram-v2
    [*] --> Closed
    Closed --> Open: 错误率超阈值
    Open --> HalfOpen: 超时窗口到期
    HalfOpen --> Closed: 试探成功
    HalfOpen --> Open: 试探失败

用空气开关把四个状态串起来读一遍:

  1. Closed(合闸):一切正常,电随便用,但电表一直在记录(统计错误率);
  2. Closed → Open(跳闸):检测到短路(错误率超过阈值,比如 50%),开关跳开;
  3. Open → Half-Open(试着推一半):跳闸后等 30 秒(超时窗口),把开关推到中间位置,放一点点电流过去(放行少量试探请求);
  4. Half-Open → Closed(合闸成功):试探请求正常返回,说明下游恢复了,彻底合闸,恢复统计;
  5. Half-Open → Open(又跳了):试探请求又失败,说明还没好,继续断开,再等下一个窗口。
复习提示:面试高频:这三个状态和四条转移条件几乎是必考题。记忆锚点是空气开关——「合闸 → 跳闸 → 推一半试试 → 行就合上 / 不行再跳」。

熔断的三个要点

把熔断展开,其实要回答三个问题:

  1. 怎么判定要熔断(检测):静态检测——错误率超过 50% 就熔断;或动态检测——根据实时指标判定(进阶,见 3.4 节末尾);
  2. 熔断后怎么办(处理):返回特定错误,比如 gRPC 的 UNAVAILABLE,让调用方知道「这不是业务错,是熔断了」;
  3. 怎么恢复:试探 + 逐步放开流量(3.1 节已讲,Half-Open 落地)。

动手:手写一个三态熔断器

光看状态机图不过瘾,我们直接写一个最小可用的熔断器。先声明:这个版本用「错误数阈值」判定,有缺陷(下面会说),但足以把状态机跑通。

// pkg/circuitbreaker/simple.go
package circuitbreaker

import (
	"errors"
	"sync"
	"time"
)

// State 熔断器状态
type State int

const (
	StateClosed State = iota // 闭合:正常放行
	StateOpen                // 打开:快速失败
	StateHalfOpen            // 半开:放少量试探
)

var ErrCircuitOpen = errors.New("circuit breaker is open")

// Breaker 简单熔断器
type Breaker struct {
	mu               sync.Mutex
	state            State
	failureThreshold int           // 错误数阈值:攒够多少个失败就跳闸
	failureCount     int           // 当前已累计的失败数
	lastStateChange  time.Time     // 上次状态变更时间
	openTimeout      time.Duration // Open 状态持续多久后转 HalfOpen
	halfOpenMaxCalls int           // HalfOpen 最多放几个试探请求
	halfOpenCalls    int           // HalfOpen 已放行的试探数
}

func New(failureThreshold int, openTimeout time.Duration) *Breaker {
	return &Breaker{
		state:            StateClosed,
		failureThreshold: failureThreshold,
		openTimeout:      openTimeout,
		halfOpenMaxCalls: 5, // 默认放 5 个试探请求
	}
}

// State 返回当前状态(供外部观察状态机,如降级判断、打印调试)
func (b *Breaker) State() State {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.state
}

Allow 在每次调用前执行——判断这个请求放不放行:

// pkg/circuitbreaker/simple.go(续)
// Allow 判断请求是否允许通过
func (b *Breaker) Allow() error {
	b.mu.Lock()
	defer b.mu.Unlock()

	switch b.state {
	case StateClosed:
		// 步骤 1:闭合态直接放行
		return nil
	case StateOpen:
		// 步骤 2:开放态,只有超时后才转半开并放行一个试探
		if time.Since(b.lastStateChange) > b.openTimeout {
			b.state = StateHalfOpen
			b.halfOpenCalls = 0
			return nil
		}
		return ErrCircuitOpen
	case StateHalfOpen:
		// 步骤 3:半开态限制试探请求数,超了就拒绝
		if b.halfOpenCalls < b.halfOpenMaxCalls {
			b.halfOpenCalls++
			return nil
		}
		return ErrCircuitOpen
	}
	return nil
}

MarkSuccess / MarkFailure 在调用返回后执行——把结果喂给状态机:

// pkg/circuitbreaker/simple.go(续)
// MarkSuccess 标记一次成功
func (b *Breaker) MarkSuccess() {
	b.mu.Lock()
	defer b.mu.Unlock()
	if b.state == StateHalfOpen {
		// 半开态成功 → 转 Closed,统计清零(失败计数和试探名额都要归零)
		b.state = StateClosed
		b.failureCount = 0
		b.halfOpenCalls = 0
	}
}

// MarkFailure 标记一次失败
func (b *Breaker) MarkFailure() {
	b.mu.Lock()
	defer b.mu.Unlock()

	if b.state == StateHalfOpen {
		// 半开态失败 → 立刻转 Open,重新计时
		b.state = StateOpen
		b.lastStateChange = time.Now()
		return
	}

	// 闭合态:累计失败数,攒够阈值就跳闸
	b.failureCount++
	if b.failureCount >= b.failureThreshold {
		b.state = StateOpen
		b.lastStateChange = time.Now()
	}
}

写个 main 函数把状态机完整跑一遍:

// cmd/breaker_demo/main.go
package main

import (
	"fmt"
	"time"

	"content-community/pkg/circuitbreaker"
)

func main() {
	// 阈值 3 个失败,跳闸后 1 秒进入半开
	b := circuitbreaker.New(3, time.Second)

	// 阶段 1:制造 3 次失败,触发跳闸
	for i := 0; i < 3; i++ {
		_ = b.Allow()
		b.MarkFailure()
	}
	fmt.Println("3 次失败后:", b.State()) // StateOpen(1)

	// 阶段 2:熔断打开期间,请求直接被拒
	err := b.Allow()
	fmt.Println("熔断期间调用:", err) // circuit breaker is open

	// 阶段 3:等 1 秒超时窗口过去,进入半开并试探成功
	time.Sleep(1100 * time.Millisecond)
	_ = b.Allow()          // 这次放行(转 HalfOpen + 占用试探名额)
	b.MarkSuccess()        // 试探成功 → 回 Closed
	fmt.Println("试探成功后:", b.State()) // StateClosed(0)

	// 阶段 4:恢复后请求正常放行
	fmt.Println("恢复后调用:", b.Allow()) // <nil>
}

运行命令与预期输出:

go run ./cmd/breaker_demo/
3 次失败后: 1
熔断期间调用: circuit breaker is open
试探成功后: 0
恢复后调用: <nil>

(State 是 int 类型,0 是 Closed、1 是 Open、2 是 HalfOpen。想打印名字可以在 State 上实现 String() 方法,这里从简。)

逐行讲解几个关键设计:

代码为什么这么写
sync.Mutex 包住所有字段Allow 和 Mark* 会被多个 goroutine 并发调用(每个请求一个),不加锁会发生数据竞争
time.Since(b.lastStateChange) > b.openTimeoutOpen 不是永久状态,到期自动转 HalfOpen——这就是「超时窗口」
半开态里 halfOpenCalls < halfOpenMaxCalls限制试探数量,避免「半开」变「全开」,刚恢复的服务又被冲垮
MarkFailure 里先判断 HalfOpen半开态下一次失败就立刻回 Open——试探失败说明还没恢复,别犹豫
MarkSuccess 只在 HalfOpen 有动作Closed 态的成功不需要改变状态,只影响统计(工程版里会记录)
复习提示:⚠️ 新手必踩的坑 1:忘记调用 MarkSuccess / MarkFailure。熔断器只有「统计」没有「上报」就是摆设——很多同学实现了 Allow 却忘了在业务返回后调用 Mark,结果状态永远停在 Closed,熔断根本不触发。放行 → 调业务 → 无论成败都 Mark,这三步必须成对出现。
复习提示:⚠️ 新手必踩的坑 2:把 Open 状态下被拒绝的请求也记成失败。这些请求根本没发出去,既不该算成功也不该算失败;如果记进统计,失败率永远降不下来,Half-Open 试探刚成功就会被旧数据再次打回 Open,系统在 Open / Closed 之间反复横跳。
复习提示:错误数阈值 vs 错误率阈值:上面的实现用的是「错误数阈值」,简单但两头不讨好——大流量时哪怕错误率只有 1%,错误数也会很快攒够阈值,导致过早熔断;小流量时又迟迟凑不够数,反应迟钝。所以生产级熔断器(aegis、Sentinel 等)大多用「错误率阈值 + 最小请求数」:请求量没到最小样本数时不做判定。跟写部分第 1 步的工程版就是这个改进版。

小收口:熔断是「快速失败」——宁可返回错误,也不把资源浪费在注定失败的调用上。但有些业务场景里,直接报错用户体验太差(比如帖子列表因为作者服务挂了就整个打不开)——这就轮到降级出场了。


3.3 降级:撑不住时返回一个「够用的替代品」

先补课:什么是降级

补课胶囊 · 降级(Degradation) 一句话:系统压力大或部分依赖故障时,主动放弃部分非核心功能,返回简化但可用的结果,保住主干体验。 类比:餐厅爆满时推出「精简菜单」——正常菜单 100 道菜现做现炒(慢路径),忙不过来时只卖 10 道预制菜(快路径)。客人照样能吃上饭(有响应),只是选择少了、可能不是最新鲜的(牺牲部分质量换可用性)。 最小示例:帖子列表要显示作者昵称,作者服务挂了——降级:昵称显示「未知用户」,头像用默认图,帖子列表本身照常可看。 回到主线:降级和熔断的关系是——熔断负责「发现问题、切断调用」,降级负责「切断之后给用户看什么」。

降级和熔断的区别(一句话记住)

  • 熔断:快速失败——「下游不行了,这单我不做了」,直接返回错误;
  • 降级:尽可能返回响应——「正菜做不了,给你上预制菜」,返回缓存、默认值或简化数据。

两者经常配合使用:熔断器跳闸后,调用不发了,改走降级逻辑。

快慢路径:降级的基本套路

很多业务天然有两条路径:

  • 快路径:耗资源少、计算快(如查 Redis 缓存);
  • 慢路径:耗资源多、计算慢(如查 DB 并回写缓存)。

正常流程:先快后慢(第 06 章的 cache-aside 模式)。降级时只走快路径,跳过慢路径:

flowchart TD
    Q[请求到达] --> F[快路径: 查 Redis]
    F -->|命中| OK[返回结果]
    F -->|未命中| S[慢路径: 查 DB 并回写缓存]
    S --> OK
    X[触发降级] --> F2[降级: 只查 Redis]
    F2 -->|命中| OK2[返回缓存结果]
    F2 -->|未命中| ERR[直接返回错误或默认值
不碰 DB]

经典案例:缓存降级

这是降级里最经典的招式。对照第 06 章学过的 cache-aside:

  • 正常时:查 Redis → 未命中则查 DB → 回写缓存;
  • 降级时:查 Redis → 未命中直接返回错误 / 默认值,不再查 DB。

就这么一个小改动,收益有三层:

  1. 应用负载快速下降:只查 Redis 很快,请求迅速处理完,资源被腾出来;
  2. 能撑住极高并发:瓶颈只剩 Redis(单机 Redis 轻松 10 万 QPS),DB 不参与;
  3. 保住数据库:DB 不会被洪峰压垮——数据库是系统里最难恢复的组件,保它就是保命脉。

代价是什么?缓存未命中的那部分用户会看到旧数据或报错——牺牲少数人的完整体验,换取系统整体活着,这就是降级的价值观。

跨服务降级:按业务重要性排序

资源不足时,按「这个功能有多重要」逐个牺牲:

层面降级顺序
单个服务内增删改(写操作)先停,全力保查询
集群层面边缘业务(如运营后台)先停,资源让给核心服务
读写分组写服务先停,资源给读服务
同节点多服务从最不重要的服务开始逐个停

一个熟悉的现实例子:大促抢购时,电商 App 的「历史订单查询」「评价列表」会变得很慢或不可用——它们被降级了,资源全让给了「下单」这条核心链路。

小收口:降级是「温柔版」的故障处理,它关心的是用户还能看到什么。到这里,检测(统计错误率)、处理(熔断 + 降级)、恢复(Half-Open 试探)都有了,还缺「预防」——限流是唯一能在故障发生之前就起作用的手段,也是下一节的重头戏。

3.4 限流:自己别接得太多

先补课:限流相关的基础度量词

补课胶囊 · 限流(Rate Limiting)与三个度量词 一句话:限流是给系统设一个「最大接待量」,超过的请求直接拒绝,防止自己被压垮。 类比:游乐场检票口。过山车同时在车上最多 30 人(并发数),每分钟最多放行 100 人进园(QPS);如果检票员发现队伍已经排到马路上(响应时间飙升),就该挂「今日门票售罄」的牌子了(限流)。 最小示例:评论接口设限流阈值 100 QPS——第 101 个请求到达的瞬间,直接返回「系统繁忙」,根本不进入业务逻辑。 回到主线:注意限流保护的是被调用方自己(「别都进来」),而熔断保护的是调用方(「别去调它」)——这是两者的本质分工。

三个度量词严格区分一下,新手极易混淆:

度量词含义限的是
并发数此刻正在处理中的请求数「同时在车上的人数」
QPS每秒进入的请求数(Queries Per Second)「每分钟放行进园的人数」
平均响应时间一个请求从进来到返回平均花多久不是限流对象,而是压测时观测的系统健康指标

并发数和 QPS 的关系:并发数 ≈ QPS × 平均响应时间(著名的 Little’s Law)。比如 QPS 100、每个请求平均耗时 0.2 秒,任意时刻正在处理的请求约 20 个。「限 QPS」和「限并发数」是两种不同的限流,分别对应下面要讲的窗口类算法和计数器算法。

限流对象:针对什么限流?

限流不是只有「全局限 1000 QPS」这一种玩法,它有三个维度:

  • 粒度:单机限流(每个实例自己限自己的)/ 集群限流(整个集群共享一个额度,需要 Redis 等中心化组件配合);
  • 范围:整个应用 / 某个服务 / 某个接口;
  • 业务对象:按用户、IP、商品、订单……非常灵活。例如:
    • VIP 用户不限流,普通用户限流(按身份区别对待);
    • 登录接口按 IP 限流(防撞库,第 05 章用过的思路)。

本章跟写部分实现的是单机限流——每个实例内存里自己计数。集群限流(多实例共享额度)需要 Redis + Lua 原子操作,思路是「把 Allow 里的计数搬到 Redis」,面试提一句思路即可,本章不展开实现。

限流算法总览:一个五选一的选型题

直接上总表,后面逐个实现:

算法原理优点缺点
计数器任意时刻「正在处理」的请求数不超上限最简单,直接限并发数需要配 Release,忘了就永久占坑
固定窗口时间切成固定窗口,每窗口内请求数不超阈值实现简单,直观窗口边界会突发双倍流量
滑动窗口窗口随时间滑动,统计「最近 N 秒」的请求数平滑,无边界问题实现略复杂(要存时间戳或分桶)
令牌桶匀速发令牌,请求拿到令牌才处理允许积压,突发流量友好允许突发也可能打下游一个措手不及
漏桶请求匀速通过,速率绝对均匀输出绝对平滑,对下游最友好不允许积压,突发流量直接拒绝
flowchart LR
    subgraph 令牌桶
        T[匀速发令牌] --> TB[桶里攒令牌
最多攒到桶容量] TB --> R1[请求拿令牌通过
没令牌就拒绝] end subgraph 漏桶 L[请求进桶] --> LB[匀速漏出处理
桶满则拒绝新请求] end
复习提示:老师的实用建议:实践中随便选一个就行,效果差不多。推荐计数器(限并发)或令牌桶(限 QPS)——好理解、好实现、好排查。面试时把五种原理和差异讲清楚,比纠结「选哪个」重要得多。

计数器:限并发数的最低成本方案

最简单也最实用——任意时刻系统中只有固定数量的请求正在被处理:

// pkg/ratelimit/counter.go
package counter

import "sync"

// CounterLimiter 计数器限流器,限制并发数
type CounterLimiter struct {
	mu       sync.Mutex
	current  int // 当前并发数
	maxConns int // 最大并发数
}

func New(maxConns int) *CounterLimiter {
	return &CounterLimiter{maxConns: maxConns}
}

// Allow 尝试获取一个并发槽位
func (l *CounterLimiter) Allow() bool {
	l.mu.Lock()
	defer l.mu.Unlock()
	// 步骤 1:已达上限直接拒绝
	if l.current >= l.maxConns {
		return false
	}
	// 步骤 2:占用一个槽位
	l.current++
	return true
}

// Release 处理完毕后释放槽位(必须 defer 调用!)
func (l *CounterLimiter) Release() {
	l.mu.Lock()
	defer l.mu.Unlock()
	if l.current > 0 {
		l.current--
	}
}

用法模板:

// internal/service/comment.go(节选)
func (s *CommentService) Publish(ctx context.Context, req *PublishReq) error {
	if !s.limiter.Allow() {
		return errors.New("系统繁忙,请稍后再试") // 满了直接拒绝
	}
	defer s.limiter.Release() // 关键:无论如何都要释放
	return s.repo.Insert(ctx, req)
}
复习提示:⚠️ 新手必踩的坑:只调 Allow 忘了 Release。槽位只增不减,并发数很快顶到上限,之后所有请求都被拒绝——系统「假死」。一定要 defer limiter.Release(),而且 defer 必须写在 Allow 成功之后(写前面的话,被拒绝的请求也会去减计数,把 current 减成负数虽然被 > 0 挡住,但逻辑已错乱)。

固定窗口:按时间片计数

先把它的「前身」——固定窗口——写出来,才能看清滑动窗口到底改进了什么:

// pkg/ratelimit/fixedwindow.go
package fixedwindow

import (
	"sync"
	"time"
)

// FixedWindowLimiter 固定窗口限流器
type FixedWindowLimiter struct {
	mu        sync.Mutex
	window    time.Duration // 窗口大小,如 1s
	threshold int           // 每个窗口内允许的最大请求数
	count     int           // 当前窗口已放行的请求数
	windowEnd time.Time     // 当前窗口的结束时间
}

func New(window time.Duration, threshold int) *FixedWindowLimiter {
	return &FixedWindowLimiter{
		window:    window,
		threshold: threshold,
		windowEnd: time.Now().Add(window),
	}
}

// Allow 判断是否放行
func (l *FixedWindowLimiter) Allow() bool {
	l.mu.Lock()
	defer l.mu.Unlock()
	now := time.Now()
	// 步骤 1:跨过窗口边界,就重开一个窗口,计数清零
	if now.After(l.windowEnd) {
		l.count = 0
		l.windowEnd = now.Add(l.window)
	}
	// 步骤 2:本窗口计数已达阈值,拒绝
	if l.count >= l.threshold {
		return false
	}
	// 步骤 3:放行并计数
	l.count++
	return true
}

它的问题叫边界突刺:假设阈值是每 1 秒 100 个请求。第 1 个窗口的最后 0.1 秒涌进 100 个,紧接着第 2 个窗口的前 0.1 秒又涌进 100 个——0.2 秒内通过了 200 个请求,瞬时压力是阈值的两倍。窗口边界像一道闸门,两边的水可以在闸门开合的瞬间同时挤过去。

flowchart LR
    subgraph 窗口1["窗口 1(阈值 100)"]
        A[0.9s-1.0s: 100 个请求通过]
    end
    subgraph 窗口2["窗口 2(阈值 100)"]
        B[1.0s-1.1s: 100 个请求通过]
    end
    A --> B
    C[实际效果: 0.2 秒内通过 200 个] -.-> B

滑动窗口:把「闸门」变成「浮标」

滑动窗口的思路:不再看「这个固定窗口内」有多少请求,而是看「从现在往前推 N 秒」有多少请求。窗口像浮标一样跟着当前时间滑动,边界突刺自然消失。

// pkg/ratelimit/slidingwindow.go
package slidingwindow

import (
	"sync"
	"time"
)

// SlidingWindowLimiter 滑动窗口限流器(朴素版:存所有请求的时间戳)
type SlidingWindowLimiter struct {
	mu        sync.Mutex
	window    time.Duration // 窗口大小,如 1s
	threshold int           // 窗口内允许的最大请求数
	requests  []time.Time   // 窗口内的请求时间戳
}

func New(window time.Duration, threshold int) *SlidingWindowLimiter {
	return &SlidingWindowLimiter{
		window:    window,
		threshold: threshold,
	}
}

// Allow 检查是否允许通过
func (l *SlidingWindowLimiter) Allow() bool {
	l.mu.Lock()
	defer l.mu.Unlock()

	now := time.Now()
	// 步骤 1:清理窗口外的旧请求(时间戳早于「now - window」的都过期了)
	boundary := now.Add(-l.window)
	idx := 0
	for ; idx < len(l.requests); idx++ {
		if l.requests[idx].After(boundary) {
			break
		}
	}
	l.requests = l.requests[idx:]

	// 步骤 2:最近 window 内请求数已达阈值,拒绝
	if len(l.requests) >= l.threshold {
		return false
	}
	// 步骤 3:记录本次请求并放行
	l.requests = append(l.requests, now)
	return true
}

逐行看关键设计:

代码为什么这么写
requests []time.Time朴素实现直接存每个请求的时间戳——好懂,但大流量下内存开销大
boundary := now.Add(-l.window)「从现在往前推 N 秒」这条线,就是滑动窗口的左边界
清理用下标前移 l.requests[idx:]时间戳天然有序,找到第一个没过期的位置,前面的整体丢弃

生产级的滑动窗口(如 Sentinel)不存时间戳,而是把大窗口切成 10 个小时间片(分桶)各自计数,窗口滑动时只清路过的小桶——内存固定、速度快。跟写部分第 2 步的分桶滑动窗口就是这个思路,熔断器的滑动窗口统计也是同一个写法(一招两用)。

令牌桶:允许「攒额度」的限流

令牌桶是允许积压的:系统按固定速率往桶里发令牌(桶满则丢弃多余令牌),请求来时拿一个令牌,拿不到则拒绝。

它的妙处在于突发友好:限流速率 100/s、桶容量 200。系统空闲一段时间后桶里攒了 200 个令牌,这时突然来 250 个请求——前 200 个秒过(消耗积攒的令牌),后面按每秒 100 个的速率放行。平时的额度可以攒着,忙时一次用,这非常符合真实业务的流量特征。

// pkg/ratelimit/tokenbucket.go(入门版)
package tokenbucket

import (
	"sync"
	"time"
)

// TokenBucket 令牌桶限流器
type TokenBucket struct {
	mu         sync.Mutex
	capacity   int64     // 桶容量(最多攒多少令牌)
	tokens     int64     // 当前令牌数
	rate       int64     // 每秒生成的令牌数
	lastRefill time.Time // 上次补充令牌的时间
}

func New(capacity, rate int64) *TokenBucket {
	return &TokenBucket{
		capacity:   capacity,
		tokens:     capacity, // 启动时桶是满的,允许初始突发
		rate:       rate,
		lastRefill: time.Now(),
	}
}

// Allow 尝试获取一个令牌
func (tb *TokenBucket) Allow() bool {
	tb.mu.Lock()
	defer tb.mu.Unlock()

	// 步骤 1:按经过时间补充令牌(懒计算:不用后台协程,来请求时才算账)
	now := time.Now()
	elapsed := now.Sub(tb.lastRefill).Seconds()
	refill := int64(elapsed * float64(tb.rate))
	if refill > 0 {
		tb.tokens += refill
		if tb.tokens > tb.capacity {
			tb.tokens = tb.capacity // 不能超过桶容量
		}
		tb.lastRefill = now
	}

	// 步骤 2:有令牌就消费并放行,否则拒绝
	if tb.tokens > 0 {
		tb.tokens--
		return true
	}
	return false
}

一个值得注意的技巧是懒计算(lazy refill):不需要起一个后台 goroutine 每毫秒往桶里加令牌,而是在每次 Allow 时根据「距离上次补充过了多久 × 速率」一次性补齐。令牌数不是「实时变化」的,而是「每次被访问时按时间差结算」——效果等价,但省掉了常驻协程。

Go 官方扩展库 golang.org/x/time/rate 就是一个功能完备的令牌桶(支持阻塞等待 Wait、一次拿多个令牌等),日常直接用它即可:

// internal/middleware/ratelimit.go(用官方库的极简示例)
import "golang.org/x/time/rate"

limiter := rate.NewLimiter(rate.Limit(100), 200) // 速率 100/s,桶容量 200

if !limiter.Allow() {
	// 拒绝请求
}

漏桶:绝对均匀的输出

漏桶是令牌桶「不允许积压」的版本:请求先进桶,桶以固定速率往外漏(处理),桶满则拒绝新请求。任何时刻的放行速率都一样,绝对均匀。

// pkg/ratelimit/leakybucket.go
package leakybucket

import (
	"sync"
	"time"
)

// LeakyBucket 漏桶限流器:不管请求什么时候来,放行速率绝对均匀
type LeakyBucket struct {
	mu       sync.Mutex
	interval time.Duration // 相邻两次放行的最小间隔 = 1 / rate
	lastLeak time.Time     // 上一次放行("滴水")的时间
}

func New(ratePerSec int64) *LeakyBucket {
	return &LeakyBucket{
		interval: time.Second / time.Duration(ratePerSec),
	}
}

// Allow 尝试放行一个请求
func (lb *LeakyBucket) Allow() bool {
	lb.mu.Lock()
	defer lb.mu.Unlock()
	now := time.Now()
	// 步骤 1:距离上次放行还没满一个间隔,说明"滴"得太快,拒绝
	if now.Sub(lb.lastLeak) < lb.interval {
		return false
	}
	// 步骤 2:放行,并记录本次放行时间
	lb.lastLeak = now
	return true
}

对比记忆(面试高频):

对比项令牌桶漏桶
是否允许积压✅ 桶里攒令牌,突发流量友好❌ 不允许,输出绝对均匀
面对突发可以一口气放行攒下的额度直接拒绝多余的
适用场景自己的服务(能扛突发就放)调用别人的限速接口(如第三方 API 限额 100 QPS,匀着用最稳)

限流后的请求怎么办?

很少有人讨论被限流的请求——拒绝就完了吗?其实被拒绝的请求还有三条生路:

  • 同步转异步:临时保存请求,后续处理(如写入 Kafka / MQ 延迟处理——秒杀下单就是这么做的);
  • 执行特殊代码:返回默认值(这就是降级了);
  • 转发请求:通知客户端换节点重试(这就是负载均衡的事了)。

从这里也能看出,限流 / 熔断 / 降级的界限并不分明——它们都是「故障处理」的不同侧面,彼此衔接。

熔断 / 限流 / 降级怎么选

三者都用于保护系统,但侧重点不同。面对一个具体的故障场景,到底该上哪一个?下表按「你想达到什么目的」反推该选哪种手段——这是工程上最实用的判断方式:

需求选择
出故障也尽可能保持可用降级
尽快从故障中恢复熔断
至少一部分请求能被正确处理限流
复习提示:老师的经验排序:使用频率 限流 > 降级 > 熔断。限流是日常工具,大促前人人都在调阈值;降级常备但希望永远别真用上;熔断破坏性强(跳闸期间正常请求也被拒绝),要慎用、阈值要保守。

进阶:用动态判定代替静态阈值

前面讨论的限流大多是静态限流:提前设置好阈值,超了就限。更进一步,可以像第 13 章动态负载均衡那样,根据实时指标判定系统状态:

  • 硬件指标:CPU、内存、磁盘 IO;
  • 服务指标:响应时间、超时比率、错误率。

一旦判定「已经出故障」,就从熔断 / 限流 / 降级里挑一个执行;恢复阶段同样综合这些指标,决定是加大流量还是降低流量。

复习提示:实践中很少采用这么复杂的动态策略——它更多出现在宣讲、晋升答辩和面试里(手动狗头)。如果想用现成的:go-kratos/aegis 里基于 BBR(Bottleneck Bandwidth and Round-trip propagation time,一种自适应算法,原本用于网络拥塞控制)的自适应限流就是这个思路的落地——根据 CPU 等指标和滑动窗口统计,动态计算放行阈值。面试提到「aegis 的 BBR 自适应限流」是明确加分项。

顺带补一句网关层:本章的限流器都长在微服务自己身上;工程上第一道限流闸门其实在网关——Nginx 的 limit_req、APISIX / Kong 的限流插件(令牌桶 / 并发控制)在流量进服务之前就能拦掉超额部分。单体时代网关限流是主力,微服务时代两层配合:网关粗颗粒保全局,服务内细颗粒保自己(第 03 章部署的 ingress-nginx 就是网关层的实例)。

小收口:到这里三板斧的原理都讲完了。但它们都还停留在「独立的组件」,怎么接到 gRPC 服务里去?答案是第 10 章见过、第 13 章用过的老朋友——Interceptor。


3.5 在 gRPC 中接入服务治理

gRPC 的接入点:Interceptor

类似 Gin 里的 middleware(第 02 章),gRPC 中叫 Interceptor(拦截器),分客户端和服务端两种:

  • UnaryServerInterceptor:服务端普通 RPC 拦截器——请求进入服务端后、业务代码执行前拦截;
  • UnaryClientInterceptor:客户端普通 RPC 拦截器——请求发出前拦截;
  • 还有 Stream 版本(流式 RPC 用,本章不展开)。

熔断 / 限流 / 降级主要在服务端和客户端各司其职:限流保护服务端自己(挂服务端 Interceptor),熔断保护调用方(挂客户端 Interceptor——下游挂了就别发请求),降级在业务代码里结合熔断状态触发。

flowchart LR
    Req[RPC 请求] --> Chain[ChainUnaryInterceptor]
    Chain --> I1[日志 Interceptor]
    I1 --> I2[限流 Interceptor]
    I2 --> I3[熔断 Interceptor]
    I3 --> H[真正 handler 业务代码]

这张图怎么读:一个 RPC 请求从左到右依次穿过拦截器链——先经 ChainUnaryInterceptor 注册的「日志 Interceptor」记录入口信息,再到「限流 Interceptor」检查额度(超了直接返回 ResourceExhausted,链路到此终止),随后过「熔断 Interceptor」判断下游健康状态,全部放行后才抵达「真正 handler 业务代码」。任何一个拦截器拒绝,请求就停在那一环,后面的拦截器和业务代码都不会执行——这就是拦截器「链」的串行语义,也是限流、熔断能统一挂载的原因。

服务端 Interceptor 基本骨架

先用最简单的日志拦截器把骨架摸清楚:

// pkg/middleware/logging.go
package middleware

import (
	"context"
	"log"
	"time"

	"google.golang.org/grpc"
)

// LogInterceptor 简单日志拦截器
func LogInterceptor(
	ctx context.Context,
	req any,
	info *grpc.UnaryServerInfo,
	handler grpc.UnaryHandler,
) (any, error) {
	// 前置逻辑:记录调用开始
	start := time.Now()
	// 调用真正的 handler(业务代码在这里执行)
	resp, err := handler(ctx, req)
	// 后置逻辑:记录耗时和错误
	log.Printf("method=%s cost=%v err=%v", info.FullMethod, time.Since(start), err)
	return resp, err
}

注册时用 ChainUnaryInterceptor 串联多个拦截器(和 Gin 的 r.Use(a, b, c) 一个意思):

// cmd/server/main.go(节选)
server := grpc.NewServer(
	grpc.ChainUnaryInterceptor(
		middleware.LogInterceptor,
		// 限流、熔断拦截器往下排……
	),
)

四个参数各自是什么:

参数是什么用途
ctx请求上下文拿链路信息(trace、超时等)
req客户端发来的请求体业务级限流时从里面取 UID 等字段
info调用信息info.FullMethod 是方法全名,如 /user.v1.UserService/GetProfile
handler真正的业务处理函数调它就是放行,不调就是拦截

接入限流拦截器:三种粒度

粒度 1:整个应用统一限流(固定 key)——所有方法共享一个额度:

// pkg/middleware/ratelimit.go
package middleware

import (
	"context"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

// Limiter 限流器接口(跟写部分会实现它)
type Limiter interface {
	Allow(key string) bool
	Release(key string)
}

// GRPCLimitInterceptor 全应用统一限流
func GRPCLimitInterceptor(l Limiter) grpc.UnaryServerInterceptor {
	return func(ctx context.Context, req any,
		info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
		// 步骤 1:用固定 key 对整个应用一起限流
		const key = "global"
		if !l.Allow(key) {
			return nil, status.Error(codes.ResourceExhausted, "rate limited")
		}
		// 步骤 2:处理完释放槽位
		defer l.Release(key)
		return handler(ctx, req)
	}
}

codes.ResourceExhausted 是 gRPC 标准错误码「资源耗尽」,专门用于限流拒绝——客户端看到它就知道该退避重试,而不是当成 bug 上报。

粒度 2:服务级限流(按方法名分额度)——每个 RPC 方法一个独立的桶:

// pkg/middleware/ratelimit.go(续)
// ServiceLimitInterceptor 按 FullMethod 限流,粒度更细
func ServiceLimitInterceptor(l Limiter) grpc.UnaryServerInterceptor {
	return func(ctx context.Context, req any,
		info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
		// 步骤 1:用方法名作为 key,针对每个方法独立限流
		if !l.Allow(info.FullMethod) {
			return nil, status.Error(codes.ResourceExhausted, "rate limited")
		}
		// 步骤 2:处理完释放
		defer l.Release(info.FullMethod)
		return handler(ctx, req)
	}
}

好处是「隔离」:评论接口被刷爆时,额度是它自己的桶,不影响发帖接口。

粒度 3:业务级限流(按 UID 等业务字段)——每个用户单独一个额度,防单用户刷接口:

// pkg/middleware/ratelimit.go(续)
// uidRequest 约定:实现了 GetUid 的请求都能被业务级限流
type uidRequest interface {
	GetUid() int64
}

// BizLimitInterceptor 按业务字段(如 UID)限流
func BizLimitInterceptor(l Limiter) grpc.UnaryServerInterceptor {
	return func(ctx context.Context, req any,
		info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
		// 步骤 1:类型断言,取出 UID 作为 key
		if r, ok := req.(uidRequest); ok {
			key := strconv.FormatInt(r.GetUid(), 10)
			if !l.Allow(key) {
				return nil, status.Error(codes.ResourceExhausted, "rate limited")
			}
			// 步骤 2:处理完释放
			defer l.Release(key)
		}
		// 没实现 GetUid 的请求直接放行
		return handler(ctx, req)
	}
}

这里用到 Go 接口的隐式实现(第 04 章讲过):不需要修改 proto 生成的代码,只要请求结构体恰好有 GetUid() int64 方法,就能被断言成 uidRequest——优雅地把「有 UID 的请求」挑出来限流。

复习提示:缺陷:所有请求都会经过这个拦截器,但大部分不实现 GetUid(类型断言失败直接放行),白白浪费一点 CPU。拦截器是「全局税」,越通用越要轻。

替代方案:装饰器模式。针对专有业务的限流,用装饰器更干净——只包装目标方法,不影响其他任何请求:

// internal/service/user_service_ratelimited.go
// RateLimitedUserService 用装饰器模式给 UserService 加限流
type RateLimitedUserService struct {
	UserServiceServer            // 内嵌原始实现(继承它的所有方法)
	limiter           Limiter
}

func (r *RateLimitedUserService) GetProfile(ctx context.Context,
	req *GetProfileReq) (*ProfileResp, error) {
	// 步骤 1:按 UID 限流
	key := strconv.FormatInt(req.GetUid(), 10)
	if !r.limiter.Allow(key) {
		return nil, status.Error(codes.ResourceExhausted, "rate limited")
	}
	// 步骤 2:放行到被装饰的原始实现
	defer r.limiter.Release(key)
	return r.UserServiceServer.GetProfile(ctx, req)
}

装饰器模式(第 04 章接口抽象时用过):新结构体内嵌旧实现,只重写需要「加料」的方法。三种粒度怎么选?

方式粒度开销适用
全应用 key最粗最低兜底总闸
FullMethod中低大部分场景的默认选择
UID 断言细全局请求都交「税」防单用户滥用
装饰器细且精确只影响目标方法个别接口需要特殊策略

接入熔断拦截器:用 aegis 现成的轮子

熔断器不需要手写生产版——直接用 Kratos 的子项目 github.com/go-kratos/aegis(即使项目不用 Kratos 框架也能单独用,它就是个独立的 Go 库):

// pkg/middleware/circuitbreaker.go
package middleware

import (
	"context"

	"github.com/go-kratos/aegis/circuitbreaker"
	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

// CircuitBreakerInterceptor 熔断拦截器
func CircuitBreakerInterceptor(cb *circuitbreaker.Breaker) grpc.UnaryServerInterceptor {
	return func(ctx context.Context, req any,
		info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
		// 步骤 1:Allow 返回 error 表示熔断已开启,直接拒绝
		if err := cb.Allow(); err != nil {
			return nil, status.Error(codes.Unavailable, "circuit breaker open")
		}
		// 步骤 2:调用业务,拿到结果与错误
		resp, err := handler(ctx, req)
		// 步骤 3:关键——成对上报成功/失败,否则熔断状态永不更新
		if err != nil {
			cb.MarkFailed()
		} else {
			cb.MarkSuccess()
		}
		return resp, err
	}
}
复习提示:⚠️ 新手必踩的坑 1:在 Allow 之后、handler 之前就 return。有些写法把「熔断打开就返回」和「统计」分开写,结果 Open 期间根本没调用 MarkSuccess/MarkFailure,状态机卡死。务必保证「放行 → 调 handler → 无论成败都 Mark」这一对逻辑完整执行。
复习提示:⚠️ 讲义特别提醒:aegis 真实的 CircuitBreaker 接口是三个方法——Allow() error(返回 error 即拒绝)+ MarkSuccess() / MarkFailed()(注意方法名是 MarkFailed,别拼成 MarkFailure),上面代码与真实 API 一致。集成时仍应以官方文档为准——这也是「不要凭记忆写 API,要看文档」的活例子(第 01 章质量红线同款教训)。
复习提示:aegis 还提供了基于 BBR 算法的自适应限流,限流也可以考虑用这个。

接入降级:没法通用的「通用方案」

降级难以做成通用拦截器——具体怎么降级和业务强相关(作者服务挂了显示「未知用户」,评论服务挂了隐藏评论区,每种降级都不一样)。但可以借助限流器 / 熔断器的状态判断要不要降级:

// internal/service/user_service.go(节选)
// 业务代码里结合限流器状态做降级
func (s *UserService) GetProfile(ctx context.Context, uid int64) (*Profile, error) {
	// 步骤 1:已触发限流时,只查缓存,不碰 DB
	if s.limiter.IsTriggered() {
		p, err := s.cache.Get(ctx, uid)
		if err == nil {
			return p, nil
		}
		// 缓存也未命中,降级到底:返回错误
		return nil, fmt.Errorf("get profile: 限流降级且缓存未命中") // 生产中应返回项目统一的限流错误码,供上层识别
	}
	// 步骤 2:正常路径:查缓存,未命中查 DB 并回写
	return s.cacheAside.Get(ctx, uid)
}

跟写部分第 4 步会把降级抽象成 Strategy 接口 + 管理器,让「判断要不要降级」和「降级后返回什么」各自独立演化。

小收口:三板斧都挂上了拦截器,但还差一块拼图——超时。一个没设超时的请求即使被限流放过、被熔断放行,也可能在下游无限等待,把前面所有保护都架空。


3.6 超时控制:被遗忘的第四板斧

单次调用超时

调用时创建带超时的 ctx 即可(第 04 章的老朋友):

// internal/client/user.go(节选)
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel() // 不 cancel 会泄漏 context 资源
resp, err := client.GetProfile(ctx, req)

链路超时:gRPC 自带的「自动缩短」

A→B→C 调用链中,B 给 C 设置的超时时间 = A 设置的超时 − 已消耗时间(剩余超时时间)。这是通过 ctx 沿调用链传递实现的:

sequenceDiagram
    participant A as 服务 A(超时1s)
    participant B as 服务 B
    participant C as 服务 C
    A->>B: 调用(剩余超时约1s)
    Note over B: B 内部耗了 300ms
    B->>C: 调用(剩余超时 = 1s - 300ms = 700ms)
    Note over C: 只拿到剩余时间,不会超限

gRPC 自带链路超时:超时信息写在 gRPC 的 metadata 里随请求传播,服务端收到的 ctx 已经是「剩余超时时间」,无需额外处理。你在 C 里 ctx.Done() 触发的时刻,就是 A 的 1 秒耗尽的时刻——三层服务对「什么时候该放弃」达成了一致。

如果每层各自独立设 1 秒超时会怎样?A 等 B 最多 1 秒、B 等 C 最多 1 秒、C 内部又可能等 D 1 秒——用户早刷新页面走人了,你的服务还在傻等。链路超时的意义:全链路共享同一个倒计时。

超时时间怎么定

四个来源,按可信度排序:

  1. 理论上:从用户体验角度,产品经理说「用户最多能等多久」,这就是链路总超时;
  2. 观测数据:接口 P999 是 1s(第 08 章 Prometheus 的分位数),就设 1s——让 99.9% 的请求能正常完成;
  3. 手动计算:分析代码,如 DB 查询 10ms × 2 次 + 网络开销 + 余量;
  4. 经验值:首页接口应在 100ms 内,1s 是「明显卡顿」的临界点。
复习提示:⚠️ 新手必踩的坑:给依赖方设超时比自己还长。A 调 B、A 调 C,A 的总超时 1s,结果给 B 设了 2s——B 还没超时,A 已经先放弃了,2s 的设置毫无意义还占资源。依赖的超时之和必须小于等于自己的总超时,由链路超时自动保证,这也是别手动层层设超时的原因。

3.7 限流阈值如何确定

三板斧都接好了,最后一个现实问题:阈值到底设多少? 设小了误伤正常流量,设大了形同虚设。

标准答案:压测

用压测工具(第 19 章会系统学)把 QPS 从 0 逐渐推高,画出「响应时间 + 吞吐量」随并发数变化的曲线,上面有三个关键点:

点含义适用场景
A性能最好点(响应时间还稳定)追求最佳性能和较高资源利用率
B系统快崩溃的临界点追求更高并发,性能要求不严
C吞吐量最高点追求最大吞吐量
flowchart LR
    subgraph 压测曲线
        O[并发数从 0 持续加压] --> P1[A 点: 响应时间开始上翘
设阈值在这附近最稳] P1 --> P2[C 点: 吞吐量峰值] P2 --> P3[B 点: 再压就要崩了] end

面试最好回答「压测确定」——这是唯一有数据支撑的答案。

没法压测时的替代方案

新项目上线前常常没条件压测,退而求其次:

  • 已有接口:用现有观测数据(第 08 章的 Prometheus QPS、P99 指标);
  • 新接口:参考类似接口的阈值;
  • 手动计算:DB 查询 10ms × 2 次 = 单请求 20ms,4 核 CPU 理论上 1000/20 × 4 = 200 QPS。

预估后预留一些空间(阈值上调一档)——宁可第一版松一点,也别上线就把正常流量拦在门外。


3.8 可观测性接入:治理的前提是看得见

微服务中要接入日志、Trace、Prometheus,都是通过 Interceptor 实现,和上面限流拦截器套路一致——第 08 章已经讲过 Prometheus 埋点和 OpenTelemetry 链路追踪,这里只说 gRPC 场景的接入点差异。

日志建议记三类信息:

  • 客户端信息:尽量拿到客户端的应用名、IP + 端口(或实例 ID)——不然出了问题都不知道是谁调的;
  • 调用情况:调用的方法名(info.FullMethod);DEBUG 环境可以考虑把请求和响应也记下来;
  • 执行情况:是否有 error、错误码是什么。
// pkg/middleware/logging.go(完整版思路)
func LogInterceptor(ctx context.Context, req any,
	info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
	// 步骤 1:记录开始时间
	start := time.Now()
	// 步骤 2:调用 handler
	resp, err := handler(ctx, req)
	// 步骤 3:记录耗时、错误码、方法名
	log.Printf("method=%s cost=%v err=%v", info.FullMethod, time.Since(start), err)
	return resp, err
}

Trace 和 Prometheus 接入思路类似:拦截器里调 handler 前后,把耗时、错误码、方法名喂给 OpenTelemetry(打 Span)或 Prometheus(Counter / Summary 指标),只是输出目的地不同。第 08 章的 Gin 中间件埋点怎么写,这里的 gRPC Interceptor 就怎么写——中间件模式一次学会,处处能用。

小收口:正文到这里,三板斧 + 超时 + 阈值方法 + 可观测性全部讲完。但它们还是散装的——接下来「跟着写」把它们组装成三个可以复用的包,真正挂进项目里。


四、跟着写:分步实现

上一章我们在 pkg/balancer 里做的「连续失败剔除、半开试探」健康检查,其实就是熔断器的雏形。本章把治理三板斧落成三个可复用的包:

  • pkg/circuitbreaker:三态熔断器 + gRPC 客户端拦截器(3.2 节入门版的升级);
  • pkg/ratelimit:令牌桶 + 分桶滑动窗口,按 key 覆盖全局 / 接口 / 用户级三种粒度;
  • pkg/degradation:Strategy 接口 + 帖子列表「未知用户」兜底。

正文 3.2 / 3.4 节是最小原理版,这里写的是带 zap 日志、按维度管理、可用 viper 配置的工程版,算法思路与正文一致。

第 1 步:生产级熔断器(pkg/circuitbreaker)

与 3.2 节入门版相比,判定条件从「错误数阈值」升级为「窗口内失败率 ≥ 阈值且请求数 ≥ 最小请求数」(正是 3.2 节末尾提示的改进方向),并用环形分桶的滑动窗口代替单一计数器。

先写状态与配置:

// pkg/circuitbreaker/circuitbreaker.go
package circuitbreaker

import (
	"errors"
	"sync"
	"time"

	"go.uber.org/zap"
)

// State 熔断器三态(原理见 3.2 节)
type State int32

const (
	StateClosed   State = iota // 关闭:正常放行,统计失败率
	StateOpen                  // 打开:快速失败,不发请求
	StateHalfOpen              // 半开:放少量试探请求
)

var ErrCircuitOpen = errors.New("circuit: breaker is open")

// Config 配置项(注释为推荐默认值)
type Config struct {
	Window, OpenTimeout      time.Duration // 统计窗口 / Open 到期转 HalfOpen,默认 10s / 30s
	MinRequests, HalfOpenMax int           // 最小请求数 / 半开试探数,默认 20 / 5
	FailureRate              float64       // 失败率阈值:0.5 表示 50%
}

const bucketCount = 10 // 滑动窗口切成 10 个小时间片,环形复用

type bucket struct{ success, failure int64 }

核心结构体——注意 buckets [bucketCount]bucket 这个数组:10 个小桶围成一个环,时间每走一个桶的时长,就往前进一格,路过的旧桶清零。这就是「环形分桶滑动窗口」:

// pkg/circuitbreaker/circuitbreaker.go(续)
// CircuitBreaker 一个 service+method 维度一个实例
type CircuitBreaker struct {
	mu           sync.Mutex
	cfg          Config
	state        State
	buckets      [bucketCount]bucket // 环形分桶的滑动窗口
	bucketSize   time.Duration       // 小时间片时长 = Window/10
	curBucket    int                 // 当前时间片下标
	lastTime     time.Time           // 窗口已推进到的时间
	openedAt     time.Time           // 进入 Open 的时刻
	halfOpenUsed int                 // HalfOpen 已放行的试探数
	logger       *zap.Logger
}

// New 创建熔断器;logger 传 nil 则不打日志
func New(name string, cfg Config, logger *zap.Logger) *CircuitBreaker {
	if logger == nil {
		logger = zap.NewNop()
	}
	return &CircuitBreaker{cfg: cfg, bucketSize: cfg.Window / bucketCount,
		lastTime: time.Now(), logger: logger.With(zap.String("breaker", name))}
}

// State 返回当前状态,给降级判断、指标上报用
func (cb *CircuitBreaker) State() State {
	cb.mu.Lock()
	defer cb.mu.Unlock()
	return cb.state
}

窗口推进逻辑——advance 是环形分桶的灵魂,后面限流器的滑动窗口也用同一招:

// pkg/circuitbreaker/circuitbreaker.go(续)
// advance 把窗口推进到 now,顺路清零"路过"的旧时间片
func (cb *CircuitBreaker) advance(now time.Time) {
	steps := int(now.Sub(cb.lastTime) / cb.bucketSize)
	if steps <= 0 {
		return
	}
	if steps >= bucketCount { // 距上次请求超过整个窗口:全部过期
		cb.buckets = [bucketCount]bucket{}
	} else {
		for i := 1; i <= steps; i++ {
			cb.buckets[(cb.curBucket+i)%bucketCount] = bucket{}
		}
	}
	cb.curBucket = (cb.curBucket + steps) % bucketCount
	cb.lastTime = cb.lastTime.Add(time.Duration(steps) * cb.bucketSize)
}

Allow 与入门版结构一致,多了日志:

// pkg/circuitbreaker/circuitbreaker.go(续)
// Allow 调用前检查:返回 nil 放行;返回 ErrCircuitOpen 直接快速失败
func (cb *CircuitBreaker) Allow() error {
	cb.mu.Lock()
	defer cb.mu.Unlock()
	switch cb.state {
	case StateClosed:
		return nil
	case StateOpen:
		if time.Since(cb.openedAt) < cb.cfg.OpenTimeout {
			return ErrCircuitOpen // 未到期:继续快速失败
		}
		cb.state = StateHalfOpen // 到期:转半开,开始试探
		cb.halfOpenUsed = 0
		cb.logger.Info("circuit: Open -> HalfOpen")
		fallthrough
	case StateHalfOpen:
		if cb.halfOpenUsed >= cb.cfg.HalfOpenMax { // 试探名额有限
			return ErrCircuitOpen
		}
		cb.halfOpenUsed++
		return nil
	}
	return nil
}

注意 Go 的 fallthrough:Open 到期转 HalfOpen 后不再重复判断,直接落入 HalfOpen 分支占用一个试探名额——两个状态共享「放试探请求」的逻辑。

上报与判定——和入门版最大的差异在 checkTrip:

// pkg/circuitbreaker/circuitbreaker.go(续)
// MarkSuccess / MarkFailure 拿到调用结果后成对上报
func (cb *CircuitBreaker) MarkSuccess() { cb.mark(true) }
func (cb *CircuitBreaker) MarkFailure() { cb.mark(false) }

func (cb *CircuitBreaker) mark(success bool) {
	cb.mu.Lock()
	defer cb.mu.Unlock()
	switch cb.state {
	case StateHalfOpen: // 一次成功即恢复,一次失败立刻回 Open
		if success {
			cb.toState(StateClosed, "probe success")
		} else {
			cb.toState(StateOpen, "probe failure")
		}
		return
	case StateOpen:
		return // 被拒的请求没真正发出,不参与统计(3.2 节的坑 2)
	}
	cb.advance(time.Now())
	if success {
		cb.buckets[cb.curBucket].success++
	} else {
		cb.buckets[cb.curBucket].failure++
	}
	cb.checkTrip()
}

// checkTrip Closed 态判定:样本足够且失败率达标才熔断
func (cb *CircuitBreaker) checkTrip() {
	var total, failure int64
	for _, b := range cb.buckets {
		total += b.success + b.failure
		failure += b.failure
	}
	if total < cb.cfg.MinRequests {
		return // 样本不足不判定,防止小流量误熔断
	}
	if float64(failure)/float64(total) >= cb.cfg.FailureRate {
		cb.toState(StateOpen, "failure rate over threshold")
	}
}

// toState 状态迁移统一入口:清空统计并打日志
func (cb *CircuitBreaker) toState(s State, reason string) {
	cb.state = s
	cb.halfOpenUsed = 0
	cb.buckets = [bucketCount]bucket{}
	if s == StateOpen {
		cb.openedAt = time.Now()
		cb.logger.Warn("circuit: -> Open", zap.String("reason", reason))
	} else {
		cb.logger.Info("circuit: -> Closed", zap.String("reason", reason))
	}
}

最后是 Group——按「service+method」维度管理一组熔断器(手册规格:每个客户端方法一个):

// pkg/circuitbreaker/circuitbreaker.go(续)
// Group 按 service+method 维度管理一组熔断器
type Group struct {
	mu       sync.Mutex
	cfg      Config
	logger   *zap.Logger
	breakers map[string]*CircuitBreaker
}

func NewGroup(cfg Config, logger *zap.Logger) *Group {
	return &Group{cfg: cfg, logger: logger, breakers: map[string]*CircuitBreaker{}}
}

// Get 取指定维度的熔断器,不存在则创建(配置组内共享)
func (g *Group) Get(name string) *CircuitBreaker {
	g.mu.Lock()
	defer g.mu.Unlock()
	if cb, ok := g.breakers[name]; ok {
		return cb
	}
	g.breakers[name] = New(name, g.cfg, g.logger)
	return g.breakers[name]
}

验证:

go build ./pkg/circuitbreaker/

预期输出:无任何输出(编译通过)。测试闭环在第 4 步后统一跑。

复习提示:为什么一个方法一个熔断器?如果整个 user 服务共享一个熔断器,GetProfile 挂了会把 UpdateProfile 一起熔断——明明后者是好的。按方法隔离,故障影响面最小化。这和限流按 FullMethod 分桶是同一个思想:隔离。

第 2 步:限流器(pkg/ratelimit,令牌桶 + 滑动窗口)

先写令牌桶。与 3.4 节入门版的差异有三处:① 懒计算用 float64 存令牌(支持速率小于 1 个/秒);② 按 key 维护独立桶——全局级传 "global"、接口级传方法名、用户级传 "uid:1001",一套实现覆盖三种粒度;③ 带拒绝日志。

// pkg/ratelimit/tokenbucket.go
package ratelimit

import (
	"sync"
	"time"

	"go.uber.org/zap"
)

// tokenBucket 单个令牌桶(懒计算版:无需后台协程补令牌)
type tokenBucket struct {
	mu         sync.Mutex
	tokens     float64   // 当前令牌数;浮点数才能支持速率 < 1 个/秒
	lastRefill time.Time // 上次补充令牌的时间
}

// TokenBucketLimiter 按 key 维护独立令牌桶,key 就是限流维度
type TokenBucketLimiter struct {
	buckets        sync.Map // key -> *tokenBucket
	capacity, rate float64  // 桶容量(决定可突发量)/ 每秒补充数(决定长期 QPS)
	logger         *zap.Logger
}

func NewTokenBucketLimiter(capacity, ratePerSec int, logger *zap.Logger) *TokenBucketLimiter {
	if logger == nil {
		logger = zap.NewNop()
	}
	return &TokenBucketLimiter{capacity: float64(capacity),
		rate: float64(ratePerSec), logger: logger}
}

// Allow 尝试为 key 取 1 个令牌:取到放行,取不到拒绝
func (l *TokenBucketLimiter) Allow(key string) bool {
	b, _ := l.buckets.LoadOrStore(key,
		&tokenBucket{tokens: l.capacity, lastRefill: time.Now()}) // 新桶初始满,允许冷启动突发
	tb := b.(*tokenBucket)
	tb.mu.Lock()
	defer tb.mu.Unlock()
	now := time.Now()
	tb.tokens += now.Sub(tb.lastRefill).Seconds() * l.rate // 懒计算:应补令牌 = 时间差 × 速率
	if tb.tokens > l.capacity {
		tb.tokens = l.capacity // 桶满,多余令牌丢弃
	}
	tb.lastRefill = now
	if tb.tokens >= 1 {
		tb.tokens--
		return true
	}
	l.logger.Warn("ratelimit: rejected", zap.String("key", key))
	return false
}

sync.Map(第 01 章提过的并发安全 map)在这里很合适:读多写少(同一个 key 的桶只创建一次),LoadOrStore 原子地「取或建」。

复习提示:⚠️ 新手必踩的坑:令牌数用整数存。当限流速率低于 1 个/秒(比如每 2 秒放行 1 个)时,int64(时间差 × rate) 恒为 0,桶永远补不进令牌,限流器直接变成「全拒」。所以工程版用 float64 累积小数令牌,只在消费时判断 >= 1。另一个坑:按 key 存桶的 map 会随 key 无限增长(用户级限流的 key 是千万级 uid),生产上要加定时清理或 LRU 淘汰。

再写分桶滑动窗口(大窗口切成 10 个小时间片各自计数,窗口内总数超阈值则拒绝;环形推进逻辑与熔断器 advance 同一思路,对照着看):

// pkg/ratelimit/slidingwindow.go
package ratelimit

import (
	"sync"
	"time"

	"go.uber.org/zap"
)

// slidingWindow 大窗口切成 10 个小时间片,各自计数
type slidingWindow struct {
	mu       sync.Mutex
	counts   [10]int64
	cur      int       // 当前时间片下标
	lastTime time.Time // 窗口已推进到的时间
}

// SlidingWindowLimiter 按 key 维护独立滑动窗口
type SlidingWindowLimiter struct {
	windows   sync.Map
	window    time.Duration // 总窗口大小,如 1s
	threshold int64         // 窗口内允许的最大请求数
	logger    *zap.Logger
}

func NewSlidingWindowLimiter(window time.Duration, threshold int64,
	logger *zap.Logger) *SlidingWindowLimiter {
	if logger == nil {
		logger = zap.NewNop()
	}
	return &SlidingWindowLimiter{window: window, threshold: threshold, logger: logger}
}

// Allow 判断 key 本次请求是否放行(窗口推进与熔断器 advance 同一思路)
func (l *SlidingWindowLimiter) Allow(key string) bool {
	w, _ := l.windows.LoadOrStore(key, &slidingWindow{lastTime: time.Now()})
	sw := w.(*slidingWindow)
	sw.mu.Lock()
	defer sw.mu.Unlock()
	now, slot := time.Now(), l.window/10 // slot = 小时间片时长
	if steps := int(now.Sub(sw.lastTime) / slot); steps > 0 {
		if steps >= 10 {
			sw.counts = [10]int64{}
		} else {
			for i := 1; i <= steps; i++ {
				sw.counts[(sw.cur+i)%10] = 0
			}
		}
		sw.cur = (sw.cur + steps) % 10
		sw.lastTime = sw.lastTime.Add(time.Duration(steps) * slot)
	}
	var total int64 // 整个窗口内的总请求数
	for _, c := range sw.counts {
		total += c
	}
	if total >= l.threshold {
		l.logger.Warn("ratelimit: rejected", zap.String("key", key))
		return false
	}
	sw.counts[sw.cur]++
	return true
}

给它俩写一个测试(注意:令牌桶测试里的断言依赖真实时间,在 CI 机器负载高时可能抖动——这是时间相关测试的通病,本地跑通即可):

// pkg/ratelimit/ratelimit_test.go
package ratelimit

import "testing"

func TestTokenBucket(t *testing.T) {
	// 容量 5、速率 100/s:初始能连过 5 个,第 6 个被拒
	l := NewTokenBucketLimiter(5, 100, nil)
	for i := 0; i < 5; i++ {
		if !l.Allow("global") {
			t.Fatalf("第 %d 个请求不应被拒绝", i+1)
		}
	}
	if l.Allow("global") {
		t.Fatal("第 6 个请求应被拒绝")
	}
}

func TestSlidingWindow(t *testing.T) {
	// 窗口 1s、阈值 3:连发 3 个通过,第 4 个被拒
	l := NewSlidingWindowLimiter(1e9, 3, nil)
	for i := 0; i < 3; i++ {
		if !l.Allow("global") {
			t.Fatalf("第 %d 个请求不应被拒绝", i+1)
		}
	}
	if l.Allow("global") {
		t.Fatal("第 4 个请求应被拒绝")
	}
}

验证:

go test ./pkg/ratelimit/ -v

预期输出:

=== RUN   TestTokenBucket
--- PASS: TestTokenBucket (0.00s)
=== RUN   TestSlidingWindow
--- PASS: TestSlidingWindow (0.00s)
PASS
ok  	content-community/pkg/ratelimit

第 3 步:gRPC 客户端拦截器(熔断 + 超时)

手册规格要求熔断挂在客户端:下游某方法挂了,就别把请求发出去(3.5 节讲过:熔断保护调用方)。按 FullMethod 维度取熔断器,调用前查 Allow,调用后按错误类别上报——超时、5xx 算失败,4xx(参数错等业务错)算成功:

// pkg/circuitbreaker/interceptor.go
package circuitbreaker

import (
	"context"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

// GRPCClientInterceptor gRPC 客户端熔断拦截器(按 FullMethod 维度)
func GRPCClientInterceptor(g *Group) grpc.UnaryClientInterceptor {
	return func(ctx context.Context, method string, req, reply any,
		cc *grpc.ClientConn, invoker grpc.UnaryInvoker,
		opts ...grpc.CallOption) error {
		cb := g.Get(method) // 一个 service+method 一个熔断器
		// 步骤 1:调用前检查,Open 就地快速失败,不发网络请求
		if err := cb.Allow(); err != nil {
			return status.Error(codes.Unavailable, "circuit breaker open: "+method)
		}
		// 步骤 2:真正发起 RPC;步骤 3:按错误类别上报统计
		err := invoker(ctx, method, req, reply, cc, opts...)
		cb.report(err)
		return err
	}
}

// report 错误分类上报:只有"下游故障类"错误才触发熔断统计
func (cb *CircuitBreaker) report(err error) {
	if err == nil {
		cb.MarkSuccess()
		return
	}
	switch status.Code(err) {
	case codes.DeadlineExceeded, codes.Unavailable, codes.Canceled, // 超时/连不上/取消
		codes.Internal, codes.Unknown: // 服务端内部错误等 5xx 类
		cb.MarkFailure()
	default: // InvalidArgument / NotFound 等业务错:调用本身是成功的
		cb.MarkSuccess()
	}
}
复习提示:⚠️ 新手必踩的坑:把参数错误也算失败。InvalidArgument(参数不合法)、NotFound(查无此人)这类业务错说明「调用本身成功,是业务上拒绝了」——算成功;只有超时、Unavailable 等故障类才算失败。分错类别会把下游的正常报错误判成故障,误触发熔断。

接入方式与上一章负载均衡相同:grpc.Dial 时加一行 grpc.WithChainUnaryInterceptor(circuitbreaker.GRPCClientInterceptor(cbGroup));每次调用记得带超时(一般接口 1s,见 3.6 节),超时同样会被 report 记为失败参与熔断统计。

验证:

go build ./pkg/circuitbreaker/ && go test ./pkg/circuitbreaker/ -v

预期输出:编译无输出;测试里补一个「失败率超阈值 → Open → 到期 HalfOpen → 试探成功回 Closed」的闭环用例(把 Window、OpenTimeout 设小到 200ms 级别避免测试跑太久),全部 PASS。

第 4 步:降级策略(pkg/degradation + 帖子列表兜底)

按手册规格定义 Strategy 接口与统一管理器:事前判定要降级就直接走 Fallback,正常调用失败也走 Fallback 兜底——降级的核心是「尽可能返回响应」(见 3.3 节):

// pkg/degradation/degradation.go
package degradation

import (
	"context"
	"sync"

	"go.uber.org/zap"
)

// Strategy 降级策略接口:每个可降级的方法实现一份
type Strategy interface {
	ShouldDegrade() bool                                // 当前是否需要降级(如:熔断器已打开)
	Fallback(ctx context.Context, req any) (any, error) // 兜底:返回默认值/缓存/简化数据
}

// Manager 降级管理器:按方法注册策略、执行带降级的调用
type Manager struct {
	mu         sync.RWMutex // 注册只发生在启动期,读多写少
	strategies map[string]Strategy
	logger     *zap.Logger
}

func NewManager(logger *zap.Logger) *Manager {
	return &Manager{strategies: map[string]Strategy{}, logger: logger}
}

// Register 给方法注册降级策略(也可接配置中心做一键降级开关)
func (m *Manager) Register(method string, s Strategy) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.strategies[method] = s
}

// Do 执行一次带降级的调用:事前判定要降级,或正常调用失败,都走 Fallback
func (m *Manager) Do(ctx context.Context, method string, req any,
	call func(context.Context, any) (any, error)) (any, error) {
	m.mu.RLock()
	s := m.strategies[method]
	m.mu.RUnlock()
	if s != nil && s.ShouldDegrade() {
		m.logger.Warn("degradation: triggered", zap.String("method", method))
		return s.Fallback(ctx, req)
	}
	resp, err := call(ctx, req)
	if err == nil || s == nil {
		return resp, err // 调用成功,或没注册策略:原样返回
	}
	m.logger.Warn("degradation: fallback on error",
		zap.String("method", method), zap.Error(err))
	return s.Fallback(ctx, req)
}

接口设计的巧思:ShouldDegrade 和 Fallback 分开——「要不要降级」可以看熔断器状态,「降级返回什么」由业务自己定,两者独立演化。

手册里的经典示例:帖子列表依赖作者服务,作者服务熔断时降级为占位「未知用户」,帖子列表照常可看:

// internal/service/post_service.go(节选)
type Author struct{ Nickname, Avatar string }

// AuthorFallback 作者服务的降级策略:熔断打开就不查了,返回占位作者
type AuthorFallback struct {
	breaker *circuitbreaker.CircuitBreaker // 复用第 1 步的熔断器状态
}

func (f *AuthorFallback) ShouldDegrade() bool {
	return f.breaker.State() == circuitbreaker.StateOpen
}

func (f *AuthorFallback) Fallback(_ context.Context, _ any) (any, error) {
	return &Author{Nickname: "未知用户", Avatar: "/img/avatar-default.png"}, nil
}

// GetPostList 作者信息用 degrade.Do 包一层;启动时注册:
// degrade.Register("user.GetAuthor",
//   &AuthorFallback{breaker: cbGroup.Get("/user.v1.UserService/GetAuthor")})
func (s *PostService) GetPostList(ctx context.Context) ([]*Post, error) {
	posts, err := s.repo.List(ctx)
	if err != nil {
		return nil, fmt.Errorf("list posts: %w", err)
	}
	for _, p := range posts {
		v, err := s.degrade.Do(ctx, "user.GetAuthor", p.AuthorID,
			func(ctx context.Context, req any) (any, error) {
				return s.userClient.GetAuthor(ctx, req.(int64)) // 正常路径
			})
		if err == nil {
			p.Author = v.(*Author)
		}
	}
	return posts, nil
}

验证:go build ./... 无输出即编译通过。手动验证:停掉作者服务再刷帖子列表 → 接口仍正常返回,作者昵称显示「未知用户」,日志出现 degradation: triggered 与 circuit: -> Open。

本章完成后的项目结构

content-community/
├── configs/config.yaml             # 追加 governance.circuitbreaker 配置
└── pkg/
    ├── circuitbreaker/             # 【本章新增】熔断
    │   ├── circuitbreaker.go       # 三态状态机 + 滑动窗口统计 + Group
    │   ├── circuitbreaker_test.go  # 熔断→半开→恢复闭环测试
    │   └── interceptor.go          # gRPC 客户端熔断拦截器
    ├── ratelimit/                  # 【本章新增】限流
    │   ├── tokenbucket.go          # 令牌桶(懒计算 + 按 key 维度)
    │   ├── slidingwindow.go        # 分桶滑动窗口
    │   └── ratelimit_test.go       # 两种限流器测试
    └── degradation/                # 【本章新增】降级
        └── degradation.go          # Strategy 接口 + Manager

验证清单

  • go test ./pkg/circuitbreaker/ -v 通过:失败率超阈值 → Open → 到期 HalfOpen → 试探成功回 Closed
  • go test ./pkg/ratelimit/ -v 通过:令牌桶容量耗尽拒绝、按速率补充;滑动窗口超阈值拒绝
  • go build ./... 无报错,gRPC 客户端已挂上 GRPCClientInterceptor
  • 人为让下游返回 Unavailable:日志出现 circuit: -> Open,之后调用直接快速失败、不再发网络请求
  • 等 open_timeout(可临时改小到 1s)后出现 Open -> HalfOpen,下游恢复则回到 Closed
  • 作者服务熔断期间,帖子列表仍能返回,作者昵称显示「未知用户」

五、工程实践要点

  1. 优先限流,慎用熔断。熔断破坏性强(跳闸期间正常请求也被拒绝),阈值要保守;限流是日常工具,大促前调阈值是常规操作。使用频率:限流 > 降级 > 熔断。
  2. 限流阈值靠压测。没法压测就参考类似接口 + 预留空间;上线后结合第 08 章的 Prometheus 指标持续校准。
  3. gRPC 超时控制自带链路传递,直接用 context.WithTimeout 即可,不要层层手动设超时——依赖超时之和超过总超时的设置毫无意义。
  4. 降级业务强相关,难以做成通用拦截器,但可借助限流 / 熔断状态作为降级开关(ShouldDegrade)。
  5. 拦截器粒度要选好:全应用限流粒度粗,业务字段限流粒度细但「全局交税」,装饰器最干净。拦截器越通用越要轻。
  6. 务必接入可观测性:日志、Trace、Metrics 是治理的基础——没有观测就没有治理,你连「该不该熔断」都无法判断。
  7. 重试要小心放大效应:A→B→C 链路,每层重试 3 次会放大 27 倍流量(第 13 章 Failover 的教训),必须配合超时控制,且只在本该成功的地方重试(幂等接口才可重试,第 15 章支付会专门讲幂等)。
  8. 按 key 存桶 / 存熔断器的 map 要防无限增长:用户级限流的 key 是千万级 uid,生产上要加定时清理或 LRU 淘汰,否则内存缓慢泄漏。

六、自测题与动手练习

自测题(先别翻正文,合上书能答出来才算懂;答案要点都藏在正文里):

  1. 用自己的话(不许说术语)讲一遍服务雪崩是怎么发生的。为什么「加机器」救不了正在雪崩的系统?
  2. 熔断器三个状态各自的行为是什么?四条状态转移的触发条件分别是什么?Half-Open 存在的意义是什么?
  3. 「错误数阈值」和「错误率阈值 + 最小请求数」两种判定方式各有什么问题?为什么生产级熔断器选后者?
  4. 降级和熔断的核心区别是什么?缓存降级相比正常流程少了哪一步?这样做的三层收益是什么?
  5. 固定窗口的「边界突刺」是怎么发生的?滑动窗口为什么没有这个问题?
  6. 令牌桶和漏桶在「是否允许积压」上的差异是什么?各自适合什么场景?
  7. 限流拦截器有哪三种粒度?为什么业务级限流用装饰器比 Interceptor 更干净?
  8. gRPC 的链路超时是怎么「自动缩短」的?为什么不该层层手动设超时?

动手练习(建议真做一遍,做到才算过关):

  1. 手写熔断器闭环测试:基于 3.2 节的入门版 Breaker 写一个测试——连续制造失败让错误数超阈值,断言进入 Open;等 openTimeout 后断言进入 Half-Open;试探成功后断言回到 Closed。完成标准:go test -v 三次断言全部通过。
  2. 加限流拦截器:给某个 gRPC 方法挂 ServiceLimitInterceptor(按 FullMethod),阈值设为 3,用 10 个并发请求压它。完成标准:观察到前若干个请求成功、其余返回 ResourceExhausted,且限流器 Release 正常(第二次压测还能通过前几个)。
  3. 链路超时实验:构造 A→B→C 的调用链,A 设 1s 超时,在 C 里打印 ctx 的截止时间。完成标准:确认 C 拿到的剩余时间小于 1s(等于 1s 减去 B 内部耗时);故意让 C sleep 超过剩余时间,观察 A 侧返回 DeadlineExceeded。
  4. 客户端容错升级(课程作业):给第 13 章自定义负载均衡的 Done 回调加上错误识别——返回 ResourceExhausted(触发限流)就把该节点权重调低;返回 Unavailable(触发熔断)就暂时把它挪出可用列表,另起 goroutine 定时向它发健康检查请求,收到正常响应再挪回(挪回时要控制流量,参考 3.1 节的逐步放开思路)。

七、模拟面试问答

以下 12 道题覆盖本章所有高频考点,按「基础 → 进阶 → 追问」三档排列。每题给出可以直接背的一句话结论、展开思路、面试官想听的关键词、新手最容易答错的地方。

面试官
【基础】 什么是服务雪崩?怎么防止?
候选人

一句话结论:雪崩是单个服务的故障沿调用链向上传染、级联放大,最终拖垮整个系统的现象;防它靠熔断、限流、降级三板斧。

展开:一个请求在微服务里可能触发多次内部调用(流量放大),下游一个慢查询就能把上游的 goroutine、连接全部占住,故障像多米诺骨牌一样向上传播。三板斧分工:熔断发现下游不行就快速失败、隔离故障;限流把进入系统的流量控制在自己能承受的范围内;降级撑不住时返回缓存或默认值,保住核心体验。本质都是「故障处理」——预防靠限流、检测靠错误率统计、处理靠熔断降级、恢复靠 Half-Open 试探。

面试官想听的关键词:级联故障、流量放大、快速失败、隔离、故障四阶段(预防/检测/处理/恢复)。

常见失分点:只会背「加熔断」三个字,说不清雪崩的传导机制;或者以为加机器能解决——扩容只会让更多请求冲进故障点,加速崩溃。

面试官
【基础】 讲一下熔断器的三态状态机。
候选人

一句话结论:熔断器有 Closed、Open、Half-Open 三个状态——Closed 正常放行并统计错误率,Open 快速失败拒绝一切,Half-Open 放少量请求试探恢复情况。

展开:默认 Closed,持续统计窗口内的失败率;失败率超阈值(比如 50%)转 Open,此后所有请求直接返回错误,一个网络请求都不发,这就是「快速失败」;Open 持续一个超时窗口(比如 30 秒)后转 Half-Open,只放行少量(比如 5 个)试探请求——试探成功回 Closed 恢复正常,失败立刻回 Open 继续断开。Half-Open 就是「试探 + 逐步放开流量」恢复策略的落地,避免刚恢复的服务被全量流量再次打挂。

面试官想听的关键词:有限状态机、错误率阈值、快速失败、超时窗口、试探、最小请求数。

常见失分点:漏说 Half-Open 的存在或说不出它为什么存在;说不出 Closed → Open 的判定条件是错误率而不是「挂了就熔」。

面试官
【基础】 熔断和降级有什么区别?
候选人

一句话结论:熔断是「快速失败」——下游不行了直接拒绝调用、返回错误;降级是「尽可能返回响应」——返回缓存、默认值或简化数据,不直接拒绝用户。

展开:两者保护的对象也不同:熔断保护调用方(别把资源浪费在注定失败的调用上),降级保护用户体验(系统部分坏了,主干还能用)。它们经常配合:熔断器跳闸后调用不发了,改走降级逻辑——比如作者服务熔断,帖子列表照样能看,作者昵称显示「未知用户」。降级的经典招式是缓存降级:只查 Redis 不查 DB,牺牲未命中的少数请求,保住数据库和整体可用性。

面试官想听的关键词:快速失败 vs 尽可能返回响应、快慢路径、缓存降级、跨服务降级(按业务重要性牺牲边缘功能)。

常见失分点:把两者混为一谈,或只说「都是保护系统」这种正确但空洞的话;说不出一个具体的降级例子。

面试官
【基础】 限流和熔断分别保护谁?
候选人

一句话结论:限流保护被调用方自己(「别都进来」,超过承受能力就拒绝),熔断保护调用方(「别去调它」,下游不行了就不发请求)。

展开:方向相反——限流是入口闸门,作用在自己身上,防自己被压垮;熔断是出口保险丝,作用在调用链上,防自己被下游拖死。所以限流拦截器挂服务端(或网关),熔断拦截器挂客户端。一个是事前预防(限流在故障发生前就起作用),一个是事中处理(熔断在故障发生后止损)。两者配合加上降级兜底,才是一套完整的稳定性方案。

面试官想听的关键词:保护对象、入口闸门 / 出口保险丝、事前预防 vs 事中止损、服务端 / 客户端拦截器。

常见失分点:说不清保护方向的差异;认为三者是替代关系而不是互补关系。

面试官
【进阶】 固定窗口和滑动窗口限流的区别?滑动窗口为什么更平滑?
候选人

一句话结论:固定窗口按固定时间片计数,窗口交界处可能瞬间通过双倍流量的「边界突刺」;滑动窗口统计「从现在往前推 N 秒」的请求数,边界随时间平滑移动,没有突刺。

展开:固定窗口限 100 QPS,第 1 个窗口的最后 0.1 秒涌进 100 个、第 2 个窗口的前 0.1 秒又涌进 100 个——0.2 秒内实际通过 200 个,瞬时压力翻倍。滑动窗口的左边界是「now − window」,任意时刻往回看都是完整的一个窗口,不存在「翻篇清零」的瞬间。实现上朴素版存所有请求的时间戳(内存大),生产级用「大窗口切 10 个小时间片分桶计数」的环形结构,内存固定、速度快。

面试官想听的关键词:边界突刺(临界问题)、时间戳队列、分桶计数、环形复用、内存 O(1)。

常见失分点:说不出边界突刺的具体数值例子;把滑动窗口实现说成必须存全部时间戳(不知道分桶优化)。

面试官
【进阶】 令牌桶和漏桶的区别?各自适用什么场景?
候选人

一句话结论:令牌桶「先攒后花」——允许积压,突发流量友好;漏桶「细水长流」——输出速率绝对均匀,不允许积压。

展开:令牌桶按固定速率发令牌、桶满丢弃多余令牌,请求拿到令牌才通过——系统空闲时攒下的令牌可以在突发时一口气用掉,适合保护自己的服务(能扛突发就放行)。漏桶不管请求什么时候来,一律匀速放行,速率绝对均匀——适合调用别人的限速接口,比如第三方 API 限额 100 QPS,匀着用最稳,突发反而可能触发对方封禁。补充:Go 官方 golang.org/x/time/rate 就是令牌桶,支持 Allow 非阻塞判断和 Wait 阻塞等待。

面试官想听的关键词:允许积压 vs 绝对均匀、突发流量、桶容量、x/time/rate、懒计算。

常见失分点:只背「一个是令牌一个是漏」却说不清速率特征的差异;不知道桶容量参数决定了「最多能攒多少突发额度」。

面试官
【进阶】 gRPC 的链路超时是怎么回事?为什么不用每层都设超时?
候选人

一句话结论:gRPC 自带链路超时传递——超时信息随请求传播,下游收到的 ctx 已经是「剩余超时时间」,全链路共享同一个倒计时,不需要也不应该层层手动设超时。

展开:A 设 1 秒超时调 B,B 内部耗了 300ms 再调 C,C 拿到的 ctx 剩余 700ms——三层对「什么时候该放弃」天然一致,不会出现用户早已离开、服务还在傻等的情况。如果每层独立各设 1 秒,最坏情况总耗时 3 秒,而且外层早就超时放弃了、内层还在白费资源。所以正确姿势是:只在入口设总超时,内部依赖靠 ctx 自动继承剩余时间;同时注意依赖超时之和不能超过自己的总超时——这个约束由链路超时机制自动保证。

面试官想听的关键词:剩余超时、metadata 传播、context.WithTimeout、ctx.Done()、全链路共享倒计时。

常见失分点:不知道 gRPC 自带这个能力,回答说「每层都 WithTimeout 一下」;说不出「依赖超时之和 ≤ 总超时」这个约束。

面试官
【进阶】 限流阈值怎么确定?没有条件压测怎么办?
候选人

一句话结论:标准答案是压测——把并发逐渐推高画曲线,在「性能最好点、吞吐最高点、崩溃临界点」里按业务目标选;没法压测就用观测数据、参考同类接口加手动计算,并预留空间。

展开:压测曲线有三个关键点:A 是响应时间开始上翘的性能最好点(追求稳定选这附近)、C 是吞吐量峰值点(追求最大吞吐)、B 是临近崩溃的临界点。生产系统一般取 A 到 C 之间偏保守的位置。替代方案:已有接口看 Prometheus 的 QPS / P99 历史数据;新接口参考类似接口;手动计算——单请求 DB 10ms × 2 次约 20ms,4 核理论上 1000/20 × 4 = 200 QPS。预估后要上调留余量,宁可松也别误伤正常流量。

面试官想听的关键词:压测、A/B/C 三点、P999、Little’s Law(并发 = QPS × 响应时间)、预留余量。

常见失分点:只答「拍脑袋设个值」或「参考别人」;说不出压测曲线上三个点分别代表什么。

面试官
【追问】 被限流 / 熔断的请求只能直接拒绝吗?还有什么处理方式?
候选人

一句话结论:不是,被拒的请求还有三条生路——同步转异步(写 MQ 延迟处理)、执行特殊代码(返回默认值,即降级)、转发请求(通知客户端换节点重试)。

展开:同步转异步最典型的例子是秒杀下单:请求先写进 Kafka,前端立刻返回「排队中」,后端按自己的节奏消费——把瞬时洪峰削成平稳流量,这就是「削峰填谷」。返回默认值就是降级(缓存 / 占位数据)。转发重试配合负载均衡,把请求导向健康节点。可以看出限流、熔断、降级的边界并不分明——它们都是「故障处理」的不同侧面,工程上常常组合使用。

面试官想听的关键词:削峰填谷、同步转异步、MQ、默认值、重试 + 负载均衡、三板斧边界模糊。

常见失分点:只知道拒绝;说削峰时举不出具体业务例子;忽略了重试的放大效应(每层重试 3 次会放大 27 倍流量,必须配超时和幂等)。

面试官
【追问】 注册中心挂了 / 下游部分实例挂了,你的服务治理方案怎么配合工作?
候选人

一句话结论:治理三板斧不是孤立的——注册中心挂了靠客户端本地缓存续命(第 12 章),节点挂了靠负载均衡剔除(第 13 章),节点慢了靠熔断限流兜底(本章),多层防线各管一段。

展开:完整的客户端容错链路是:注册中心不可用时用本地缓存的服务列表继续调用;调用时负载均衡优先选健康节点;某节点连续失败被剔除后,若整个下游都开始报错,熔断器跳闸快速失败,同时降级逻辑接管返回兜底数据;恢复期靠 Half-Open 试探逐步放量。再配合链路超时防止无限等待、客户端日志 / Trace 记录每个环节。面试里能把「注册中心容错 + 特色 LB + 熔断限流降级 + 链路超时」串成一条完整的故障场景推演,就是满分答案。

面试官想听的关键词:本地缓存、健康检查剔除、多层防线、快速失败 + 兜底数据、Half-Open 逐步放量、可观测性。

常见失分点:只会单点答题(只讲熔断),不能把多章能力串成完整故障场景;忘记提「恢复」这一环。

面试官
【追问】 如果让你给现在的系统加熔断,你会怎么设计错误判定——哪些错误算失败、多少失败算故障?
候选人

一句话结论:两步走——先分类:只有「下游故障类」错误才算失败——超时(DeadlineExceeded)、连不上(Unavailable)、被取消(Canceled)、服务端内部错误(Internal / Unknown),参数错、资源不存在这类业务错要算成功;再定阈值:用「错误率 + 最小请求数」组合判定,而不是简单的错误数。

展开:分类的判定依据是「这次调用本身有没有成功送达并被处理」:InvalidArgument、NotFound 说明调用链路是通的,是业务语义上的拒绝,不算下游故障;如果把它们也计成失败,下游被恶意刷非法参数就能触发熔断,等于攻击者可以遥控你的熔断器。超时要计入失败,配合链路超时,「慢」本身就是一种故障。定阈值为什么不能数错误个数:阈值 100 个错误——大促 QPS 1 万时哪怕错误率只有 1%,一秒钟就攒够 100 个,系统明明 99% 健康却被熔断;深夜 QPS 10 时故障率 100% 也要 10 秒才凑够,故障早已蔓延。改成「错误率 ≥ 50% 且窗口内请求数 ≥ 20 才判定」,样本不足时根本不做判定——小样本下的比率不可信,这是统计学的基本素养。另外两个细节:Open 状态下被熔断器拒绝的请求不能计入统计(没发出去的请求既非成功也非失败);统计窗口用滑动窗口实现,aegis / Sentinel 都是这个做法。

面试官想听的关键词:错误分类、故障类 vs 业务类、快就是慢(超时即故障)、错误率 vs 错误数、最小样本数、滑动窗口统计、防止状态机卡死。

常见失分点:一刀切把所有 error 都算失败;只知道「用错误率」却说不出为什么要配最小请求数;没想过「被熔断拒绝的请求要不要统计」这个细节;举不出「业务错误误触发熔断」的攻击场景。

面试官
【追问】 听说过自适应限流 / 动态熔断吗?和静态阈值比好在哪?
候选人

一句话结论:自适应限流不预设固定阈值,而是根据 CPU、响应时间、错误率等实时指标动态计算放行量,系统能自动跟随负载水位调整;代价是实现复杂、行为难预测,实践中用得少但面试是加分项。

展开:静态阈值的问题是「一刀切」——阈值 200 QPS,但系统在 CPU 抖动、GC、依赖变慢时实际能承受的可能只有 120。动态方案像第 13 章的动态负载均衡一样实时观测系统状态:CPU 超标就收紧,恢复就放松。落地案例是 go-kratos/aegis 基于 BBR 思想的自适应限流——CPU 和滑动窗口统计结合,动态算放行阈值。为什么实践中少用:指标本身可能抖动导致限流行为震荡,需要平滑和缓冲,维护成本高;大部分场景静态阈值 + 压测校准已经够用。

面试官想听的关键词:BBR、aegis、自适应、实时指标(CPU / RT / 错误率)、阈值震荡、平滑处理。

常见失分点:只知道名词说不出原理;无脑吹动态方案更好,说不出它的代价(震荡、复杂度)和静态方案的合理性。


八、本章小结

复习提示:
  • 服务治理 = 故障预防(限流)+ 故障检测(错误率 / 实时指标)+ 故障处理(熔断 / 降级)+ 故障恢复(试探 + 逐步放开流量)
  • 雪崩是单个故障沿调用链级联放大(流量放大效应),加机器救不了,必须先隔离故障
  • 熔断是三态状态机:Closed 统计 → Open 快速失败 → Half-Open 试探恢复;判定用「错误率阈值 + 最小请求数」
  • 熔断器三步必须成对:Allow 放行 → 调业务 → 无论成败都 Mark;Open 期间被拒的请求不计入统计
  • 降级是尽可能返回响应(缓存降级不碰 DB、跨服务降级按重要性牺牲边缘功能),熔断是快速失败——两者常配合
  • 限流五种算法:计数器(限并发)、固定窗口(有边界突刺)、滑动窗口(分桶平滑)、令牌桶(允许积压、突发友好)、漏桶(绝对均匀)
  • 限流保护被调用方(入口闸门),熔断保护调用方(出口保险丝);使用频率 限流 > 降级 > 熔断
  • gRPC 靠 Interceptor 接入治理:服务端限流(全应用 / FullMethod / 业务字段三种粒度,业务级用装饰器更干净)、客户端熔断(按方法分熔断器,错误分类上报)
  • 链路超时 gRPC 自带:下游 ctx 已是剩余超时,只在入口设总超时,依赖超时之和 ≤ 总超时
  • 限流阈值靠压测(A 性能最好点 / B 崩溃临界点 / C 吞吐峰值),没法压测就观测数据 + 参考 + 手动计算 + 留余量
  • 可用轮子:go-kratos/aegis(熔断 + BBR 自适应限流,不依赖 Kratos 框架)、golang.org/x/time/rate(令牌桶)
  • 完整治理方案是面试亮点:注册中心容错 + 特色 LB + 熔断限流降级 + 链路超时 + 客户端治理 + 中间件容错

下一章(第 15 章)我们进入支付服务设计——「钱」相关的系统一分钱都不能差。你会发现本章的很多思想在那里以更严格的形式重现:幂等(重试不重复扣款的基石)、超时控制(掉单问题的源头)、状态机(订单状态的流转管理)和对账(发现数据不一致的最后防线)。如果说这一章保护的是「系统的命」,下一章保护的就是「用户的钱」。

复习提示:伏笔:本章说「只有幂等接口才可以安全重试」——什么是幂等?为什么同一个支付请求发两次,绝对不能扣两次钱?带着这个问题进入第 15 章。
About Me

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

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

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

目标

学AI,加油!加油!