阶段二 · 服务治理与一致性

弹性治理

一句话总结

分布式系统中,故障不是"如果"而是"何时".
弹性治理通过超时,重试,熔断,限流,隔离舱和降级六把武器,让系统在部分组件失败时仍能提供有损但可用的服务--而非全面崩溃.

为什么需要弹性治理

单体应用中,一个模块调另一个模块是进程内函数调用--要么成功,要么 panic.微服务之间隔着网络,多了一整类新的失败模式:

失败模式 表现 后果
网络超时 请求发出去,响应迟迟不来 goroutine/线程挂起,资源耗尽
服务过载 下游 QPS 超出承载能力 响应变慢 → 上游超时 → 重试 → 雪崩
网络分区 部分实例不可达 请求持续打到不可达实例
级联故障 A 依赖 B,B 依赖 C,C 挂了 从 C → B → A 逐层传播
重试风暴 所有调用方同时重试 下游负载 × N,加速崩溃

没有弹性治理的微服务 = 圣诞树彩灯串联电路.一个灯泡坏了,整串灯全灭.
弹性治理 = 把彩灯改成并联 + 保险丝.一个灯泡坏了其他继续亮,短路时保险丝跳闸保护电路.

超时(Timeout)

最基础也最重要的弹性机制:给每个外部调用设置一个最长等待时间.

为什么必须设置超时

没有超时的调用 = 无限等待.当下游卡住时:

  1. 调用方 goroutine 被阻塞
  2. 请求持续到来,新的 goroutine 不断阻塞
  3. goroutine 数量暴涨,内存耗尽
  4. 服务 OOM 或完全无响应

超时策略

层级 设置位置 推荐值
连接超时 TCP 连接建立 1-3s
请求超时 单次 RPC 等待响应 根据接口 P99 × 2-3
端到端超时 Gateway 或客户端 整条链路总预算
// gRPC:通过 context deadline 控制超时
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

resp, err := client.GetOrder(ctx, req)
if status.Code(err) == codes.DeadlineExceeded {
// 超时 → 降级处理
}

超时要沿调用链递减

如果 Gateway 超时 5s,内部 A→B 超时设为 10s--A 等 B 等 10s,但 Gateway 5s 就返回了,B 的工作白做.

正确做法:使用 gRPC Deadline 传播(02 篇讲过),或手动保证内部超时 < 外部超时.

重试(Retry)

瞬时故障(网络抖动,某个 Pod 重启中)可以通过重试自愈.但重试是双刃剑--用不好会加速系统崩溃.

何时重试

状态码 是否重试 原因
Unavailable 重试 服务暂时不可用,可能很快恢复
DeadlineExceeded 谨慎重试 可能是慢而非挂,重试可能加重负载
ResourceExhausted 退避后重试 限流中,等一下再试
Internal 通常不重试 服务端 bug,重试也是同样结果
InvalidArgument 绝不重试 参数错误,重试 100 次也不会变对
NotFound 绝不重试 资源不存在是确定性结果

退避策略(Backoff)

不能立即重试--所有客户端同时重试会形成重试风暴.必须加退避:

策略 间隔公式 特点
固定间隔 1s, 1s, 1s 简单但容易同步重试
指数退避 1s, 2s, 4s, 8s 给下游恢复时间
指数退避 + 抖动 1s±random, 2s±random, 4s±random 打散重试时间点,避免雷群效应
// 指数退避 + 抖动
func backoff(attempt int) time.Duration {
base := time.Second * time.Duration(1<<attempt) // 1s, 2s, 4s, 8s...
jitter := time.Duration(rand.Int63n(int64(base) / 2)) // 0 ~ base/2 的随机抖动
return base + jitter
}

// gRPC service config 配置自动重试
serviceConfig := `{
"methodConfig": [{
"name": [{"service": "order.v1.OrderService"}],
"retryPolicy": {
"maxAttempts": 3,
"initialBackoff": "0.1s",
"maxBackoff": "1s",
"backoffMultiplier": 2,
"retryableStatusCodes": ["UNAVAILABLE"]
}
}]
}`

重试的前提:操作幂等

如果 CreateOrder 不是幂等的,重试可能创建两个订单.重试策略只能用于幂等操作(查询天然幂等;写操作需要幂等设计--03 篇讲过).

