2910 字
约 9 分钟
19
S17-X14 · 熔断、限流与隔离

故障扩散路径通常是同一条:某个依赖变慢 → 调用方线程被占满 → 自己也变慢 → 上游超时 → 整条链路雪崩。熔断、限流、隔离是三把不同的刀:熔断砍掉"已经坏掉的依赖",限流砍掉"超出容量的请求",隔离砍掉"故障在池子之间的传染"。先分清三者,再谈 Resilience4j 怎么配。

1. 三者各治什么病

手段 防的是什么 触发信号 生效动作 用错的后果
熔断 依赖持续失败拖垮调用方 失败率、慢调用比例 快速失败,不再真实调用 过高形同虚设;过低误杀健康依赖
限流 自身容量被打穿 QPS/并发超阈值 拒绝或排队 按单实例算导致总量翻倍
隔离 一个依赖拖垮所有依赖 池子饱和 只影响该池的调用 池子过小,正常请求也排队

三者是串联而非替代:限流在最外层挡过载,隔离把线程资源切开,熔断在池内快速失败。只做熔断,过载流量照样把线程占满;只做限流,依赖抖一下仍然雪崩。

2. 熔断器的三态与迁移条件

状态 行为 迁移到 条件
CLOSED 正常放行,统计成功/失败/慢调用 OPEN 窗口内失败率或慢调用比例超阈值,且样本数 ≥ 最小请求数
OPEN 直接快速失败,不发起真实调用 HALF_OPEN 经过 waitDurationInOpenState
HALF_OPEN 放行少量探测请求 CLOSED / OPEN 探测成功率达阈值 → 关闭;任一失败 → 重新打开

两个参数最容易被忽略:minimumNumberOfCalls 决定"样本不够时不评估",少了它启动后 3 个请求 2 个失败就熔断;permittedNumberOfCallsInHalfOpenState 决定半开放多少探测,放太多等于没熔断。

resilience4j.circuitbreaker:
  instances:
    inventory:
      slidingWindowSize: 100              # TIME_BASED 时单位为秒
      minimumNumberOfCalls: 20
      failureRateThreshold: 50               # 百分比
      slowCallDurationThreshold: 800ms
      slowCallRateThreshold: 60
      waitDurationInOpenState: 10s
      permittedNumberOfCallsInHalfOpenState: 5
      automaticTransitionFromOpenToHalfOpenEnabled: true

slowCallDurationThreshold 的意义是"慢也算失败"——依赖没报错但响应时间翻倍,同样是故障前兆。只统计异常不统计慢调用是很常见的配置缺陷。

3. 熔断粒度:按依赖、按接口、按实例

粒度 例子 优点 缺点
按依赖 调 inventory 的请求共用一个熔断器 样本充足,判断准 一个慢接口拖累整个依赖
按接口 每个下游 method 一个 精准,坏接口不影响好接口 熔断器数量膨胀
按实例 每个下游 ip:port 一个 能剔除单台坏机器 与 LB 耦合,实例变化时状态频繁重置

建议按依赖 + 按关键接口两级;按实例的粒度只在下游实例少且稳定时值得做。

4. 限流的四类算法

算法 原理 突发流量 实现成本
固定窗口 每窗口计数,超阈值拒绝 窗口边界可双倍放行 最低
滑动窗口 按时间片加权或分桶统计 边界问题缓解 中
令牌桶 按速率产令牌,桶可蓄 允许有限突发 中
漏桶 固定速率流出,队列缓冲 完全平滑,不允突发 中,需要队列

固定窗口的边界问题很实在:窗口 1 秒、阈值 100,两个窗口交界处的 20ms 内能通过 200 个。要求匀速用漏桶,允许突发用令牌桶。

5. 分布式限流:Lua 保证原子性

单机限流用 RateLimiter 就够,集群限流必须集中计数。INCR 加 EXPIRE 的两条命令不是原子的,中间崩溃会留下永不过期的 key,所以要用 Lua:

-- KEYS[1]=限流键  ARGV[1]=阈值  ARGV[2]=窗口毫秒
local cur = redis.call('INCR', KEYS[1])
if cur == 1 then
  redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
if cur > tonumber(ARGV[1]) then
  return 0
end
return 1
private static final RedisScript<Long> LIMIT = new DefaultRedisScript<>(LUA, Long.class);
public boolean allow(String key, int threshold, long windowMs) {
    Long r = redisTemplate.execute(LIMIT, List.of("rl:" + key),
            String.valueOf(threshold), String.valueOf(windowMs));
    return r != null && r == 1L;
}

三点注意:窗口用 PEXPIRE 而非应用侧判断(后者受时钟漂移影响);key 要带业务维度(用户/接口/租户),否则全局限流等于全局限速;Redis 本身要扛得住限流流量,限流器成单点是常见翻车点。

6. 舱壁隔离的三种形态

形态 隔离的是什么 开销 适用
线程池隔离 线程资源 上下文切换 + 线程数膨胀 阻塞式调用、下游慢且不可控
信号量隔离 并发数计数 极低(一次 CAS) 响应快、不想付线程代价
连接池隔离 连接数 中(要维护多个池) HTTP 客户端、数据库连接
resilience4j.bulkhead:
  instances:
    inventory:
      maxConcurrentCalls: 20
      maxWaitDuration: 0        # 0 表示不排队,直接拒绝
resilience4j.thread-pool-bulkhead:
  instances:
    payment:
      maxThreadPoolSize: 16
      queueCapacity: 8          # 必须有上限

