Files
cs-note/hzh/MS/02-服务治理/06-容错模式.md
T
2026-05-24 11:42:38 +08:00

21 KiB
Raw Blame History

tags, create time
tags create time
microservice
circuit-breaker
retry
rate-limiting
bulkhead
health-check
fallback
chaos-engineering
2026-05-05 10:30

容错模式

概述

微服务最大的挑战是 网络不可靠。分布式系统的每个远程调用都可能超时、被拒绝、或部分成功。必须提前设计降级和恢复策略。

graph TB
    A["🛡️ 超时控制"] --> B["⏱ 重试机制"]
    B --> C["⚡ 熔断器"]
    C --> D["🔀 限流"]
    D --> E["🧱 舱壁隔离"]
    E --> F["💤 降级策略"]

[!warning] 核心认知 不要假设任何网络调用会成功。每个远程调用的代码都应该考虑失败路径。

[!question]- 💡 思考题 如果一个系统从未出现过故障,还需要容错设计吗?

答案是肯定的。Netflix 的混沌工程理念指出:"故障不是是否发生的问题,而是何时发生。" 没有容错设计的系统在第一次外部依赖超时后就会暴露出级联崩溃的风险。


正文

各模式的组合使用

这 7 种容错模式通常不会单独使用,而是层层叠加形成纵深防御体系:

flowchart LR
    subgraph Layer1["第一层:快速失败"]
        T["⏱ 超时控制<br/>最先触发,避免线程等待"]
    end
    
    subgraph Layer2["第二层:安全重试"]
        R["⏱ 指数退避重试<br/>给故障恢复的时间窗口"]
    end
    
    subgraph Layer3["第三层:自我保护"]
        CB["🔘 熔断器<br/>高频失败时直接短路"]
        RL["🔀 限流器<br/>保护下游不被打满"]
        BH["🧱 舱壁隔离<br/>故障不蔓延"]
    end
    
    subgraph Layer4["第四层:兜底策略"]
        D["💤 降级响应<br/>返回缓存/默认值"]
        HC["💚 健康检查<br/>自动剔除故障节点"]
    end
    
    Layer1 --> Layer2 --> Layer3 --> Layer4
    
    style Layer1 fill:#e3f2fd
    style Layer2 fill:#fff3e0
    style Layer3 fill:#fce4ec
    style Layer4 fill:#e8f5e9

[!tip] 使用顺序的重要性 超时是所有机制的前提。一个没有超时的重试只会让请求无限期挂起。

1. 超时控制

这是所有容错机制的前提——没有超时的系统迟早会被拖垮。

// Go context 超时时序图
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

resp, err := client.DoRequest(ctx, req)
if err == context.DeadlineExceeded {
    // 上游超时 → 走降级逻辑或快速返回错误
}
层级 推荐超时值 说明
HTTP 客户端 → 业务服务 3~5s 留有余量给下游
gRPC → 内部服务 1~2s 内网延迟低
DB 查询 500ms~1s 长查询应在 SQL 层限制

[!tip] 超时传递原则 如果整体 SLA 要求 P99 < 200ms,子调用的超时时间必须逐层递减。例如:网关 200ms → 订单服务 150ms → 库存服务 80ms。

2. 重试机制

[!warning] 关键原则 只对 幂等操作 重试!POST 创建操作直接重试会导致重复数据。GET、PUT(同参数)适合重试。

指数退避 + Jitter

第 1 次重试:等待 1s + random(0~100ms)
第 2 次重试:等待 2s + random(0~100ms)
第 3 次重试:等待 4s + random(0~100ms)
...
func retryWithBackoff(ctx context.Context, maxRetries int, fn func() error) error {
    for attempt := uint(0); attempt <= uint(maxRetries); attempt++ {
        if err := fn(); err != nil {
            if attempt == uint(maxRetries) {
                return err
            }
            // jitter: 随机偏移,避免所有客户端同时重试造成二次冲击
            jitter := time.Duration(rand.Int63n(int64(time.Millisecond * 100)))
            wait := time.Duration(math.Pow(2, float64(attempt)))*time.Second + jitter
            select {
            case <-ctx.Done():
                return ctx.Err()
            case <-time.After(wait):
            }
        } else {
            return nil
        }
    }
    return nil
}

Jitter 为什么重要?

  • 如果没有随机偏移,当某个下游服务挂掉时,所有客户端会在同一时刻同时重试
  • 这会造成 retry storm(重试风暴),对恢复中的服务施加二次冲击
  • 加上 jitter 后,请求均匀分散到时间窗口内

