服务治理:熔断 / 限流 / 降级原理与 gRPC 接入

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

@

学习目标

学完本章,你应该能够:

  1. 用"故障预防 / 检测 / 处理 / 恢复"的框架描述服务治理在治理什么,说清单个故障如何引发雪崩。
  2. 讲清熔断器三态状态机(Closed / Open / Half-Open)的转移条件,能手写一个简单的熔断器。
  3. 区分降级与熔断:降级"尽可能返回响应",熔断"快速失败",并讲出缓存降级、跨服务降级的思路。
  4. 讲清主流限流算法(计数器 / 固定窗口 / 滑动窗口 / 令牌桶 / 漏桶)的原理与取舍,能手写其中两三个。
  5. 在 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 故障处理三段论

服务治理本质是讨论:

  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 熔断的要点

  1. 怎么判定要熔断:静态检测(错误率超过 50% 熔断)或动态检测
  2. 熔断后怎么办:返回特定错误(如 UNAVAILABLE
  3. 怎么恢复:试探 + 逐步放开流量

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

优势:

  1. 应用负载快速下降(只查 Redis 很快,请求迅速处理完,资源腾出)
  2. 能撑住极高并发(瓶颈变成 Redis,少数 DB 查询会拖累整体)
  3. 保住数据库(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:客户端发过来的请求
  • infoServer 字段是你的服务实现,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
}

TracePrometheus 接入思路类似,记录的信息也和日志差不多(耗时、错误码、方法名等),只是输出目的地不同(OpenTelemetry / Prometheus)。


九、工程实践要点

  1. 优先限流,慎用熔断。熔断破坏性强,要慎用;限流是日常工具。
  2. 限流阈值靠压测。没法压测就参考类似接口 + 预留空间。
  3. gRPC 超时控制自带链路传递,直接用 context.WithTimeout 即可。
  4. 降级业务强相关,难以做成通用拦截器,但可借助限流/熔断状态作为降级开关。
  5. 拦截器粒度要选好:全应用限流粒度粗,业务字段限流粒度细,装饰器最干净。
  6. 务必接入可观测性:日志、Trace、Metrics 是治理的基础,没有观测就没有治理。
  7. 重试要小心放大效应:A→B→C 链路,每层重试 3 次会放大 27 倍流量,必须配合超时控制。

十、自测题与动手练习

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

  1. 熔断三态 Closed / Open / Half-Open 各自做什么?Half-Open 存在的意义是什么?
  2. 降级和熔断的核心区别是什么?缓存降级相比正常流程少了哪一步?
  3. 固定窗口和滑动窗口的区别?令牌桶和漏桶的区别(尤其"是否允许积压")?
  4. 限流拦截器有哪三种粒度?为什么业务级限流用装饰器比 Interceptor 更干净?
  5. gRPC 链路超时是怎么"自动缩短"的?限流阈值为什么最好靠压测定?

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

  1. 手写熔断器:实现三态 Breaker,写个小测试——连续制造失败让错误数超阈值,确认进入 Open;等 openTimeout 后进入 Half-Open;试探成功回到 Closed。
  2. 加限流拦截器:给某个 gRPC 方法加 ServiceLimitInterceptor(按 FullMethod),阈值设很小(如 3),用并发压一下确认超限返回 ResourceExhausted
  3. 链路超时实验:构造 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章)我们进入支付服务设计,看"钱"相关的系统是如何靠幂等、对账、状态机和回调验签来保证一分钱都不差的。

About Me

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

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

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

目标

学AI,加油!加油!