对非幂等操作,宁可返回错误让用户重试,也不要自动重试.

熔断(Circuit Breaker)

当下游持续故障时,重试只会加重负担.熔断器的逻辑:如果连续 N 次调用失败,就不再调用下游,直接快速失败--给下游喘息恢复的机会.

三种状态

stateDiagram-v2
[*] --> Closed
Closed --> Open: 错误率超过阈值
Open --> HalfOpen: 冷却时间结束
HalfOpen --> Closed: 探测请求成功
HalfOpen --> Open: 探测请求失败
状态 行为 转换条件
Closed(关闭) 正常通过所有请求 错误率超阈值 → Open
Open(打开) 所有请求直接失败,不调下游 冷却时间到 → Half-Open
Half-Open(半开) 放过少量探测请求 探测成功 → Closed;失败 → Open

Go 实现示例

import "github.com/sony/gobreaker"

cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "user-service",
MaxRequests: 3, // Half-Open 时放过的探测请求数
Interval: 10 * time.Second, // Closed 状态下错误计数窗口
Timeout: 30 * time.Second, // Open → Half-Open 的冷却时间
ReadyToTrip: func(counts gobreaker.Counts) bool {
// 10 次请求中失败率 > 60% → 熔断
return counts.Requests > 10 &&
float64(counts.TotalFailures)/float64(counts.Requests) > 0.6
},
})

// 使用熔断器包裹调用
result, err := cb.Execute(func() (interface{}, error) {
return client.GetUser(ctx, req)
})
if err == gobreaker.ErrOpenState {
// 熔断中 → 返回降级结果
return cachedUser, nil
}

熔断器的颗粒度

每个下游服务一个熔断器,而非全局一个.否则 A 服务挂了导致 B 服务的熔断器也打开--误伤.

更细粒度可以按"服务+方法"设置独立熔断器.

限流(Rate Limiting)

限流保护自身 不被过量请求打垮.与熔断不同--熔断保护的是调用链下游,限流保护的是自己.

限流层次

位置 保护对象 典型实现
Gateway 整个系统入口 APISIX / Kong 插件
服务自身 单个服务的承载能力 中间件 / 拦截器
下游调用 防止打垮依赖方 客户端限流器

令牌桶实现

import "golang.org/x/time/rate"

// 每秒 100 个请求,允许突发 20
limiter := rate.NewLimiter(rate.Limit(100), 20)

func rateLimitInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
if !limiter.Allow() {
return nil, status.Error(codes.ResourceExhausted, "rate limit exceeded")
}
return handler(ctx, req)
}

分布式限流

上面是单实例限流.如果服务有 10 个副本,每个限 100 QPS = 总共 1000 QPS.

需要精确的全局限流时用 Redis 做中心计数器(如 go-redis/redis_rate),但增加了 Redis 依赖和延迟.大多数场景下,单实例限流 × 副本数的近似值够用.

隔离舱(Bulkhead)

名字来自船舶设计--把船体分成多个水密隔舱,一个进水不会全船沉没.

在微服务中:为不同的依赖/调用方分配独立的资源池,一个下游出问题不会耗尽所有资源.

隔离方式

方式 实现 场景
线程池隔离 每个下游用独立线程池/goroutine pool Java (Hystrix),Go 中用 channel 信号量
信号量隔离 限制并发调用数 轻量,不需要额外线程
进程隔离 不同依赖的调用放在不同 Pod/容器 极端隔离需求
// Go 中用 channel 实现信号量隔离
type Bulkhead struct {
sem chan struct{}
}

func NewBulkhead(maxConcurrency int) *Bulkhead {
return &Bulkhead{sem: make(chan struct{}, maxConcurrency)}
}

func (b *Bulkhead) Execute(ctx context.Context, fn func() error) error {
select {
case b.sem <- struct{}{}:
defer func() { <-b.sem }()
return fn()
case <-ctx.Done():
return status.Error(codes.ResourceExhausted, "bulkhead full")
}
}

// 为每个下游创建独立的隔离舱
var (
userBulkhead = NewBulkhead(50) // 用户服务最多 50 并发
paymentBulkhead = NewBulkhead(20) // 支付服务最多 20 并发
)

降级(Degradation)