可重试 vs 不可重试的错误类型

可以重试 不应重试
连接超时 4xx Client Error(除 429)
连接重置 404 Not Found
502/503/504 Gateway Error 业务错误(如余额不足)
并发冲突(乐观锁失败) 403 Forbidden

[!example]- ❌ 常见反模式:重试非幂等操作

// 危险!每次重试都会创建一个新的订单
resp, err := httpClient.Post("/api/orders", jsonBody)
if err != nil { retry() } // ← 重复下单!

安全做法: 为每个写操作生成全局唯一的 requestId,下游基于 requestId 做幂等校验。

3. 熔断器 (Circuit Breaker)

当下游服务频繁失败时,快速失败避免线程堆积雪崩。

flowchart TD
    CB{{🔘 CIRCUIT BREAKER}}

    subgraph CL ["CLOSED — 正常"]
        C["请求放行<br/>⏱ 实时统计成功率<br/>⚠️ 连续失败 → 熔断"]
    end

    subgraph OP ["OPEN — 熔断"]
        O["🚫 请求短路<br/>💥 直接返回降级响应<br/>🛡 不调下游,防雪崩"]
    end

    subgraph HO ["HALF-OPEN — 探测"]
        H["🔍 放行少量探测请求<br/>✅ 成功 → 恢复全量流量<br/>❌ 失败 → 重新熔断"]
    end

    CB --> CL
    CL -.->|失败 > N| OP
    OP -.->|等待超时| HO
    HO -->|探测成功| CL
    HO -->|探测失败| OP

    style CL fill:#e6f9e6,stroke:#4caf50,stroke-width:3px,color:#000
    style OP fill:#ffebee,stroke:#ef5350,stroke-width:3px,color:#000
    style HO fill:#fff8e1,stroke:#ffa726,stroke-width:3px,color:#000

状态转换条件

状态 触发条件 行为 退出条件
Closed 初始状态 / 从 Half-Open 恢复 正常放行请求 连续失败数 > threshold → 打开
Open 失败率超过阈值 立即短路返回错误 等待 recoveryTimeout → 进入半开
Half-Open 探测期 放少量请求测试 探测成功 → Closed;探测失败 → Open

实战示例

// gobreaker 示例
cb := state.NewCB(state.Settings{
    Name:        "UserService",
    MaxElems:    10,              // 窗口大小
    WaitRetry:   5 * time.Second, // Open → Half-Open 等待时间
    ReadyToTrip: func(counts state.Counters) bool {
        return counts.Failures > 5  // 连续 5 次失败熔断
    },
})

result := cb.Execute(doRequest)
if result.Err != nil {
    // 熔断打开,立即短路返回错误,不调用下游
    return fallbackResponse()
}

熔断器 vs 限流器

[!note]- 两者的区别(常被混淆)

维度 熔断器 (Circuit Breaker) 限流器 (Rate Limiter)
触发条件 下游故障时才打开 始终生效,不依赖故障状态
决策依据 成功率、失败次数 请求速率超过阈值
目的 保护自身不被慢速下游拖垮 保护下游不被过多请求打满
状态性 有状态(Closed/Open/Half-Open) 无状态(持续监控流速)

两者配合效果最佳:限流在前挡流量洪峰,熔断在后兜底保护。

保护下游服务不被过量请求压垮。

常见算法

flowchart LR
    subgraph "令牌桶"
        T1["匀速添加令牌"]
        T2["请求消耗令牌"]
        T3["不够则拒绝"]
    end
    
    subgraph "滑动窗口"
        S1["按时间片切分"]
        S2["统计每片请求数"]
        S3["超过阈值则拒绝"]
    end
算法 特点 适用场景
固定窗口 简单实现,存在边界突发问题 低频场景
滑动窗口 精确控制,内存占用高 需要精细限流的场景
令牌桶 允许一定突发,平均速率受限 API 网关通用方案
漏桶 强制匀速输出,不允突发 防止下游被打满

令牌桶 (Token Bucket)

令牌桶的核心思想:以恒定速率往一个"桶"里放令牌,请求到来时从桶中取令牌,取到则放行,取不到则拒绝或等待。

