故障扩散路径通常是同一条:某个依赖变慢 → 调用方线程被占满 → 自己也变慢 → 上游超时 → 整条链路雪崩。熔断、限流、隔离是三把不同的刀:熔断砍掉"已经坏掉的依赖",限流砍掉"超出容量的请求",隔离砍掉"故障在池子之间的传染"。先分清三者,再谈 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~2 次);
- 重试必须带退避 + 抖动,否则失败请求同时重试形成二次尖峰;
- 熔断打开时不再重试,直接 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. 自测
- 熔断、限流、隔离分别防的是什么,为什么不能互相替代?
minimumNumberOfCalls不配会发生什么?- 为什么"慢调用"也要计入熔断判定?
- 固定窗口限流的边界问题具体怎么发生,哪种算法能缓解?
- 线程池隔离的两个隐性代价是什么,队列上限为什么必须有?
- 装饰顺序"熔断在外、重试在内"和反过来,统计结果差在哪?