This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
all-in-kingsoft/hzh/MS/02-服务治理/容错模式/README.md
T
2026-05-06 15:03:31 +08:00

542 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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]] — 熔断器的指标暴露和告警联动