flowchart LR
    subgraph "🪣 令牌桶内部"
        BUCKET["令牌桶<br/>容量 = maxBurst"]
        REFILL["⚡ 固定速率 r/s 添加令牌<br/>最多填到 maxBurst"]
    end
    
    REQ[请求到达] --> CHECK{桶中有令牌?}
    CHECK -- 是 --> CONSUME["消耗 1 个令牌<br/>✅ 请求放行"]
    CHECK -- 否 --> DROP["❌ 请求被拒绝 / 排队等待"]
    
    REFILL -.-> BUCKET
    CONSUME -.->|"桶 -1"| BUCKET

工作流程:

  1. 初始状态:桶中有 maxBurst 个令牌(满桶)
  2. 以固定速率 r(如 100/s)不断往桶中添加令牌,但总量不超过桶容量
  3. 每个请求到来时尝试获取 N 个令牌(通常 N=1)
  4. 有足够令牌 → 消费后放行;不足 → 拒绝或阻塞
  5. 关键特性:桶未满的令牌会累积,这使得在一段时间空闲后突然来了大量请求时,桶里有足够的令牌可以应对瞬时突发流量

为什么允许突发?

假设 rate = 100/s, maxBurst = 200:

时间 桶中令牌数 行为
t=0s 200 初始满桶
t=1s 300 → 200 理论应增到300,但桶已满,上限200
t=2s~9s 100 稳定状态(每秒消耗 ≈ 每秒生成)
t=10s 200 系统空闲,桶重新蓄满
t=11s 0 ⚡ 瞬间涌入200个请求全部通过(突发!)
t=12s 100 后续仅100个能通过(恢复限速率)

这就是为什么令牌桶适合 API 网关——用户可能积攒了多个请求一口气发过来,直接全部拒绝体验很差;允许合理范围内的突发,用户体验更好。

Go 实现:

type TokenBucket struct {
    mu         sync.Mutex
    tokens     float64   // 当前令牌数
    maxBurst   float64   // 桶容量
    rate       float64   // 补充速率(令牌/秒)
    lastRefill time.Time // 上次补充令牌的时间
}

func NewTokenBucket(rate, maxBurst float64) *TokenBucket {
    return &TokenBucket{
        tokens:     maxBurst, // 初始满桶
        maxBurst:   maxBurst,
        rate:       rate,
        lastRefill: time.Now(),
    }
}

// Allow 检查是否可以通行(非阻塞)
func (tb *TokenBucket) Allow() bool {
    tb.mu.Lock()
    defer tb.mu.Unlock()

    now := time.Now()
    elapsed := now.Sub(tb.lastRefill).Seconds()
    
    // 根据经过的时间补充令牌
    tb.tokens += elapsed * tb.rate
    if tb.tokens > tb.maxBurst {
        tb.tokens = tb.maxBurst // 不能超过桶容量
    }
    tb.lastRefill = now

    // 尝试消费一个令牌
    if tb.tokens >= 1 {
        tb.tokens--
        return true
    }
    return false
}

// Wait 阻塞等待直到获取到令牌
func (tb *TokenBucket) Wait(ctx context.Context) error {
    tb.mu.Lock()
    defer tb.mu.Unlock()

    for {
        if tb.Allow() {
            return nil
        }
        // 计算还需要等多久才有令牌
        waitDuration := time.Duration((1-tb.tokens)/tb.rate) * time.Second
        tb.mu.Unlock()
        
        select {
        case <-time.After(waitDuration):
            tb.mu.Lock()
        case <-ctx.Done():
            return ctx.Err()
        }
    }
}

漏桶 (Leaky Bucket)

漏桶的核心思想:请求像水一样流入桶中,桶以固定的速率向下漏水,漏完才能接收新水。如果桶满了,新来的水就溢出丢弃。

flowchart TD
    REQ["💧 请求源源不断流入"] --> QUEUE["🪣 漏桶<br/>队列缓冲区"]
    QUEUE --> PROCESS["🚰 以固定速率 drainer 处理<br/>不管上游多快,只匀速处理"]
    
    QUEUE --> FULL{"桶满了?"}
    FULL -- 是 --> DROP["💨 丢弃新请求 / 返回 429"]
    
    style QUEUE fill:#e3f2fd
    style PROCESS fill:#c8e6c9
    style DROP fill:#ffebee

工作流程:

  1. 桶有一个固定容量 capacity
  2. 请求到达时进入桶(相当于往桶里倒水)
  3. 处理单元以恒定速率 rate 取出并处理请求(相当于底部有个小孔一直在漏水)
  4. 如果请求到达速度超过处理速度,桶会被逐渐填满
  5. 桶满时,新来的请求直接被拒绝

关键特性对比令牌桶:

场景:突发流量 500qps 涌入,限流配置 rate=100/s, capacity=200

维度 令牌桶行为 漏桶行为
前 200 个请求 ✅ 通过(桶里有足够令牌) ❌ 拒绝(桶已满了)
第 201~300 个 ⏱ 按恢复速率放行(100/s) ────────
后续请求 ❌ 多余的被拒绝 ✅ 匀速处理 100/s
下游视角 有突发性 始终匀速的 100/s
维度 令牌桶 漏桶
突发容忍 ✅ 允许(空闲时令牌会累积) ❌ 不允许(直接拒绝或排队)
输出形态 输入决定输出节奏 始终匀速输出
队列语义 无队列(拒绝或立即执行) 隐含队列(请求先进入桶再被处理)
保护对象 对客户端更友好 对下游处理单元更安全
实现复杂度 需记录剩余令牌和更新时间 只需记录当前量和漏出速率

Go 实现:

type LeakyBucket struct {
    mu         sync.Mutex
    water      float64   // 当前水量(请求数)
    capacity   float64   // 桶容量
    rate       float64   // 排水速率(请求/秒)
    lastDrain  time.Time // 上次排水时间
}

func NewLeakyBucket(capacity float64, rate float64) *LeakyBucket {
    return &LeakyBucket{
        capacity: capacity,
        rate:     rate,
        lastDrain: time.Now(),
    }
}

// Add 尝试加入请求(非阻塞),已满返回 false
func (lb *LeakyBucket) Add() bool {
    lb.mu.Lock()
    defer lb.mu.Unlock()

    now := time.Now()
    elapsed := now.Sub(lb.lastDrain).Seconds()
    
    // 根据时间排出一定量的水
    lb.water -= elapsed * lb.rate
    if lb.water < 0 {
        lb.water = 0 // 不能排成负的
    }
    lb.lastDrain = now

    // 加水
    if lb.water+1 <= lb.capacity {
        lb.water++
        return true // 成功放入桶中
    }
    return false // 桶满了,拒绝
}

// DrainLoop 持续的排水循环(实际服务中的处理方式)
func (lb *LeakyBucket) DrainLoop(ctx context.Context) {
    ticker := time.NewTicker(100 * time.Millisecond) // 每 100ms 排水一次
    defer ticker.Stop()
    
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            lb.mu.Lock()
            drainAmount := lb.rate * 0.1 // 100ms 内可处理的量
            lb.water -= drainAmount
            if lb.water < 0 {
                lb.water = 0
            }
            // 这里执行实际的处理逻辑
            // processNextRequest()
            lb.mu.Unlock()
        }
    }
}

如何选择?

flowchart TD
    Choice["如何选?"] --> Q1{"是否需要支持突发流量?"}
    
    Q1 -- 是 --> TB["选令牌桶 ✅"]
    Q1 -- 否 --> LB["选漏桶 ✅"]
    
    TB --> UseCases1["典型场景:<br/>• REST API 限流<br/>• 第三方调用配额<br/>• 用户体验优先的网关"]
    
    LB --> UseCases2["典型场景:<br/>• DB 连接池限流<br/>• 消息队列消费者速率<br/>• 防抖/削峰压后端"]
    
    style TB fill:#e8f5e9
    style LB fill:#fff3e0
    style UseCases1 fill:#e8f5e9
    style UseCases2 fill:#fff3e0

[!tip] 工程实践建议

  • 生产环境推荐使用成熟库:如 Go 的 uber-go/ratelimit(基于令牌桶)、Redis 的 LIMIT 参数配合 XREADGROUP(漏桶模型)
  • Redis + Lua 脚本是实现分布式令牌桶的最常见方案,保证多实例下的限流一致性
  • 很多云厂商的 API 网关默认采用令牌桶算法,因为它在限制平均速率的同时给了客户端更好的吞吐体验

限流层级

graph TB
    Client["客户端"] --> GW["L1: 网关级限流<br/>防刷 / DDOS"]
    GW --> Biz["L2: 业务级限流<br/>按用户/接口配额"]
    Biz --> Downstream["L3: 下游服务限流<br/>保护实例不被打满"]
    
    style GW fill:#ffebee
    style Biz fill:#fff3e0
    style Downstream fill:#e8f5e9

5. 舱壁隔离 (Bulkhead)

为不同下游服务分配独立的线程池/连接池,防止一个服务的故障蔓延到其他服务。