当上游故障或自身过载时,主动放弃部分非核心功能,保证核心链路可用.

降级策略

策略 做法 示例
返回缓存 用最近一次正常结果 推荐服务挂了 → 返回热门商品缓存
返回默认值 预设的兜底数据 用户头像服务挂了 → 返回默认头像
功能关闭 直接跳过非核心调用 评论服务过载 → 商品页不显示评论区
简化流程 跳过非必要步骤 风控服务超时 → 小额订单直接放行
func getRecommendations(ctx context.Context, userID string) ([]*Product, error) {
products, err := recommendClient.GetForUser(ctx, userID)
if err != nil {
// 降级:返回热门商品缓存
log.Warn("recommend service degraded", "err", err)
return getHotProductsFromCache()
}
return products, nil
}

降级分级

实践中将功能按重要性分级:
P0(核心) = 登录,下单,支付--绝不降级.
P1(重要) = 库存校验,风控--可短时跳过但需告警.
P2(一般) = 推荐,评论,个性化--可长时间降级不影响核心体验.
降级开关通过配置中心(06 篇 ConfigMap / 远程配置)动态控制.

六把武器的组合使用

这些机制不是互斥的--生产环境中它们层层叠加:

请求进入 →
1. 限流(自身保护)→ 超过则 429
2. 超时(设置 deadline)→ 超时则快速失败
3. 熔断检查 → 如果 Open 则直接降级
4. 隔离舱 → 如果满则快速失败
5. 发起调用 → 等待响应
6. 失败 → 判断是否可重试
可重试 → 退避后重试(回到 3)
不可重试 → 降级处理
7. 成功 → 返回结果
┌──────────┐
│ 请求 │
└──────────┘


┌──────────┐
│ 限流? │
└──────────┘
│ "未限流"

┌──────────┐
│ 设置超时 │
└──────────┘


┌──────────┐
│ 返回 429 │
└──────────┘
┌──────────┐
│ 熔断器状态? │
└──────────┘
│ "Closed"

┌──────────┐
│ 隔离舱有空? │
└──────────┘
│ "满"

┌──────────┐
│ 降级处理 │
└──────────┘
┌──────────┐
│ 发起 RPC │
└──────────┘
│ "成功"

┌──────────┐
│ 返回结果 │
└──────────┘
┌──────────┐
│ 可重试? │
└──────────┘
│ "是"

┌──────────┐
│ 退避 │
└──────────┘

弹性治理的可观测性

治理策略必须配合监控,否则你不知道它们是否生效,阈值是否合理:

指标 含义 关注点
熔断器状态变化 Open/Close 切换次数 频繁切换说明阈值设置不合理
重试次数 / 重试成功率 重试是否有效 重试成功率低说明不是瞬时故障
限流触发次数 流量是否超出容量 持续触发需要扩容
降级触发次数 依赖健康度 频繁降级需要修复根因
P99 延迟变化 超时是否合理 P99 接近超时值说明阈值太紧

快速回顾

  • 超时:每个外部调用必须设 deadline,沿调用链递减
  • 重试:仅限幂等操作 + 可重试状态码,必须指数退避 + 抖动
  • 熔断:连续失败 → 快速失败保护下游,冷却后半开探测
  • 限流:令牌桶保护自身承载能力,Gateway + 服务双层限流
  • 隔离舱:不同下游独立资源池,防止一个拖垮全部
  • 降级:按功能重要性分级,非核心功能可返回缓存/默认值/跳过
  • 六把武器层层叠加使用,配合可观测性验证效果

动手练习

  1. 验证重试状态码过滤:写一个 gRPC 服务端,随机 50% 概率返回 Internal 错误.客户端配置自动重试(仅重试 Unavailable),观察 Internal 错误不会被重试
  2. 实现熔断器:用 sony/gobreaker 给客户端加熔断器:连续 5 次失败后熔断 10 秒.验证熔断期间请求直接失败不再打到服务端
  3. 测试隔离舱:实现一个信号量隔离舱(并发限制 5),用 10 个并发请求测试,观察第 6 个开始被拒绝
  4. 组合弹性治理链路:实现"限流 → 超时 → 熔断 → 降级"完整链路.服务端延迟从 100ms 逐步升高到 5s,观察各层机制依次触发