542 lines
17 KiB
Markdown
542 lines
17 KiB
Markdown
---
|
||
tags: [microservice, circuit-breaker, retry, rate-limiting, bulkhead, health-check]
|
||
create time: 2026-05-05
|
||
---
|
||
|
||
# 容错模式
|
||
|
||
## 概述
|
||
|
||
微服务最大的挑战是 **网络不可靠**。分布式系统的每个远程调用都可能超时、被拒绝、或部分成功。必须提前设计降级和恢复策略。
|
||
|
||
```mermaid
|
||
graph TB
|
||
A["🛡️ 超时控制"] --> B["⏱ 重试机制"]
|
||
B --> C["⚡ 熔断器"]
|
||
C --> D["🔀 限流"]
|
||
D --> E["🧱 舱壁隔离"]
|
||
E --> F["💤 降级策略"]
|
||
```
|
||
|
||
> [!warning] 核心认知
|
||
> 不要假设任何网络调用会成功。每个远程调用的代码都应该考虑失败路径。
|
||
|
||
## 1. 超时控制
|
||
|
||
这是所有容错机制的**前提**——没有超时的系统迟早会被拖垮。
|
||
|
||
```go
|
||
// 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)
|
||
...
|
||
```
|
||
|
||
```go
|
||
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 |
|
||
|
||
## 3. 熔断器 (Circuit Breaker)
|
||
|
||
当下游服务频繁失败时,快速失败避免线程堆积雪崩。
|
||
|
||
```mermaid
|
||
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 |
|
||
|
||
### 实战示例
|
||
|
||
```go
|
||
// 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()
|
||
}
|
||
```
|
||
|
||
## 4. 限流 (Rate Limiting)
|
||
|
||
保护下游服务不被过量请求压垮。
|
||
|
||
### 常见算法
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
subgraph "令牌桶"
|
||
T1["匀速添加令牌"]
|
||
T2["请求消耗令牌"]
|
||
T3["不够则拒绝"]
|
||
end
|
||
|
||
subgraph "滑动窗口"
|
||
S1["按时间片切分"]
|
||
S2["统计每片请求数"]
|
||
S3["超过阈值则拒绝"]
|
||
end
|
||
```
|
||
|
||
| 算法 | 特点 | 适用场景 |
|
||
|------|------|---------|
|
||
| **固定窗口** | 简单实现,存在边界突发问题 | 低频场景 |
|
||
| **滑动窗口** | 精确控制,内存占用高 | 需要精细限流的场景 |
|
||
| **令牌桶** | 允许一定突发,平均速率受限 | API 网关通用方案 |
|
||
| **漏桶** | 强制匀速输出,不允突发 | 防止下游被打满 |
|
||
|
||
### 令牌桶 (Token Bucket)
|
||
|
||
令牌桶的核心思想:**以恒定速率往一个"桶"里放令牌,请求到来时从桶中取令牌,取到则放行,取不到则拒绝或等待。**
|
||
|
||
```mermaid
|
||
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 ~ t=9s 100 稳定在 100(每秒消耗 ≈ 每秒生成)
|
||
t=10s 200 系统空闲,桶重新蓄满
|
||
t=11s 0 ⚡ 瞬间涌入 200 个请求全部通过(突发!)
|
||
t=12s 0 后续只有 100 个能通过(恢复限速率)
|
||
```
|
||
|
||
这就是为什么令牌桶适合 API 网关——用户可能积攒了多个请求一口气发过来,直接全部拒绝体验很差;允许合理范围内的突发,用户体验更好。
|
||
|
||
**Go 实现:**
|
||
|
||
```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)
|
||
|
||
漏桶的核心思想:**请求像水一样流入桶中,桶以固定的速率向下漏水,漏完才能接收新水。如果桶满了,新来的水就溢出丢弃。**
|
||
|
||
```mermaid
|
||
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个通过 │ ← 桶里够令牌 │ ❌桶满了拒绝 │ ← 已经满了
|
||
│ ⏱后面100个/秒 │ ← 恢复限速率 │ ──────────── │
|
||
│ ❌多余的被拒 │ │ ✅匀速处理100/s│ ← 底部漏水
|
||
└──────────────┘ └──────────────┘
|
||
↑
|
||
无论上游来多少,
|
||
下游只看到匀速的100/s
|
||
```
|
||
|
||
| 维度 | 令牌桶 | 漏桶 |
|
||
|------|--------|------|
|
||
| **突发容忍** | ✅ 允许(空闲时令牌会累积) | ❌ 不允许(直接拒绝或排队) |
|
||
| **输出形态** | 输入决定输出节奏 | 始终匀速输出 |
|
||
| **队列语义** | 无队列(拒绝或立即执行) | 隐含队列(请求先进入桶再被处理) |
|
||
| **保护对象** | 对客户端更友好 | 对下游处理单元更安全 |
|
||
| **实现复杂度** | 需记录剩余令牌和更新时间 | 只需记录当前量和漏出速率 |
|
||
|
||
**Go 实现:**
|
||
|
||
```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()
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 如何选择?
|
||
|
||
```mermaid
|
||
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 网关默认采用令牌桶算法,因为它在限制平均速率的同时给了客户端更好的吞吐体验
|
||
|
||
### 限流层级
|
||
|
||
```mermaid
|
||
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)
|
||
|
||
为不同下游服务分配独立的线程池/连接池,防止一个服务的故障蔓延到其他服务。
|
||
|
||
```mermaid
|
||
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
|
||
```
|
||
|
||
```go
|
||
// poolgroup 示例:为不同服务隔离连接池
|
||
orderPool := pool.New(10, 100) // 活跃10个,上限100个
|
||
userPool := pool.New(5, 50)
|
||
payPool := pool.New(8, 80)
|
||
// 订单服务占满连接不会影响用户服务的调用
|
||
```
|
||
|
||
## 6. 降级策略
|
||
|
||
当系统部分不可用时,通过降级保证核心功能可用。
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
Req["用户请求"]
|
||
|
||
Req --> Normal{"正常链路是否可用?"}
|
||
|
||
Normal -- 是 --> Full["完整功能 ✅"]
|
||
|
||
Normal -- 否 --> Degrade{"降级级别?"}
|
||
|
||
Degrade -- "非核心模块" --> Skip["跳过该功能 ⚡"]
|
||
Degrade -- "核心模块不可用" --> Cache["返回缓存兜底 📦"]
|
||
|
||
Cache -- "无缓存" --> Default["返回默认值/提示页"]
|
||
|
||
Skip --> Partial["部分可用 ✅"]
|
||
Default --> Minimal["最低可用 ✅"]
|
||
```
|
||
|
||
### 常见降级场景
|
||
|
||
| 场景 | 降级方案 | 影响 |
|
||
|------|---------|------|
|
||
| 推荐服务挂了 | 展示热门榜单 / 空 | 用户体验略降 |
|
||
| 评论服务挂了 | 隐藏评论区 | 不影响购买流程 |
|
||
| 搜索服务超时 | 返回最近浏览记录 | 降低用户感知 |
|
||
| 库存服务不可用 | 显示"暂时无法确认库存" | 引导用户稍后再试 |
|
||
|
||
## 7. 健康检查
|
||
|
||
```mermaid
|
||
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 更重要**——一个进程活着但数据库连接耗尽时,应该停止接收流量而不是重启
|
||
|
||
## 关联笔记
|
||
|
||
- [[02-服务治理/API网关/README]] — 网关层的限流和熔断插件
|
||
- [[02-服务治理/服务发现/README]] — 健康检查是服务发现的基础
|
||
- [[04-可观测性/Metrics监控/README]] — 熔断器的指标暴露和告警联动
|