线程池隔离的代价:每个依赖一套池,10 个依赖 × 16 线程 = 160 个线程常驻,外加队列与上下文切换。它的价值在于下游卡死时只耗尽自己那 16 个线程,而不是吃光 Tomcat 的工作线程。

queueCapacity 不设上限是硬伤:队列无限,请求全堆着等,直到超时才失败,"快速失败"变成"慢速失败"。

7. 超时的层级关系

层 谁设 与下一层的关系
客户端调用方 Feign/WebClient timeout 最内层,必须小于上游给它的超时
网关 nginx / Spring Cloud Gateway 大于客户端超时,留出重试余量
上游服务 自身接口处理预算 大于网关超时,避免上游先掐断

方向只有一个:内层 < 外层。反过来外层先超时、内层还在跑,重试叠上去,同一份工作被做三遍。

# 示例值,按依赖的实际 RT 分布核对,不是基准
spring.cloud.openfeign.client.config.inventory:
  connectTimeout: 200
  readTimeout: 800
  loggerLevel: basic

8. 降级的三个层次

层次 做法 用户体验 风险
返回缓存值 读上一次成功的快照 数据可能陈旧 陈旧数据被当成实时
返回默认值 空列表、0、默认配置 可用但结果空 空值被下游当成"真没有"
关闭非核心功能 关推荐、关评论、关积分 主流程仍可用 需要功能开关,不能靠改代码

降级必须和熔断绑定:熔断打开即自动 fallback,而非人工切开关;fallback 要打日志与指标,否则不知道降级发生了几次。

9. 重试与熔断的相互作用

重试会在故障时放大流量:1 个请求重试 3 次,下游看到 4 倍压力。所以三条规则:

  1. 重试只在幂等操作上做,次数极少(1~2 次);
  2. 重试必须带退避 + 抖动,否则失败请求同时重试形成二次尖峰;
  3. 熔断打开时不再重试,直接 fallback。

Resilience4j 的装饰顺序决定语义:熔断在外、重试在内(Decorators.ofSupplier(...).withCircuitBreaker(cb).withRetry(retry)),此时熔断器统计的是"经过重试后的最终结果";反过来则每次重试都计入熔断统计,失败率被人为重试放大,熔断会更早打开。

10. 可观测:三个必须有的指标

指标 含义 异常信号
circuitbreaker.state 0/1/2 对应 CLOSED/OPEN/HALF_OPEN OPEN 次数突增 → 下游在抖
ratelimiter.availablePermissions 剩余许可 长期为 0 → 阈值低于实际需求
bulkhead.availableConcurrentCalls 剩余并发 长期为 0 且拒绝数上升 → 池子太小
拒绝率 限流拒绝 / 总请求 分接口看,全局平均会掩盖单接口打满

指标要打标签(依赖名、接口名),否则全局 5% 的拒绝率背后可能是某接口 80% 被拒。

11. 常见坑

  • 全局限流阈值按单实例算。10 台各配 100 QPS,集群实际放 1000,远超下游容量。
  • 熔断半开时放太多请求。permittedNumberOfCallsInHalfOpenState 配成 100,等于给快挂的依赖再来一轮压力测试。
  • 重试没有退避。失败瞬间的重试风暴比原始故障更致命。
  • 线程池隔离没设队列上限。请求排队等到超时,快速失败退化成慢速失败。
  • 只熔断不限流。过载照样把线程占满,熔断器没机会统计。
  • fallback 里再调同一个依赖。等于没降级,还把调用链拉长。

12. 排错:五种"配了但没生效"

现象 常见原因 处理
熔断器从不打开 minimumNumberOfCalls 过高,或异常未被记录 降门槛,确认异常在 recordExceptions 内
熔断打开后不恢复 automaticTransitionFromOpenToHalfOpen 未开、无调用触发 开启自动迁移或加定时探测
限流阈值看着生效但下游仍被打挂 阈值按单实例算,或 key 维度太粗 按集群总量折算单机阈值,细化 key
隔离池满但下游健康 池子太小、maxWaitDuration 过长 按 P99 并发调大,等待设 0
超时总在外层触发 内层 > 外层,或重试叠加 重排层级,关掉熔断打开时的重试

13. 边界:什么情况不该上这套

场景 更合适的做法 理由
依赖是同进程本地调用 直接超时控制 没有网络故障,隔离收益极低
批量离线任务 队列 + 并发度控制 熔断对批处理没有语义
内部低 QPS 管理接口 不做限流 阈值难估,误拒成本高于过载成本
幂等且极便宜的下游 允许重试,不熔断 重试成本低于误熔断
强一致写链路 快速失败 + 报错 降级返回旧值破坏一致性

一句话收束:限流挡在门口,隔离把房间隔开,熔断在房间里快速认输,超时层级从内到外必须递增,重试永远带退避。

14. 自测

  1. 熔断、限流、隔离分别防的是什么,为什么不能互相替代?
  2. minimumNumberOfCalls 不配会发生什么?
  3. 为什么"慢调用"也要计入熔断判定?
  4. 固定窗口限流的边界问题具体怎么发生,哪种算法能缓解?
  5. 线程池隔离的两个隐性代价是什么,队列上限为什么必须有?
  6. 装饰顺序"熔断在外、重试在内"和反过来,统计结果差在哪?
S17-X14 · 熔断、限流与隔离
https://www.clxhxhhr.top/posts/5888/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。