熔断与容错
断路器、降级、Resilience4j 直觉。
熔断与容错
这一课你将能做到
- 给上一课的 ProblemClient 配上 Resilience4j 熔断器和 fallback,problem-service 挂掉时学习面板照常返回兜底数据
- 说出断路器三状态(CLOSED → OPEN → HALF_OPEN)的转换条件,并能通过 actuator 端点现场观察状态翻转
- 分清熔断、降级、限流各解决什么问题,能画出「超时 → 熔断 → 降级 → 限流」的雪崩防护组合拳
为什么要学
上一课配好了 Feign 和超时,但只解决「等太久就报错」。想象线上场景:problem-service 因慢 SQL 卡住,响应从 50ms 恶化到 8s。user-service 里每个进学习面板的请求都卡在 Feign 调用上 8 秒,Tomcat 默认 200 个工作线程几秒内被占满——这时连不需要题目数据的接口(登录、查个人信息)也全部无响应。problem 服务的故障,把完全无辜的 user 服务拖死了,这就是服务雪崩。
超时只是把「无限等」变成「等 N 秒后报错」,线程依然被占用。断路器(Circuit Breaker)再加一层:发现下游持续失败就主动「跳闸」,之后的请求不再真正调用下游(不再占用线程等超时),而是立刻走降级逻辑返回兜底数据。等下游缓过来,再放少量请求「试探」,成功了就恢复。
Spring Cloud CircuitBreaker 是抽象层,Spring Cloud 2023.0.1 的默认实现是 Resilience4j(Netflix Hystrix 已进入维护模式,新项目不用)。
先建立图像
user-service 调用 problemClient.getProblem(id)
│
┌─── 断路器当前状态? ───┐
▼ ▼ ▼
CLOSED OPEN HALF_OPEN
(闭合) (打开) (半开试探)
│ │ │
正常发起调用 直接走fallback 放行少量
统计失败率 (不占线程) 试探请求
│ │ │
失败率≥50%? │ 试探成功?
是│ │否 │ 是│ │否
▼ ▼ │ ▼ ▼
OPEN 继续CLOSED │ CLOSE OPEN
│ │
等 10s 冷却 ─────────────▶ 转入 HALF_OPEN
| 状态 | 行为 | 离开条件 |
|---|
| CLOSED(闭合) | 正常调用,用滑动窗口统计失败率 | 失败率 ≥ 阈值 → OPEN |
| OPEN(打开) | 拒绝调用,直接走 fallback,不占用线程 | 冷却时间到 → HALF_OPEN |
| HALF_OPEN(半开) | 只放行少量试探请求 | 达标 → CLOSED,不达标 → OPEN |
再记住三兄弟的分工——它们经常被混为一谈:
| 手段 | 针对什么 | 动作 | 谁来做 |
|---|
| 熔断 | 下游持续故障 | 跳闸,停止调用,防止故障传染 | 调用方(Resilience4j) |
|
降级
| 限流 | 流量超过本服务容量 | 拒绝/排队多余请求,保护自己 | 网关或本服务(如 Bucket4j、Sentinel) |
降级不是独立开关,它是熔断、超时、异常发生时的兜底动作;熔断是触发降级的机制之一。
跟着做
步骤 1:user-service 添加依赖
在上一课 openfeign、eureka-client、loadbalancer 的基础上追加:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-resilience4j</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
步骤 2:打开 Feign 的熔断开关
spring:
cloud:
openfeign:
circuitbreaker:
enabled: true # 打开后每个 Feign Client 都会被断路器包裹
步骤 3:编写 fallback 并挂到 Feign 接口上
改写 ProblemClient(fallback 指向一个实现了同接口的 Spring Bean):
package com.example.user.client;
import com.example.user.dto.ProblemDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@FeignClient(
name = "problem-service",
fallback = ProblemClientFallback.class // 兜底实现
)
public interface ProblemClient {
@GetMapping("/problems/{id}")
ProblemDTO getProblem(@PathVariable("id") Long id);
}
fallback 类——注意必须加 @Component,方法签名与接口完全一致:
package com.example.user.client;
import com.example.user.dto.ProblemDTO;
import org.springframework.stereotype.Component;
@Component
public class ProblemClientFallback implements ProblemClient {
@Override
public ProblemDTO getProblem(Long id) {
// 降级:返回兜底数据,而不是让异常炸穿学习面板
return new ProblemDTO(id, "题目信息暂不可用,请稍后刷新", "UNKNOWN");
}
}
步骤 4:配置 Resilience4j 实例参数
resilience4j:
circuitbreaker:
instances:
problem-service: # 实例名 = Feign Client 的服务名
slidingWindowSize: 10 # 滑动窗口:统计最近 10 次调用
minimumNumberOfCalls: 5 # 窗口内不足 5 次不计算失败率(防止偶发误判)
failureRateThreshold: 50 # 失败率 ≥ 50% 就跳闸(OPEN)
waitDurationInOpenState: 10s # OPEN 持续 10 秒后进入 HALF_OPEN
permittedNumberOfCallsInHalfOpenState: 3 # 半开状态放行 3 个试探请求
automaticTransitionFromOpenToHalfOpenEnabled: true # 到点自动转半开(否则等下一个请求触发)
slowCallDurationThreshold: 3s # 超过 3s 的调用记为"慢调用"
slowCallRateThreshold: 80 # 慢调用占比 ≥ 80% 也跳闸
步骤 5:注解版 @CircuitBreaker(非 Feign 场景)
不是走 Feign 的调用(比如 RestClient 调第三方 API、本地慢操作),用注解手动包裹:
package com.example.user.service;
import com.example.user.dto.ProblemDTO;
import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Service;
@Service
public class ProblemService {
private final ProblemClient problemClient;
public ProblemService(ProblemClient problemClient) {
this.problemClient = problemClient;
}
@CircuitBreaker(name = "problem-service", fallbackMethod = "getProblemFallback")
public ProblemDTO getProblem(Long id) {
return problemClient.getProblem(id);
}
/** 签名要求:与原方法一致,最后多一个 Throwable 参数 */
public ProblemDTO getProblemFallback(Long id, Throwable t) {
return new ProblemDTO(id, "题目信息暂不可用", "UNKNOWN");
}
}
步骤 6:用 actuator 观察熔断器状态(强烈建议亲手做)
management:
endpoints:
web:
exposure:
include: health,circuitbreakers
访问 http://localhost:8081/actuator/circuitbreakers,能看到每个实例的实时状态。
- 一切正常时访问
http://localhost:8081/dashboard/1001/problem/1,状态是 CLOSED;
- 手动停掉 problem-service,继续连续访问(超过 5 次)——前几次报超时,之后立刻变成兜底文案,状态翻成
OPEN,且响应时间从 8 秒级骤降到毫秒级(不再傻等超时);
- 等 10 秒后状态变为
HALF_OPEN;把 problem-service 重新启动,试探请求成功,状态回到 CLOSED。
讲清楚要点
- 滑动窗口怎么计数:Resilience4j 默认按「次数」维护一个环形数组(
slidingWindowSize: 10,只看最近 10 次调用);也可配 slidingWindowType: TIME_BASED 改为按时间窗口统计。窗口内调用数达到 minimumNumberOfCalls 才开始计算失败率,避免上线头几个请求偶发失败就误跳闸。
- 状态机转换条件(必背):CLOSED 状态下,滑动窗口内失败率 ≥
failureRateThreshold(或慢调用率 ≥ slowCallRateThreshold)→ OPEN;OPEN 持续 waitDurationInOpenState → HALF_OPEN;HALF_OPEN 中试探请求失败率低于阈值 → CLOSED,否则再次 → OPEN。
- OPEN 期间的请求零成本:连下游都不碰,直接进 fallback——这就是熔断保护调用方线程池的核心价值,也是它和「超时重试」的本质区别。
- Feign 集成方式有两条路:开
spring.cloud.openfeign.circuitbreaker.enabled=true 后用 @FeignClient(fallback=...)(整套接口的兜底)或 fallbackFactory(能拿到异常原因,排查问题更好用);非 Feign 场景用 @CircuitBreaker(name, fallbackMethod) 注解。注意 fallback 触发的前提是断路器介入,只配 fallback 类、不开 circuitbreaker.enabled 是不生效的。
- 熔断 ≠ 降级 ≠ 限流:熔断是「要不要调用下游」的开关;降级是「拿不到正常结果时返回什么」的业务动作;限流是「保护自己不被流量打垮」,放在网关或入口层,三者组合使用。
- Resilience4j 是一套工具箱:除了 CircuitBreaker 还有 Bulkhead(舱壁隔离,限制并发调用数,防止单个下游吃光线程池)、TimeLimiter(超时限制,配合 Reactive 使用)、Retry(有限重试,只适合瞬时抖动,下游真挂时重试是火上浇油)。
你可能会懵的地方
-
fallback 什么时候不生效?
①没开 spring.cloud.openfeign.circuitbreaker.enabled=true;②fallback 类没加 @Component(或没被扫描到);③fallback 方法签名与接口不一致;④抛出的是 FeignException 之外的、被你自己的代码 catch 掉的异常——断路器根本没机会介入。逐项排查。
-
熔断会不会误伤?下游只是抖了一下就跳闸?
minimumNumberOfCalls: 5 + 失败率阈值 50% 意味着至少要「最近 10 次里挂掉一半」才跳闸,偶发一两次失败不触发。参数调优的本质就是权衡「保护灵敏度」和「误伤率」,没有万能值。
-
降级返回兜底数据,用户体验受损了怎么办?
降级设计是产品问题不只是技术问题:能缓存的用上次的 Redis 缓存旧值,能算默认值的给默认值,实在不行的才提示「稍后重试」。原则是核心链路可用、次要信息可缺——学习面板少了题目副标题,远好过整个页面 500。
-
Retry 和 CircuitBreaker 一起用,顺序是?
Retry 在外层、CircuitBreaker 在内层:先重试(比如重试 2 次),重试耗尽的结果计入熔断统计。这样瞬时抖动被重试消化,持续故障才触发熔断。反过来配会让每次重试都被熔断拦截,重试形同虚设。
-
OPEN 状态下所有请求都降级,有没有例外?
默认所有请求都走 fallback。HALF_OPEN 只放行 permittedNumberOfCallsInHalfOpenState 个试探请求,其余照样降级。这就是为什么冷却时间不能设太长——否则下游恢复了,流量还要排队等试探。
错法对照
| 错法 | 问题 | 正解 |
|---|
只配了 fallback 类,不开 circuitbreaker.enabled | 断路器没接入 Feign,降级永远不触发 | 两个配置成对出现 |
waitDurationInOpenState: 60s 且不支持半开 | 下游已恢复却要等满 60s,白降级 1 分钟 | 10s 左右 + 自动转半开试探 |
| 在 fallback 里继续调用同一个下游 | 降级方法本身又卡住,线程照样被占 | fallback 只用本地数据/缓存,不发远程调用 |
| 用 Retry 兜底下游宕机 | 每个请求都傻等 3 次重试,放大故障 | 重试只留给瞬时抖动,持续故障靠熔断 |
无脑 failureRateThreshold: 10 | 轻微抖动就跳闸,频繁误伤正常流量 | 失败率 50% 起步,配合 minimumNumberOfCalls |
卡点急救
- 启动报
No fallback instance of type ... found:fallback 类忘了 @Component,或它所在的包不在 @ComponentScan 范围内。
- 熔断器状态永远是 CLOSED,下游挂了也不降级:确认访问的是同一个实例名(
@FeignClient(name) == yml 里 resilience4j.circuitbreaker.instances 的 key);确认请求真的发出去了(minimumNumberOfCalls 没达到之前不统计)。
- 想看到 HALF_OPEN 却总是直接 CLOSED:
automaticTransitionFromOpenToHalfOpenEnabled 没开时,要等下一个请求到来才检查冷却时间;开了之后状态会准时翻转,刷新 actuator 端点观察。
面试常问
-
断路器三状态的转换条件?(必背)
CLOSED:统计滑动窗口内的调用失败率(或慢调用比例),达到 failureRateThreshold 转 OPEN;OPEN:不再发起真实调用、直接降级,持续 waitDurationInOpenState 后转 HALF_OPEN;HALF_OPEN:放行 permittedNumberOfCallsInHalfOpenState 个试探请求,成功率达到预期则转回 CLOSED,否则再次 OPEN。核心价值:OPEN 期间请求不占用线程等待超时,把故障隔离在下游,防止线程池耗尽引发的雪崩。
-
熔断、降级、限流的区别?
限流在入口处拒绝超出容量的请求,保护自己;熔断在调用方检测下游持续故障后跳闸,停止调用、防止故障传染;降级是熔断/超时/异常发生时的兜底动作,返回默认值、缓存或提示。线上组合拳:入口限流(网关层)→ 调用超时(Feign/TimeLimiter)→ 熔断防传染(Resilience4j)→ 降级保体验(fallback),四层从外到内层层设防。
-
Hystrix 和 Resilience4j 怎么选?为什么?
新项目选 Resilience4j:Hystrix 已停止更新、只支持线程池/信号量隔离的老模型;Resilience4j 是函数式轻量库、原生支持 Spring Boot 3 / Jakarta、提供滑动窗口 + 慢调用检测 + Bulkhead/Retry/RateLimiter 全家桶,与 Spring Cloud CircuitBreaker 抽象无缝集成。面试能说出「Spring Cloud 2023 默认实现就是 Resilience4j」即可。
自测三问
- HALF_OPEN 状态放行的 3 个试探请求全部成功后,断路器进入什么状态?
- Feign 的 fallback 生效需要哪两个条件同时满足?
- 为什么 OPEN 状态能保护调用方的 Tomcat 线程池?
小结一句话
断路器用「滑动窗口统计失败率 → 状态机自动跳闸 → 半开试探恢复」保护调用方,fallback 提供降级兜底,与超时、限流组合成完整的雪崩防护体系。
延伸