熔断、隔离、限流与降级:四种机制分别阻断什么故障
一个服务可以每秒只接收十个请求,却同时积压数百个尚未结束的调用;也可以并发始终只有十个,但持续向已经失败的依赖发送新请求。进入速率、正在执行的数量、近期调用结果,是三种不同的测量值。
限流、隔离和熔断分别使用这些测量值决定是否放行。请求被拒绝或依赖调用失败以后,才轮到降级逻辑决定还能向用户提供什么结果。
四种保护各自限制什么
| 机制 | 观察或限制的对象 | 常见拒绝原因 | 不直接处理的部分 |
|---|---|---|---|
| RateLimiter 限流 | 一个周期内的准入许可,或其他明确的流量规则 | 本周期额度用完 | 已经进入的请求执行多久 |
| Bulkhead 隔离 | 同时占用某类执行资源的数量 | 并发许可或隔离队列已满 | 近期失败率是否升高 |
| CircuitBreaker 熔断 | 一段样本中的失败、慢调用与恢复探测 | 依赖被判断为暂不适合继续调用 | 单次网络请求的具体超时时间 |
| Fallback 降级 | 原操作不可用时可接受的替代结果 | 无安全替代值时仍然失败 | 自动修复原依赖或撤销已提交写入 |
隔离可以采用信号量,也可以使用独立线程池和有界队列。信号量 Bulkhead 让原来的调用线程获取许可、执行、归还许可;线程池 Bulkhead 会把任务提交到另一组线程。异步调用需要让许可覆盖任务真正完成的时间,如果只包住“提交 Future”这个瞬间,并发控制就会提前结束。Resilience4j Bulkhead区分了两种实现。
限流的“限”也要说明范围。单 JVM 的两次许可,在十台实例上是十份独立额度;全局租户额度需要共享计数或集中准入服务,还要处理协调服务不可用时允许还是拒绝。令牌桶允许一定突发,漏桶强调平滑输出,固定窗口在周期交界可能集中放行,滑动窗口则换取更细的统计。Resilience4j 的 RateLimiter按自己的刷新周期与许可状态运作,不能把所有算法都称作同一种 QPS 限制。
保护对象通常按“依赖 + 操作类别”划分。支付提交和支付查询的失败语义、时限及可用替代结果不同,共用一个熔断器会互相影响。另一方面,为每个订单号创建一个熔断器会产生大量稀疏样本和常驻对象。以有限、稳定的操作集合命名,更容易配置和观察。
Spring Cloud 抽象与 Resilience4j 实现
Spring Cloud CircuitBreaker 提供 Factory 与调用抽象,Resilience4j 实现统计窗口、许可和状态转换。接入 starter 后,默认配置可能同时涉及 CircuitBreaker、TimeLimiter 与 Bulkhead;只看到一个 run 方法,无法推导底层只有一个保护器。Spring Cloud 的默认配置和 Bulkhead 配置需要一起核对。
实验直接调用 Resilience4j 核心对象,便于分别观察三个组件。依赖版本由 Cloud 版本 2025.1.3 的 BOM 管理,其中 CircuitBreaker 5.0.3 的 依赖声明锁定 Resilience4j 2.3.0。工程没有启用 Spring Cloud CircuitBreaker starter,也没有依赖注解代理自动织入。
统计窗口与并发许可怎样决定放行
熔断器何时开始计算失败率
CLOSED 表示允许调用并记录结果,不是“关闭调用”。计数窗口记录最近若干次完成结果;时间窗口聚合最近若干秒的结果。达到 minimumNumberOfCalls 之前,样本不足,失败率阈值不会触发熔断。
实验配置:计数窗口 4,最小样本 4,失败率阈值 50%
空窗口
├─ 第 1 次失败:样本不足,继续 CLOSED
├─ 第 2 次失败:样本不足,继续 CLOSED
├─ 第 3 次失败:样本不足,继续 CLOSED
└─ 第 4 次失败:失败率 100%,进入 OPEN
OPEN
├─ 等待期内:拒绝新调用,不发送到依赖
└─ 4 秒后有调用到达:进入 HALF_OPEN,最多允许 2 次探测
├─ 两次成功:CLOSED
└─ 探测失败率达到阈值:重新 OPEN这些数字为可观察的小型实验配置,不适合不加分析地复制到生产。样本量过小容易抖动,窗口太长又可能把旧故障长期带入判断。低流量接口与高流量接口通常不能共用同一组最小样本数。
Resilience4j 还可以按慢调用比例触发熔断。慢调用即使返回成功,也会进入独立的慢调用统计;但“慢调用阈值”只用于评价已经完成的调用,并不会自动在该时刻中断它。网络时限或 TimeLimiter需要另外配置。详细状态与阈值定义见 CircuitBreaker 文档。
默认关闭自动半开转换时,OPEN 的等待期结束后,需要下一次请求触发进入 HALF_OPEN。读取状态接口可能仍然看到 OPEN。半开探测若迟迟不结束,也需要单次超时与最大半开等待时间;否则少量悬挂请求会长期占住探测资格。
哪些结果进入统计
HTTP 客户端通常把“收到 HTTP 响应”和“业务调用成功”分开处理。JDK HttpClient 收到 503 会返回一个正常的 HttpResponse,而不会自动抛依赖故障异常。工程在适配层把 503 等不符合契约的响应转换成 DependencyFailure,再交给熔断器记录。
403 被转换成 BusinessRejected 并明确忽略;参数非法在调用开始前返回 400。ignoreExceptions 排除的结果既不记成功,也不记失败。仅设置 recordExceptions 时,其他未匹配异常的默认计数行为还要查看库定义,不能把“未记失败”理解为“已从分母排除”。
统计窗口大小也不限制并发。即使窗口只有四条,CLOSED 时仍可有许多调用同时开始,等这些调用完成后才陆续更新统计。要限制正在执行的数量,需使用 Bulkhead。
许可必须随真实操作归还
容量为一的信号量 Bulkhead:
请求 A 获取许可 → 调用远端 → 等待远端返回 → 归还许可
│
请求 B 尝试获取许可 ──────┘
没有空闲许可且等待为 0 → BulkheadFullException增加等待队列只会把拒绝推迟。请求在队列里消耗了大部分时限,拿到许可后再访问下游,常常形成用户已经离开、服务还在工作的情况。队列长度、获取等待和下游操作时限应放在一起估算。
所有保护器的内存状态也有生命周期。进程重启后,熔断样本、局部限流额度和缓存重新开始;扩容会增加独立保护器数量。运营时需要看整个服务的调用量和拒绝量,不能把一个实例的指标直接当集群总值。
让 Resilience4j 处理真实依赖失败
准备并启动两个进程
下载 完整实验 ZIP,解压目录中的 README.md 列出 Worker、保护对象、Controller 和六个集成测试的位置。
环境为 Linux、Bash、Docker Engine 与 Compose、curl、jq、unzip。Boot 4.1.1 / Cloud 版本 2025.1.3,Maven 3.9.12,编译目标 Java 17;构建用 JDK 25,运行镜像 Temurin 25.0.4_7。版本线入口见 Spring Cloud。
set -euo pipefail
test "$(id -u)" -ne 0 || exit 1
for tool in docker curl jq unzip; do command -v "$tool" || exit 1; done
docker info >/dev/null
docker compose version
LAB_ROOT=$(mktemp -d)
unzip microservice-resilience-lab.zip -d "$LAB_ROOT"
cd "$LAB_ROOT/microservice-resilience-lab"
mkdir -p .m2
chmod -R a+rX worker caller
docker run --rm \
--user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/m2 -e MAVEN_OPTS=-Duser.home=/tmp \
-v "$PWD:/work" -v "$PWD/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -Dmaven.repo.local=/m2 clean verify应显示六个测试通过及 BUILD SUCCESS。首次下载依赖可能较慢;遇到 Maven 仓库 TLS 或网络错误先解决下载问题,不要修改组件版本绕过。构建失败报告位于 caller/target/surefire-reports。
构建容器使用宿主 UID/GID,.m2 为当前用户可写。运行时 Caller 和 Worker 都为 UID/GID 10001;挂载 JAR 只读,端口分别为回环 18181 和 18182。
docker compose up -d
docker compose exec -T caller id
ready=0
for i in $(seq 1 60); do
if curl -q --noproxy '*' --fail-with-body --silent --max-time 2 \
http://127.0.0.1:18181/actuator/health |
jq -e '.status=="UP"' >/dev/null; then
ready=1
break
fi
sleep 1
done
test "$ready" -eq 1 || { docker compose logs --tail 80; exit 1; }这里的 HTTP 管理和等待控制接口没有认证,只能在隔离本机实验中使用。curl -q 跳过默认配置,--noproxy '*' 避免回环请求受代理环境影响;完整选项见 curl 手册。
先使用 Bulkhead 路径完成一次成功调用,它不会给熔断器添加样本:
curl -q --noproxy '*' --fail-with-body --silent --show-error \
--max-time 9 http://127.0.0.1:18181/call/bulkhead预期 {"priceMinor":1990}。报价来自实际 Worker HTTP 响应。
四次失败触发熔断,第五次不再访问 Worker
工程真正执行的保护代码为:
CircuitBreaker breaker = CircuitBreaker.of("price",
CircuitBreakerConfig.custom()
.slidingWindowSize(4).minimumNumberOfCalls(4)
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(4))
.permittedNumberOfCallsInHalfOpenState(2)
.maxWaitDurationInHalfOpenState(Duration.ofSeconds(5))
.recordExceptions(DependencyFailure.class)
.ignoreExceptions(BusinessRejected.class)
.build());
String result = breaker.executeSupplier(() -> remote(mode));先定义实验请求函数。预期错误不使用 --fail-with-body,而是先确认 curl 传输成功,再检查 HTTP 状态和业务错误字段。
ok() {
curl -q --noproxy '*' --fail-with-body --silent --show-error --max-time 9 "$@"
}
bad() {
local code
code=$(curl -q --noproxy '*' --silent --show-error --max-time 9 \
-o response.json -w '%{http_code}' "$1") || return 1
test "$code" = "$2" || return 1
jq -e --arg error "$3" '.error==$error' response.json
}
state() { ok http://127.0.0.1:18181/lab/state; }
count() { ok http://127.0.0.1:18182/counts | jq -er .calls; }
state | jq -e '.state=="CLOSED" and .buffered==0'必须从空窗口开始。若已有样本,执行 docker compose restart caller,重新等待健康,再从这里开始。以下代码块连续执行,避免人工停顿越过四秒开放窗口:
before=$(count)
for i in 1 2 3 4; do
bad 'http://127.0.0.1:18181/call/circuit?mode=fail' 503 DEPENDENCY_FAILURE
done
state | jq -e '.state=="OPEN" and .failed==4 and .failureRate==100'
bad http://127.0.0.1:18181/call/circuit 503 CIRCUIT_OPEN
test "$(count)" -eq "$((before+4))"四次失败进入真实熔断器窗口,第五次收到 CallNotPermittedException,由应用映射为 503/CIRCUIT_OPEN。Worker 的调用数只增加四次。
等开放窗口过去,再观察两次探测:
sleep 4.2
ok http://127.0.0.1:18181/call/circuit
state | jq -e '.state=="HALF_OPEN"'
ok http://127.0.0.1:18181/call/circuit
state | jq -e '.state=="CLOSED"'
state | jq .transitions应依次出现 CLOSED_TO_OPEN、OPEN_TO_HALF_OPEN、HALF_OPEN_TO_CLOSED。状态完全由真实请求结果推进,没有调用强制切换 API。两次半开探测要连续执行;人为停顿超过半开最长五秒会改变结果。
占住一个真实调用,观察隔离拒绝
准备 Worker 的等待闸门,随后在后台发起一个等待请求。等待最长八秒,Caller HTTP 时限六秒,因此应连续执行整个代码块;异常时的清理函数会主动释放闸门。
HOLD_PID=
release_hold() {
ok -X POST http://127.0.0.1:18182/lab/release >/dev/null 2>&1 || true
if test -n "$HOLD_PID"; then wait "$HOLD_PID" 2>/dev/null || true; fi
}
trap release_hold EXIT
ok -X POST http://127.0.0.1:18182/lab/hold
ok 'http://127.0.0.1:18181/call/bulkhead?mode=hold' >hold.json &
HOLD_PID=$!
for i in $(seq 1 30); do
if ok http://127.0.0.1:18182/counts | jq -e '.active==1' >/dev/null; then break; fi
sleep 0.05
done
ok http://127.0.0.1:18182/counts | jq -e '.active==1'
before=$(count)
bad http://127.0.0.1:18181/call/bulkhead 503 BULKHEAD_FULL
test "$(count)" -eq "$before"
ok -X POST http://127.0.0.1:18182/lab/release
wait "$HOLD_PID"
HOLD_PID=
jq -e '.priceMinor==1990' hold.json
state | jq -e '.bulkheadAvailable==1'
trap - EXIT第二个请求在 Caller 内被拒绝,未进入 Worker。第一个请求收到释放信号后完成,Bulkhead 归还唯一许可。再调用 /call/bulkhead 应恢复成功。
这里保护的整个同步 HTTP 操作由 bulkhead.executeSupplier 包住。若只在方法开头检查一次“当前并发”,随后没有原子获取与归还过程,并发请求仍可能同时穿过检查。
两次周期许可与第三次拒绝
实验 RateLimiter 首次使用时创建,配置为每两秒两次许可,等待时间为零。以下三次请求需连续完成,以落在同一个周期:
before=$(count)
ok http://127.0.0.1:18181/call/rate
ok http://127.0.0.1:18181/call/rate
bad http://127.0.0.1:18181/call/rate 429 RATE_LIMITED
test "$(count)" -eq "$((before+2))"
sleep 2.1
ok http://127.0.0.1:18181/call/rate第三次没有调用 Worker。后一个周期许可刷新,调用恢复。若曾经使用这个端点,周期可能恰好跨过本次三条命令;要从新启动的 Caller 重新实验,或按实际周期记录结果,不能把周期跨界造成的三次成功直接判断为限流失效。
本实验为单进程、单资源的准入额度,不代表集群全局每秒一个请求,也没有模拟多租户分布式限流。
降级只使用实际取得且未过期的数据
/call/offer 的正常路径通过 HTTP 取得报价,解析 priceMinor 后保存为进程内快照;失败路径仅捕获 DependencyFailure,快照年龄最多允许五秒。
先让没有缓存的调用失败:
bad 'http://127.0.0.1:18181/call/offer?mode=fail' 503 DEPENDENCY_FAILURE
ok http://127.0.0.1:18181/call/offer | jq -e '.source=="LIVE" and .stale==false'
ok 'http://127.0.0.1:18181/call/offer?mode=fail' |
jq -e '.source=="CACHE" and .stale and .ageMs<=5000'
bad 'http://127.0.0.1:18181/call/offer?mode=forbidden' 403 FORBIDDEN
bad 'http://127.0.0.1:18181/call/offer?mode=invalid' 400 INVALID_ARGUMENT
sleep 5.1
bad 'http://127.0.0.1:18181/call/offer?mode=fail' 503 DEPENDENCY_FAILURE
ok http://127.0.0.1:18181/call/offer | jq -e '.source=="LIVE"'有有效快照时,返回值保留价格、source=CACHE、stale=true 和 ageMs。没有快照或过期时仍然失败,403 和参数错误也不会进入降级。
这类报价只适合允许短暂陈旧的展示;创建订单时仍需重新确认价格。缓存来源是一次真实远端成功响应,不能在 fallback 中直接写死“默认成功”或伪造库存余额。
整组复验要求新启动 Caller,以免窗口、许可和缓存受前面调用影响:
docker compose restart caller
# 重新执行前面的健康等待代码
bash verify.sh
docker compose downdown 删除本工程容器和网络,进程内样本、许可和缓存随进程消失。宿主工程、构建产物及 Maven 缓存保留。
组合保护并恢复业务调用
组合顺序会改变统计和资源占用
多个装饰器形成嵌套调用。把熔断器放在隔离器外面,隔离器的本地拒绝可能被熔断器观察到;没有正确排除这类异常时,本地线程不足会被误记成远端故障。把隔离器放在外面,OPEN 请求也可能先竞争一次隔离许可。
重试放在熔断器外面时,每次 attempt 都可能进入熔断统计;放在里面时,熔断器可能只看到重试结束后的总结果与总耗时。两种布局可以有各自用途,关键是指标中的“调用”究竟指哪个层级。Resilience4j 的 Feign 装饰器文档展示了装饰顺序如何改变实际执行。
使用注解时还要核对代理是否生效、同类内部调用是否经过代理、异常是否被内部 catch 提前转换,以及返回 Future/Publisher 后保护何时结束。只查看方法上的注解名,无法确定整个远程操作已受到保护。
替代结果也需要容量和语义约束
推荐服务不可用后读取本地缓存,成本通常较小;如果 fallback 又同步请求第二个远程推荐系统,就增加了另一条依赖链。为该链单独设置并发和时限,避免主服务出错时把备用系统一并压垮。
降级内容应按业务操作决定。允许陈旧的排行榜可以展示旧数据;权限校验失败必须拒绝;支付结果未知需要查询或人工处理,不能返回成功;订单创建失败也不能返回空订单编号让客户端误认为操作已完成。错误到 HTTP 的映射由接口契约决定,实验的 503/429 只是这组接口的明确约定。
观察恢复过程,而不只观察状态名称
| 表现 | 优先检查 |
|---|---|
| 依赖持续失败,但熔断器仍 CLOSED | 最小样本数、异常是否被记录、真实调用是否经过保护对象 |
| OPEN 后 Worker 仍有请求 | 之前已放行的在途调用、未受保护入口、其他实例的独立熔断器 |
| HALF_OPEN 长时间不结束 | 探测请求是否完成、网络超时及最大半开等待 |
| Bulkhead 经常满,CPU 却不高 | 阻塞 I/O、下游延迟、连接获取、许可是否覆盖过长的无关工作 |
| 重启或扩容后限流量上升 | 每进程独立额度、新窗口和新实例数量 |
| HTTP 成功率很好,用户却一直看到旧结果 | 单独统计 fallback 比例、缓存年龄与原始失败率 |
依赖恢复后,可以先减少失败流量来源,恢复少量探测,再逐步增加准入。熔断器回到 CLOSED 只代表当前样本满足阈值;真实容量还需要看在途请求、延迟、连接占用及下游资源。频繁人工重置会擦掉故障样本,也会让每个实例重新冲击尚未恢复的依赖。
规则变更应保留具体旧值和生效实例范围。将失败阈值从 50% 调到 100% 会显著延后拒绝,提高并发上限也会增加下游负担;它们不是没有业务代价的“消除报警”开关。
权威资料与规范地址
| 资料 | 完整地址 |
|---|---|
| Resilience4j Bulkhead | https://resilience4j.readme.io/docs/bulkhead |
| Resilience4j RateLimiter | https://resilience4j.readme.io/docs/ratelimiter |
| Spring Cloud CircuitBreaker 默认配置 | https://docs.spring.io/spring-cloud-circuitbreaker/reference/spring-cloud-circuitbreaker-resilience4j/default-configuration.html |
| Spring Cloud Bulkhead 配置 | https://docs.spring.io/spring-cloud-circuitbreaker/reference/spring-cloud-circuitbreaker-resilience4j/bulkhead-properties-configuration.html |
| CircuitBreaker 5.0.3 BOM | https://raw.githubusercontent.com/spring-cloud/spring-cloud-circuitbreaker/v5.0.3/spring-cloud-circuitbreaker-dependencies/pom.xml |
| Resilience4j TimeLimiter | https://resilience4j.readme.io/docs/timeout |
| Resilience4j CircuitBreaker | https://resilience4j.readme.io/docs/circuitbreaker |
| Spring Cloud 版本线 | https://spring.io/projects/spring-cloud/ |
| curl 手册 | https://curl.se/docs/manpage.html |
| Resilience4j Feign 装饰器 | https://resilience4j.readme.io/docs/feign |
