学习目标
学完本章,你应该能够:
- 用"故障预防 / 检测 / 处理 / 恢复"的框架描述服务治理在治理什么,说清单个故障如何引发雪崩。
- 讲清熔断器三态状态机(Closed / Open / Half-Open)的转移条件,能手写一个简单的熔断器。
- 区分降级与熔断:降级"尽可能返回响应",熔断"快速失败",并讲出缓存降级、跨服务降级的思路。
- 讲清主流限流算法(计数器 / 固定窗口 / 滑动窗口 / 令牌桶 / 漏桶)的原理与取舍,能手写其中两三个。
- 在 gRPC 中用
UnaryServerInterceptor接入限流、熔断、降级,并结合链路超时与压测阈值形成完整方案。
前置知识(如果下面任意一点生疏,先回看对应章):
- 第13章 负载均衡:知道 Failover、重试放大效应。
- 第02章 Gin + GORM / gRPC 基础:知道一个接口怎么写、Interceptor 是什么。
- Go 并发基础:
sync.Mutex、context 超时控制。
本章你会动手做的事:
- 手写一个三态熔断器,模拟"错误率超阈值 → Open → 半开试探 → 恢复"的完整过程。
- 用
UnaryServerInterceptor给某个 gRPC 方法加限流,分别试全应用级、服务级、按 UID 级三种粒度。 - 用
context.WithTimeout在一个 A→B→C 调用链上验证"链路超时自动缩短"。
一、服务治理到底在治理什么
1.1 服务治理的范围
服务治理是一个非常宽泛的问题,主要涵盖:
- 熔断、限流、降级(高频面试点)
- 超时控制
- 隔离(线程池隔离、信号量隔离)
- 分组和路由
- 优雅退出
微服务架构要面出亮点,必须有一整套从前端到后端的服务治理方案。
1.2 故障处理三段论
服务治理本质是讨论:
- 怎么保证系统不出现故障(预防)
- 万一故障了:怎么尽快发现?怎么处理?怎么恢复?
这构成了故障处理的理论框架:
| 阶段 | 思路 |
|---|---|
| 故障预防 | 限流:不管有没有问题,到阈值就限流 |
| 故障检测 | 静态检测(阈值)+ 动态检测(实时指标) |
| 故障处理 | 同步转异步 / 执行特殊代码 / 请求转发 |
| 故障恢复 | 固定等待 / 实时计算 / 试探法 + 灰度 |
flowchart TD
P[故障预防: 限流] --> D[故障检测: 阈值/实时指标]
D --> H[故障处理: 异步/特殊代码/转发]
H --> R[故障恢复: 等待/试探/灰度]关键认知:熔断、限流、降级本质上是一回事——都是"故障处理"的不同表现形式。
1.3 微服务的流量放大与雪崩
一个用户请求在微服务中可能引发多次内部调用(如 A→B→C、A→D)。当请求量翻倍,下游实际负载增长不止翻倍。如果不做治理,单点故障会级联放大,造成雪崩。熔断、限流、降级的核心目标就是防止雪崩。
flowchart TD
U[用户请求翻倍] --> A[服务 A]
A -->|调用| B[服务 B]
A -->|调用| D[服务 D]
B -->|调用| C[服务 C]
C -. 故障未治理 .-> X[级联放大 雪崩]⚠️ 新手必踩的坑:以为"加机器就能解决雪崩"。雪崩是"调用链放大 + 单点故障传播"导致的,单纯扩容只会让更多请求冲进故障点,反而加速崩溃。必须先有熔断/限流把故障"隔离"住,扩容才有意义。
二、熔断(Circuit Breaker)
2.1 熔断器的三态状态机
熔断器是一个有限状态机,三个状态:
| 状态 | 行为 |
|---|---|
| Closed(闭合) | 正常处理所有请求,统计错误率 |
| Open(开放) | 直接拒绝所有请求,返回特定错误(快速失败) |
| Half-Open(半开) | 放入少量请求试探,成功则转 Closed,失败则转 Open |
Half-Open 就是"恢复过程中的状态"。
stateDiagram-v2
[*] --> Closed
Closed --> Open: 错误率超阈值
Open --> HalfOpen: 超时窗口到期
HalfOpen --> Closed: 试探成功
HalfOpen --> Open: 试探失败类比:熔断就像家里配电箱的"空气开关"。正常情况下(Closed)电随便用;一旦发现短路/过载(错误率超阈值),开关"啪"地跳开(Open),直接断电保护整条线路;过一会儿(超时窗口)开关半合(Half-Open)试着通一点点电,没问题就彻底合上恢复供电,还跳闸就继续断开。它保护的是"线路"——也就是你的下游服务不被持续冲垮。
2.2 熔断的要点
- 怎么判定要熔断:静态检测(错误率超过 50% 熔断)或动态检测
- 熔断后怎么办:返回特定错误(如
UNAVAILABLE) - 怎么恢复:试探 + 逐步放开流量
2.3 熔断器简单实现
package breaker
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 // 当前窗口错误数
requestCount int // 当前窗口总请求数
lastStateChange time.Time
openTimeout time.Duration // Open 状态持续多久后转 HalfOpen
halfOpenMaxCalls int // HalfOpen 状态允许试探的请求数
halfOpenCalls int
}
func New(failureThreshold int, openTimeout time.Duration) *Breaker {
return &Breaker{
state: StateClosed,
failureThreshold: failureThreshold,
openTimeout: openTimeout,
halfOpenMaxCalls: 5, // 默认放 5 个试探请求
}
}
// 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 标记一次成功
func (b *Breaker) MarkSuccess() {
b.mu.Lock()
defer b.mu.Unlock()
if b.state == StateHalfOpen {
// 步骤 1:半开成功 → 转 Closed,清空计数
b.state = StateClosed
b.failureCount = 0
b.requestCount = 0
}
}
// MarkFailure 标记一次失败
func (b *Breaker) MarkFailure() {
b.mu.Lock()
defer b.mu.Unlock()
b.failureCount++
b.requestCount++
if b.state == StateHalfOpen {
// 步骤 1:半开失败 → 立刻转 Open,记录时间
b.state = StateOpen
b.lastStateChange = time.Now()
return
}
// 步骤 2:闭合态错误数达阈值 → 转 Open
if b.failureCount >= b.failureThreshold {
b.state = StateOpen
b.lastStateChange = time.Now()
}
}
⚠️ 新手必踩的坑:忘记调用
MarkSuccess/MarkFailure。熔断器只有"统计"没有"上报"就是摆设——很多同学实现了Allow却忘了在业务返回后调用 Mark,结果状态永远停在 Closed,熔断根本不触发。务必在 Interceptor 里成对调用(见 5.4 节)。
三、降级(Degradation)
降级和熔断类似,但降级是尽可能返回一个响应,而不是直接拒绝。
3.1 快慢路径思路
很多业务有快慢两条路径:
- 快路径:耗资源少、计算快(如查 Redis 缓存)
- 慢路径:耗资源多、计算慢(如查 DB 后回写缓存)
正常流程:先快后慢。降级时只走快路径,跳过慢路径。
flowchart TD
Q[请求到达] --> F[快路径: 查 Redis]
F -->|命中| OK[返回结果]
F -->|未命中| S[慢路径: 查 DB 并回写]
S --> OK
X[触发降级] --> F2[快路径: 查 Redis]
F2 -->|未命中| ERR[直接返回错误/默认值]类比:降级就像餐厅爆满时的"精简菜单"——正常菜单 100 道菜(慢路径,现做现炒),降级时只卖 10 道预制菜(快路径,出餐快)。客人照样能吃到东西(返回响应),只是选择少了、可能不是最新鲜的。
3.2 经典案例:缓存降级
正常时:查 Redis → 未命中则查 DB → 回写缓存。
降级时:查 Redis → 未命中直接返回错误/默认值,不再查 DB。
优势:
- 应用负载快速下降(只查 Redis 很快,请求迅速处理完,资源腾出)
- 能撑住极高并发(瓶颈变成 Redis,少数 DB 查询会拖累整体)
- 保住数据库(DB 不会被压垮)
3.3 跨服务降级
资源不足时按业务重要性降级:
- 用户服务中:增删改停掉,全力支持查询
- 集群层面:边缘业务停掉,资源给核心服务
- 读写分组:写服务停掉,资源给读服务
- 同节点多服务:从最不重要开始逐个停掉
四、限流(Rate Limiting)
4.1 限流对象:针对什么限流?
- 粒度:单机限流 / 集群限流
- 范围:整个应用 / 某个服务 / 某个接口
- 业务对象:用户、IP、商品、订单……非常灵活
- VIP 用户不限流、普通用户限流
- 登录场景按 IP 限流(之前 Web 课程用过)
4.2 限流算法总览
| 算法 | 原理 | 优缺点 |
|---|---|---|
| 计数器 | 收到请求 +1,返回响应 -1,维持固定并发数 | 最简单,效果强力 |
| 固定窗口 | 时间切成窗口,每窗口内请求数不超阈值 | 窗口边界会突发双倍流量 |
| 滑动窗口 | 窗口随时间滑动,更平滑 | 比固定窗口好,实现略复杂 |
| 令牌桶 | 匀速发令牌,请求拿令牌才处理 | 允许积压(突发流量友好) |
| 漏桶 | 匀速漏水,请求匀速通过 | 绝对均匀,不允许积压 |
flowchart LR
subgraph 令牌桶
T[匀速发令牌] --> TB[桶里攒令牌]
TB --> R1[请求拿令牌通过]
end
subgraph 漏桶
L[请求进桶] --> LB[匀速漏出处理]
end实践中随便选一个就行,效果差不多。推荐计数器或令牌桶。
4.3 计数器算法实现
最简单也最实用:任意时刻系统中只有固定数量的请求正在被处理。
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 处理完毕后释放槽位
func (l *CounterLimiter) Release() {
l.mu.Lock()
defer l.mu.Unlock()
if l.current > 0 {
l.current--
}
}
4.4 滑动窗口算法
滑动窗口相比固定窗口更平滑:窗口从当前时间往前回溯 windowSize,统计这段时间内的请求数。
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:清理窗口外的旧请求
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:超过阈值则拒绝
if len(l.requests) >= l.threshold {
return false
}
// 步骤 3:记录本次请求并放行
l.requests = append(l.requests, now)
return true
}
固定窗口 vs 滑动窗口:固定窗口在 0.9s 时来 100 个、1.1s 时又来 100 个,相当于 0.2s 内 200 个请求都通过——这是固定窗口的边界突刺问题。滑动窗口没这个问题。
4.5 令牌桶算法
令牌桶是允许积压的:桶按固定速率发令牌,桶满则丢弃多余令牌。请求来时拿令牌,拿不到则拒绝/阻塞。
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
}
4.6 漏桶算法
漏桶 = 令牌桶不允许积压的版本:请求匀速通过,绝对均匀。适合对均匀性要求高的场景(如调用外部限速 API)。
4.7 限流后的请求怎么办?
很少有人讨论被限流的请求。其实可以:
- 同步转异步:临时保存请求,后续处理(如写 MQ 延迟处理)
- 执行特殊代码:返回默认值
- 转发请求:通知客户端换节点重试
从这里也能看出,限流 / 熔断 / 降级界限并不分明。
4.8 熔断 / 限流 / 降级怎么选
| 需求 | 选择 |
|---|---|
| 出故障也尽可能保持可用 | 降级 |
| 尽快从故障中恢复 | 熔断 |
| 至少一部分请求能被正确处理 | 限流 |
老师经验:使用频率 限流 > 降级 > 熔断。熔断破坏性强,少用。
五、在 gRPC 中接入服务治理
5.1 gRPC 的接入点:Interceptor
类似 Web 中的 middleware,gRPC 中叫 Interceptor,分客户端和服务端两种:
UnaryServerInterceptor:服务端普通 RPC 拦截器UnaryClientInterceptor:客户端普通 RPC 拦截器- 还有 Stream 版本
熔断 / 限流 / 降级主要在服务端处理,所以主要用服务端 Interceptor。
flowchart LR
Req[RPC 请求] --> Chain[ChainUnaryInterceptor]
Chain --> I1[日志 Interceptor]
I1 --> I2[限流 Interceptor]
I2 --> I3[熔断 Interceptor]
I3 --> H[真正 handler]5.2 服务端 Interceptor 基本骨架
import (
"context"
"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 串联多个拦截器
server := grpc.NewServer(
grpc.ChainUnaryInterceptor(LogInterceptor, /* ... */),
)
参数说明:
ctx:链路信息(trace、超时等)req:客户端发过来的请求info:Server字段是你的服务实现,FullMethod是方法名(如/user.v1.UserService/GetProfile)handler:真正的请求处理器
5.3 接入限流拦截器
整个应用限流(固定 key):
// 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) {
// 返回 gRPC 标准限流错误码
return nil, status.Error(codes.ResourceExhausted, "rate limited")
}
// 步骤 2:处理完释放槽位
defer l.Release(key)
return handler(ctx, req)
}
}
服务级别限流(按 info.FullMethod):
// 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)
}
}
业务级别限流(按 UID 等业务字段):
// BizLimitInterceptor 按业务字段(如 UID)限流
// 通过对 req 类型断言取出业务字段
type uidRequest interface {
GetUid() int64
}
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 的请求直接放行(会浪费一点 CPU 检查)
return handler(ctx, req)
}
}
缺陷:所有请求都会经过拦截器,但大部分不会匹配业务限流,浪费一点 CPU。
替代方案:装饰器模式
针对专有业务的限流,用装饰器更干净,不影响其他业务:
// 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)
}
5.4 接入熔断拦截器
直接使用 Kratos 子项目 github.com/go-kratos/aegis,即使项目不用 Kratos 框架也能用。
import (
"github.com/go-kratos/aegis/circuitbreaker"
)
// 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
}
}
aegis 还提供了基于 BBR 算法的自适应限流,限流也可以考虑用这个。
⚠️ 新手必踩的坑:在
Allow之后、handler之前就 return。有些写法把"熔断打开就返回"和"统计"分开写,结果 Open 时根本没调用MarkSuccess/MarkFailure,状态机卡死。务必保证"放行 → 调 handler → 无论成败都 Mark"这一对逻辑完整执行。
5.5 接入降级
降级难以做成通用方案——具体怎么降级和业务强相关。但可以借助限流器/熔断器的状态判断要不要降级:
// 业务代码里结合限流器状态做降级
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, err.ErrLimited
}
// 步骤 2:正常路径
return s.cacheAside.Get(ctx, uid)
}
六、超时控制
6.1 单次调用超时
调用时创建带超时的 ctx 即可:
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
resp, err := client.GetProfile(ctx, req)
6.2 链路超时控制
A→B→C 调用链中,B 的超时时间 = A 设置的超时 - 已消耗时间(剩余超时时间)。gRPC 自带链路超时:服务端收到的 ctx 已经是剩余超时时间,无需额外处理。
sequenceDiagram
participant A as 服务 A(超时1s)
participant B as 服务 B
participant C as 服务 C
A->>B: 调用(剩余超时约1s)
B->>C: 调用(剩余超时=1s-已耗时)
Note over C: 只拿到剩余时间,不会超限6.3 超时时间怎么定
- 理论上:从用户体验角度,产品经理说"用户最多能等多久"就是链路超时
- 观测数据:接口 P999 是 1s,就设 1s
- 手动计算:分析代码,如 DB 查询 10ms × 2 次 + 余量
- 经验值:首页接口应在 100ms 内,1s 是明显卡顿的临界点
七、限流阈值如何确定
7.1 标准答案:压测
压测图上有三个关键点:
| 点 | 含义 | 适用场景 |
|---|---|---|
| A | 性能最好点(响应时间稳定) | 追求最佳性能和较高资源利用率 |
| B | 系统快崩溃的临界点 | 追求更高并发,性能要求不严 |
| C | 吞吐量最高点 | 追求最大吞吐量 |
面试最好回答"压测确定"。
7.2 没法压测时的替代方案
- 已有接口:用现有观测数据(QPS、P99 等)
- 新接口:参考类似接口的阈值
- 手动计算:DB 查询 10ms × 2 次 = 20ms,4 核 CPU → 1000/20 × 4 = 200
预估后预留一些空间(上调一些)。
八、可观测性接入
微服务中要接入日志、Trace、Prometheus,都是通过 Interceptor 实现,和上面限流拦截器套路一致:
日志:
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 接入思路类似,记录的信息也和日志差不多(耗时、错误码、方法名等),只是输出目的地不同(OpenTelemetry / Prometheus)。
九、工程实践要点
- 优先限流,慎用熔断。熔断破坏性强,要慎用;限流是日常工具。
- 限流阈值靠压测。没法压测就参考类似接口 + 预留空间。
- gRPC 超时控制自带链路传递,直接用
context.WithTimeout即可。 - 降级业务强相关,难以做成通用拦截器,但可借助限流/熔断状态作为降级开关。
- 拦截器粒度要选好:全应用限流粒度粗,业务字段限流粒度细,装饰器最干净。
- 务必接入可观测性:日志、Trace、Metrics 是治理的基础,没有观测就没有治理。
- 重试要小心放大效应:A→B→C 链路,每层重试 3 次会放大 27 倍流量,必须配合超时控制。
十、自测题与动手练习
自测题(合上书能答出来,才算懂):
- 熔断三态 Closed / Open / Half-Open 各自做什么?Half-Open 存在的意义是什么?
- 降级和熔断的核心区别是什么?缓存降级相比正常流程少了哪一步?
- 固定窗口和滑动窗口的区别?令牌桶和漏桶的区别(尤其"是否允许积压")?
- 限流拦截器有哪三种粒度?为什么业务级限流用装饰器比 Interceptor 更干净?
- gRPC 链路超时是怎么"自动缩短"的?限流阈值为什么最好靠压测定?
动手练习(建议真做一遍):
- 手写熔断器:实现三态
Breaker,写个小测试——连续制造失败让错误数超阈值,确认进入 Open;等openTimeout后进入 Half-Open;试探成功回到 Closed。 - 加限流拦截器:给某个 gRPC 方法加
ServiceLimitInterceptor(按FullMethod),阈值设很小(如 3),用并发压一下确认超限返回ResourceExhausted。 - 链路超时实验:构造 A→B→C 的调用链,A 设 1s 超时,在 C 里
sleep确认拿到的 ctx 剩余时间小于 1s;故意让 C 超过剩余时间,观察 A 侧是否超时返回。
十一、本章小结
- 服务治理 = 故障预防 + 故障检测 + 故障处理 + 故障恢复
- 熔断是状态机(Closed/Open/Half-Open),Half-Open 是恢复中的状态,必须成对调用 Mark
- 降级是尽可能返回响应(如缓存降级、跨服务降级),不直接拒绝
- 限流算法有计数器 / 固定窗口 / 滑动窗口 / 令牌桶 / 漏桶,随便选一个就行
- gRPC 通过
UnaryServerInterceptor接入熔断 / 限流 / 降级,可用ChainUnaryInterceptor串联多个 - 限流拦截器分粒度:全应用 / 服务级 / 业务字段级,业务级用装饰器更干净
- 熔断可用
go-kratos/aegis子项目,即使不用 Kratos 框架也能用 - 链路超时控制 gRPC 自带,限流阈值最好靠压测确定
- 完整服务治理方案是面试亮点:注册中心容错 + 特色 LB + 熔断限流降级 + 链路超时 + 客户端治理 + 中间件容错
下一章(第15章)我们进入支付服务设计,看"钱"相关的系统是如何靠幂等、对账、状态机和回调验签来保证一分钱都不差的。