写在最前面:这一版和普通讲义有什么不同
这是《内容社区》课程第 14 章。第 12 章我们让服务之间能互相找到对方,第 13 章解决了「找到多个实例后请求发给谁」。这一章回答一个更残酷的问题:万一对方挂了、慢了,或者自己被流量冲垮了,怎么办?
生产环境里没有一个服务是永远健康的:数据库会抖、网络会丢包、依赖的第三方会超时。如果你不设防,一个慢节点就能像多米诺骨牌一样拖死整条调用链——这叫服务雪崩,本章的一切都围绕「阻止雪崩」展开。
这一版做了三件事:
- 每一步都讲透再动手。熔断、限流、降级、令牌桶、滑动窗口这些名词,你大概率只是「听过」,本章全部就地补「补课胶囊」,不让你去搜。
- 用类比先让你看见。空气开关、餐厅精简菜单、游乐场检票口、水库泄洪——先用生活场景建立直觉,再上代码和状态机图。
- 结尾有 12 道面试问答。按「基础 → 进阶 → 追问」三档,每题告诉你一句话结论、怎么展开、面试官想听什么词、新手最容易答错在哪。
如果你想要「高密度速查版」,可以看原版第 14 章 ;两版知识点完全一致,只是讲法不同。
一、本章导读
上一章我们做完了负载均衡:post 服务能从 etcd 拿到 user 服务的 3 个实例地址,还能把请求合理地分发出去。到这里,「正常情况下怎么调用」已经全部打通了。
但分布式系统有一个无法回避的现实:正常情况不是常态。网络会抖、节点会挂、依赖的下游会突然变慢。想象一个场景:user 服务的数据库执行了一条慢查询,每次请求都要卡 10 秒。会发生什么?
- post 服务调用 user 服务,等 10 秒才返回——post 服务自己的请求也被卡住 10 秒;
- post 服务的每个请求都占着一个 goroutine 在干等,请求越积越多,内存和 CPU 被耗光;
- post 服务自己也开始超时、无响应;
- 调用 post 服务的上游也被拖住……
一个数据库慢查询,最终拖死了整条链路上的所有服务。这就是雪崩:单个故障沿着调用链向上传染,越传越猛。
这一章我们要建立保护系统的「三板斧」:
- 熔断:发现下游不行了,就别再调它,快速失败,把故障隔离在最小范围内;
- 限流:自己别接太多请求,超过承受能力的直接拒绝,宁可拒绝一部分也不能全垮;
- 降级:撑不住时返回一个「够用的替代品」(旧缓存、默认值),保住核心体验。
学完这一章,你将拥有保护系统不被拖垮的能力——这也是面试里「稳定性」话题的全部主菜。下一章我们把这套治理能力用在一个最敏感的业务上:支付。
开读前先看一眼全局:熔断、限流、降级是防雪崩的"三板斧",各管一段——限流挡住超出容量的流量,熔断在下游不健康时果断停止调用,降级在断供时给个"能用但打折"的答案。看懂下面这张图的顺序,就看懂了本章的小节顺序。
flowchart LR
A["上游流量涌入"] --> B["限流:令牌桶 / 滑动窗口
超出容量的直接拒绝"]
B --> C["调用下游依赖"]
C --> D{"下游还健康吗?"}
D -->|"健康"| E["正常返回"]
D -->|"持续报错 / 超时"| F["熔断器打开
一段时间内不再调用"]
F --> G["降级:返回默认值 / 缓存数据
保住核心链路"]
F -.->|"半开状态试探恢复"| D这张图怎么读:流量从左到右走——「上游流量涌入」先过「限流」这道闸(令牌桶 / 滑动窗口,超出容量的直接拒绝),放下来的请求才去「调用下游依赖」;随后在「下游还健康吗?」这个菱形上分岔:健康就「正常返回」收尾;持续报错 / 超时则「熔断器打开」,一段时间内不再真调下游,改由「降级」返回默认值或缓存数据,保住核心链路。底部虚线是恢复路径:熔断打开期间到点转半开,放少量试探请求回到菱形重新判断,试探成功才逐步放开流量。
二、本章知识点大纲
先把这张地图看一遍,知道这一章要往哪走、走到哪算够。
必会(学完必须能独立写出来)
- 用「多米诺骨牌 / 空气开关」类比,向一个完全不懂的人讲清楚服务雪崩是怎么发生的、为什么「加机器」救不了雪崩。
- 画出熔断器的三态状态机(Closed / Open / Half-Open),标清楚每条状态转移的触发条件,并手写一个三态熔断器。
- 手写至少两种限流算法:计数器(限并发数)和令牌桶(限 QPS),并解释各自的适用场景。
- 说清降级和熔断的区别(降级「尽可能返回响应」vs 熔断「快速失败」),写出缓存降级的流程图。
- 用
UnaryServerInterceptor给 gRPC 服务端接入限流拦截器(全应用 / 服务级 / 业务字段级三种粒度),用客户端拦截器接入熔断。
理解(能讲清原理和取舍,不要求背代码)
- 服务治理的「故障四阶段」框架:预防 → 检测 → 处理 → 恢复,以及熔断 / 限流 / 降级在这个框架里的位置。
- 五种限流算法的取舍:计数器、固定窗口、滑动窗口、令牌桶、漏桶——尤其固定窗口的边界突刺问题和令牌桶 vs 漏桶在「是否允许积压」上的差异。
- 「试探 + 逐步放开流量」的通用故障恢复策略,以及 Half-Open 状态如何落地它。
- gRPC 的链路超时自动缩短机制:为什么服务端收到的 ctx 已经是「剩余超时时间」。
- 限流阈值怎么定:压测曲线上的 A / B / C 三个点各代表什么,没法压测时怎么办。
- 错误数阈值 vs 错误率阈值的差异,为什么生产级熔断器要加「最小请求数」。
了解(知道有这回事,面试能说出名字和一句话定位)
go-kratos/aegis:Kratos 的服务治理子项目,提供熔断器和基于 BBR 的自适应限流,不依赖 Kratos 框架可以单独用。- 舱壁隔离(线程池 / 信号量隔离)、分组路由、优雅退出等服务治理的边缘话题。
- 动态限流 / 动态熔断:用 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 可观测性);之后转入「第四节 跟着写」,以「第七节 面试问答」收口。每条边就是「读完上一节再读下一节」的线性顺序。
三、正文
3.1 服务治理到底在治理什么
先补课:什么是服务雪崩
补课胶囊 · 服务雪崩(级联故障) 一句话:一个服务的故障顺着调用链向上传染,最终拖垮整条链路甚至整个系统。 类比:多米诺骨牌。user 服务是中间的一张牌,它倒了会推倒调用它的 post 服务,post 再推倒调用它的网关——只要有一张牌没立稳,整排骨牌全躺下。 最小示例:本章导读里那个「DB 慢查询 → user 卡 10 秒 → post 请求堆积 → post 也卡死」的推演,就是一次完整的雪崩过程。 回到主线:熔断、限流、降级三板斧,全部是为了在「某张骨牌开始晃」时把它扶住或抽走,别让它推倒下一张。
服务治理的范围
服务治理是一个非常宽泛的问题,主要涵盖:
- 熔断、限流、降级(本章主角,也是高频面试点)
- 超时控制
- 隔离(线程池隔离、信号量隔离——把不同业务的资源隔开,别互相拖累)
- 分组和路由
- 优雅退出(第 09 章讲过的优雅停机)
微服务架构要在面试里讲出亮点,必须有一整套从前端到后端的服务治理方案,本章先把最核心的三板斧立起来。
故障处理三段论:一个统一框架
服务治理本质上在讨论两件事:
- 怎么保证系统不出现故障(预防)
- 万一故障了:怎么尽快发现?怎么处理?怎么恢复?
把这两件事展开,就得到故障处理的四阶段框架——这张表是本章的总纲,后面每个知识点都能挂进去:
| 阶段 | 思路 | 对应手段 |
|---|---|---|
| 故障预防 | 不管有没有问题,到阈值就限 | 限流 |
| 故障检测 | 静态检测(阈值)+ 动态检测(实时指标) | 错误率统计、响应时间监控 |
| 故障处理 | 同步转异步 / 执行特殊代码 / 请求转发 | 熔断、降级 |
| 故障恢复 | 固定等待 / 实时计算 / 试探法 + 灰度 | 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 个真实请求;
- 这个请求被正常处理 → 加大流量(比如放行 10%);没被正常处理 → 继续返回默认值;
- 加大流量的过程中如果又失败 → 减少流量,甚至退回到全部返回默认值;
- 不断重复,直到 100% 的流量都被正常处理。
flowchart TD
F[故障处理中 全部返回默认值] --> W[间隔一段时间]
W --> T[试探放行少量真实请求]
T -->|处理成功| U[逐步加大流量]
T -->|处理失败| F
U -->|又失败| D[减少流量或退回默认值]
U -->|持续成功| N[全部流量正常 恢复完成]为什么要这么麻烦?想象服务刚从故障中缓过来,资源还很紧张。如果此时直接 100% 放行,刚恢复的服务瞬间又被流量打挂,系统就会陷入「一会好、一会崩」的抖动循环——恢复也需要温柔。
实现上可以复用「随机数 + 阈值」的流量控制思路:维护一个放行概率(比如从 1% 开始),每个请求生成随机数决定走真实处理还是默认值,再根据处理结果实时调整这个阈值。
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: 试探失败用空气开关把四个状态串起来读一遍:
- Closed(合闸):一切正常,电随便用,但电表一直在记录(统计错误率);
- Closed → Open(跳闸):检测到短路(错误率超过阈值,比如 50%),开关跳开;
- Open → Half-Open(试着推一半):跳闸后等 30 秒(超时窗口),把开关推到中间位置,放一点点电流过去(放行少量试探请求);
- Half-Open → Closed(合闸成功):试探请求正常返回,说明下游恢复了,彻底合闸,恢复统计;
- Half-Open → Open(又跳了):试探请求又失败,说明还没好,继续断开,再等下一个窗口。
熔断的三个要点
把熔断展开,其实要回答三个问题:
- 怎么判定要熔断(检测):静态检测——错误率超过 50% 就熔断;或动态检测——根据实时指标判定(进阶,见 3.4 节末尾);
- 熔断后怎么办(处理):返回特定错误,比如 gRPC 的
UNAVAILABLE,让调用方知道「这不是业务错,是熔断了」; - 怎么恢复:试探 + 逐步放开流量(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.openTimeout | Open 不是永久状态,到期自动转 HalfOpen——这就是「超时窗口」 |
半开态里 halfOpenCalls < halfOpenMaxCalls | 限制试探数量,避免「半开」变「全开」,刚恢复的服务又被冲垮 |
MarkFailure 里先判断 HalfOpen | 半开态下一次失败就立刻回 Open——试探失败说明还没恢复,别犹豫 |
MarkSuccess 只在 HalfOpen 有动作 | Closed 态的成功不需要改变状态,只影响统计(工程版里会记录) |
MarkSuccess / MarkFailure。熔断器只有「统计」没有「上报」就是摆设——很多同学实现了 Allow 却忘了在业务返回后调用 Mark,结果状态永远停在 Closed,熔断根本不触发。放行 → 调业务 → 无论成败都 Mark,这三步必须成对出现。小收口:熔断是「快速失败」——宁可返回错误,也不把资源浪费在注定失败的调用上。但有些业务场景里,直接报错用户体验太差(比如帖子列表因为作者服务挂了就整个打不开)——这就轮到降级出场了。
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。
就这么一个小改动,收益有三层:
- 应用负载快速下降:只查 Redis 很快,请求迅速处理完,资源被腾出来;
- 能撑住极高并发:瓶颈只剩 Redis(单机 Redis 轻松 10 万 QPS),DB 不参与;
- 保住数据库: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计数器:限并发数的最低成本方案
最简单也最实用——任意时刻系统中只有固定数量的请求正在被处理:
// 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)
}
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
}
}
Allow 之后、handler 之前就 return。有些写法把「熔断打开就返回」和「统计」分开写,结果 Open 期间根本没调用 MarkSuccess/MarkFailure,状态机卡死。务必保证「放行 → 调 handler → 无论成败都 Mark」这一对逻辑完整执行。CircuitBreaker 接口是三个方法——Allow() error(返回 error 即拒绝)+ MarkSuccess() / MarkFailed()(注意方法名是 MarkFailed,别拼成 MarkFailure),上面代码与真实 API 一致。集成时仍应以官方文档为准——这也是「不要凭记忆写 API,要看文档」的活例子(第 01 章质量红线同款教训)。接入降级:没法通用的「通用方案」
降级难以做成通用拦截器——具体怎么降级和业务强相关(作者服务挂了显示「未知用户」,评论服务挂了隐藏评论区,每种降级都不一样)。但可以借助限流器 / 熔断器的状态判断要不要降级:
// 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 秒——用户早刷新页面走人了,你的服务还在傻等。链路超时的意义:全链路共享同一个倒计时。
超时时间怎么定
四个来源,按可信度排序:
- 理论上:从用户体验角度,产品经理说「用户最多能等多久」,这就是链路总超时;
- 观测数据:接口 P999 是 1s(第 08 章 Prometheus 的分位数),就设 1s——让 99.9% 的请求能正常完成;
- 手动计算:分析代码,如 DB 查询 10ms × 2 次 + 网络开销 + 余量;
- 经验值:首页接口应在 100ms 内,1s 是「明显卡顿」的临界点。
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 步后统一跑。
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 原子地「取或建」。
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 - 作者服务熔断期间,帖子列表仍能返回,作者昵称显示「未知用户」
五、工程实践要点
- 优先限流,慎用熔断。熔断破坏性强(跳闸期间正常请求也被拒绝),阈值要保守;限流是日常工具,大促前调阈值是常规操作。使用频率:限流 > 降级 > 熔断。
- 限流阈值靠压测。没法压测就参考类似接口 + 预留空间;上线后结合第 08 章的 Prometheus 指标持续校准。
- gRPC 超时控制自带链路传递,直接用
context.WithTimeout即可,不要层层手动设超时——依赖超时之和超过总超时的设置毫无意义。 - 降级业务强相关,难以做成通用拦截器,但可借助限流 / 熔断状态作为降级开关(
ShouldDegrade)。 - 拦截器粒度要选好:全应用限流粒度粗,业务字段限流粒度细但「全局交税」,装饰器最干净。拦截器越通用越要轻。
- 务必接入可观测性:日志、Trace、Metrics 是治理的基础——没有观测就没有治理,你连「该不该熔断」都无法判断。
- 重试要小心放大效应:A→B→C 链路,每层重试 3 次会放大 27 倍流量(第 13 章 Failover 的教训),必须配合超时控制,且只在本该成功的地方重试(幂等接口才可重试,第 15 章支付会专门讲幂等)。
- 按 key 存桶 / 存熔断器的 map 要防无限增长:用户级限流的 key 是千万级 uid,生产上要加定时清理或 LRU 淘汰,否则内存缓慢泄漏。
六、自测题与动手练习
自测题(先别翻正文,合上书能答出来才算懂;答案要点都藏在正文里):
- 用自己的话(不许说术语)讲一遍服务雪崩是怎么发生的。为什么「加机器」救不了正在雪崩的系统?
- 熔断器三个状态各自的行为是什么?四条状态转移的触发条件分别是什么?Half-Open 存在的意义是什么?
- 「错误数阈值」和「错误率阈值 + 最小请求数」两种判定方式各有什么问题?为什么生产级熔断器选后者?
- 降级和熔断的核心区别是什么?缓存降级相比正常流程少了哪一步?这样做的三层收益是什么?
- 固定窗口的「边界突刺」是怎么发生的?滑动窗口为什么没有这个问题?
- 令牌桶和漏桶在「是否允许积压」上的差异是什么?各自适合什么场景?
- 限流拦截器有哪三种粒度?为什么业务级限流用装饰器比 Interceptor 更干净?
- gRPC 的链路超时是怎么「自动缩短」的?为什么不该层层手动设超时?
动手练习(建议真做一遍,做到才算过关):
- 手写熔断器闭环测试:基于 3.2 节的入门版
Breaker写一个测试——连续制造失败让错误数超阈值,断言进入 Open;等openTimeout后断言进入 Half-Open;试探成功后断言回到 Closed。完成标准:go test -v三次断言全部通过。 - 加限流拦截器:给某个 gRPC 方法挂
ServiceLimitInterceptor(按FullMethod),阈值设为 3,用 10 个并发请求压它。完成标准:观察到前若干个请求成功、其余返回ResourceExhausted,且限流器 Release 正常(第二次压测还能通过前几个)。 - 链路超时实验:构造 A→B→C 的调用链,A 设 1s 超时,在 C 里打印
ctx的截止时间。完成标准:确认 C 拿到的剩余时间小于 1s(等于 1s 减去 B 内部耗时);故意让 C sleep 超过剩余时间,观察 A 侧返回DeadlineExceeded。 - 客户端容错升级(课程作业):给第 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 自带链路超时传递——超时信息随请求传播,下游收到的 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 章)我们进入支付服务设计——「钱」相关的系统一分钱都不能差。你会发现本章的很多思想在那里以更严格的形式重现:幂等(重试不重复扣款的基石)、超时控制(掉单问题的源头)、状态机(订单状态的流转管理)和对账(发现数据不一致的最后防线)。如果说这一章保护的是「系统的命」,下一章保护的就是「用户的钱」。