graph TB
    Caller["调用方"]
    
    subgraph Bulkhead["舱壁隔离"]
        Pool1[订单池<br/>maxActive=10]
        Pool2[用户池<br/>maxActive=5]
        Pool3[支付池<br/>maxActive=8]
    end
    
    Caller --> Pool1
    Caller --> Pool2
    Caller --> Pool3
    
    Pool1 --> OrderSvc[[Order Service]]
    Pool2 --> UserSvc[[User Service]]
    Pool3 --> PaySvc[[Payment Service]]
    
    note[订单服务爆满不会影响用户服务的调用]
    Pool1 -.-> note

[!question]- 💡 什么时候需要舱壁隔离?

  • 单一客户端同时调用多个不稳定下游时
  • 共享进程内存/连接池,某服务线程阻塞会占用全部资源时

不需要的情况: 每个下游已经部署在独立进程中(此时故障天然隔离);下游数量极少,连接池开销可以接受。

// poolgroup 示例:为不同服务隔离连接池
orderPool := pool.New(10, 100) // 活跃10个,上限100个
userPool := pool.New(5, 50)
payPool := pool.New(8, 80)
// 订单服务占满连接不会影响用户服务的调用

6. 降级策略

当系统部分不可用时,通过降级保证核心功能可用。

[!note]- 降级的触发时机

  • 主动降级: 管理员手动关闭非核心功能(大促期间关闭推荐、评论)
  • 被动降级: 熔断器打开后自动切换到兜底响应

生产环境中,主动降级通常是第一选择——在系统还可控时牺牲局部保全整体,比等雪崩发生后再被动的处理代价更小。

flowchart TD
    Req["用户请求"]
    
    Req --> Normal{"正常链路是否可用?"}
    
    Normal -- 是 --> Full["完整功能 ✅"]
    
    Normal -- 否 --> Degrade{"降级级别?"}
    
    Degrade -- "非核心模块" --> Skip["跳过该功能 ⚡"]
    Degrade -- "核心模块不可用" --> Cache["返回缓存兜底 📦"]
    
    Cache -- "无缓存" --> Default["返回默认值/提示页"]
    
    Skip --> Partial["部分可用 ✅"]
    Default --> Minimal["最低可用 ✅"]

常见降级场景

场景 降级方案 影响
推荐服务挂了 展示热门榜单 / 空 用户体验略降
评论服务挂了 隐藏评论区 不影响购买流程
搜索服务超时 返回最近浏览记录 降低用户感知
库存服务不可用 显示"暂时无法确认库存" 引导用户稍后再试

7. 健康检查

sequenceDiagram
    participant LB as 负载均衡器/K8s
    participant S1 as 服务实例 A
    participant S2 as 服务实例 B

    LB->>S1: GET /healthz -> 200 OK
    LB->>S2: GET /healthz -> 503 ERROR
    Note over LB: 标记 S2 不健康,剔除出池
    LB->>S2: GET /healthz -> 200 OK
    Note over LB: 逐步恢复,重新加入池
  • 存活探针 (Liveness):判断"进程是否还活着",挂了则重启
  • 就绪探针 (Readiness):判断"是否能接收流量",未就绪则剔除负载均衡池
  • Readiness 比 Liveness 更重要——一个进程活着但数据库连接耗尽时,应该停止接收流量而不是重启

[!tip] 生产环境最佳实践 健康检查端点 /healthz 不应只做 HTTP 200,还应验证关键依赖(DB、Redis)的连通性。一个数据库连接耗尽的服务返回 200 是虚假的健康信号。


总览:模式对比速查

模式 保护谁 何时生效 复杂度 推荐优先级
⏱ 超时控制 自身线程/连接 每次远程调用 ⭐ 🔴 必须实现
⏱ 重试机制 瞬时故障 失败后自动触发 ⭐⭐ 🔴 幂等操作必做
🔘 熔断器 自身系统 高频失败时 ⭐⭐ 🟠 外部依赖必配
🔀 限流器 下游服务 始终生效 ⭐⭐ 🟠 网关层必配
🧱 舱壁隔离 其他服务调用 某下游异常时 ⭐⭐⭐ 🟡 多下游场景
💤 降级策略 用户核心体验 非核心不可用时 ⭐⭐ 🟠 面向 C 端必做
💚 健康检查 负载均衡/调度 实例异常时 ⭐ 🔴 K8s 必配

[!success]- 一句话总结 超时保底、重试修复瞬时故障、熔断防雪崩、限流护下游、舱壁保隔离、降级保核心。

关联笔记