熔断、隔离、限流与降级:故障怎样被限制在局部
一个价格查询接口可以同时受到四种控制:每秒允许多少次查询、同时允许多少次下游调用、依赖持续失败时是否继续访问,以及拿不到实时价格时返回什么。它们分别对应限流、隔离、熔断和降级。
价格查询
├─ 入口配额:超出速率或租户额度 → 拒绝新请求
├─ 并发许可:该依赖的执行槽用完 → 拒绝或有界等待
├─ 熔断许可:近期失败/慢调用达到阈值 → 暂停访问依赖
├─ HTTP 调用:连接、请求和响应都受超时约束
└─ 结果选择:实时结果 / 明确标记的旧值 / 明确失败保护的位置决定它能节省什么。入口拒绝可以省去解析之后的业务计算;并发隔离限制占用线程、连接的调用数;熔断让持续失败的依赖获得喘息时间;降级改变用户得到的功能或数据。即使已经返回旧价格,实时依赖的失败仍应被记录,不能因为外层返回了 HTTP 200 就抹掉失败统计。
从一次受保护的 HTTP 调用开始
选择保护对象与实现
| 机制 | 直接控制的对象 | 作出决定的依据 | 被拒绝或替换的结果 |
|---|---|---|---|
| 熔断 CircuitBreaker | 是否尝试一次依赖调用 | 窗口内已完成调用的失败率、慢调用率 | 快速报告依赖暂不可用 |
| 隔离 Bulkhead | 同时执行或等待的工作 | 并发许可、线程数、有界队列 | 容量已满 |
| 限流 RateLimiter | 一段时间内准入的需求 | 配额、令牌、窗口或漏出速率 | 请求过多或暂时过载 |
| 降级 Fallback | 对外提供的功能和结果 | 业务允许的替代方案及其有效条件 | 旧值、部分结果、异步受理或失败 |
Java 服务可用 Resilience4j 的核心模块包装 Supplier、Callable 或异步调用,不必先引入 Spring 注解。核心实验采用 Resilience4j 2.3.0、Java 17 编译目标、Maven 3.9.12,可以在 Java 17 和 25 运行;这是一组固定依赖组合,不表示所有后续版本都使用相同的 Java 最低要求。发行差异可查 Resilience4j 2.3.0 发布说明。
下载 HTTP 保护实验工程,解压得到 reliability-protection。目录中的 pom.xml 声明核心依赖,ProtectionLab.java 创建本机 HTTP 下游、调用客户端和各类保护对象,ProtectionTest.java 执行 11 种模式,其中 8 种对应下文的保护机制,另外 3 种用于依赖治理中的共享资源与重试放大实验。实验不访问外部业务服务,也不需要数据库。
Linux 中需要 Docker、Bash、unzip。宿主操作者拥有 Docker 访问权限,这相当于能够管理本机容器;下面的一次性 Maven 容器则显式使用宿主普通 UID/GID,和 Docker daemon 的身份分开。先进入解压目录,创建自己可写的缓存:
cd reliability-protection
mkdir -p .m2
docker version
docker run --rm --user "$(id -u):$(id -g)" \
--entrypoint mvn -e HOME=/tmp -e MAVEN_CONFIG=/tmp/.m2 \
-v "$PWD:/work" -v "$PWD/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-17 \
-Dmaven.repo.local=/m2 -Duser.home=/tmp clean verifyclean verify 编译项目并执行 11 项检查,正常结果为 Tests run: 11, Failures: 0, Errors: 0, Skipped: 0,运行依赖复制到 target/dependency。权限错误先确认当前目录和 .m2 可由该 UID 写入;依赖下载失败则检查 Maven 仓库和 Docker 的出站路径。企业内网可以挂载经过批准的 Maven settings.xml 指向私服,离线环境先在同版本联网构建机准备镜像和依赖缓存,再使用 docker save/load 与 Maven -o;缺依赖时离线构建会直接失败。
每个机制都有独立执行入口。以下命令在解压目录运行,源码和依赖只读挂载,容器以普通用户执行,并禁止外网;实验 HTTP 服务和客户端仍可在容器内通过回环通信:
docker run --rm --network none --read-only --tmpfs /tmp \
--user "$(id -u):$(id -g)" -v "$PWD:/work:ro" -w /work \
eclipse-temurin:17.0.20_8-jdk \
java -cp 'target/classes:target/dependency/*' \
dev.example.ProtectionLab circuit把末尾模式换成 slow、bulkhead、async、rate、fallback、classification 或 order,可分别观察慢调用、隔离、异步许可、限流、旧值替代、错误分类和装饰顺序。使用 Java 25 时将运行镜像换为 eclipse-temurin:25.0.4_7-jdk。这些程序会停止自己的 HTTP 服务并退出;如果抛出断言,保留完整输出,按对应机制检查实际调用数和状态。
HTTP 状态怎样进入熔断统计
Java HTTP 客户端正常收到 503 时仍然得到 HttpResponse。熔断器包装的函数如果把它当普通结果返回,默认异常统计就看不到失败。需要在适配层将协议结果翻译成稳定类型,或者配置结果谓词:
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 403) throw new BusinessRejected();
if (response.statusCode() != 200) {
throw new DependencyFailure("HTTP_" + response.statusCode());
}
return response.body();这是实验接口的映射约定:200 为价格,403 为权限拒绝,其他状态按依赖失败处理。实际项目需要按接口契约展开,不能把所有 4xx 都作为同一种业务拒绝,也不能遇到任何 RuntimeException 都重试。输入错误、鉴权失败、调用方主动取消和服务不可用各有不同处理方式。
对熔断器而言,recordExceptions 指定计为失败的异常;其他未忽略异常会按成功记录。ignoreExceptions 才表示既不计失败也不计成功。实验把 BusinessRejected 和主动中断对应的 InterruptedCall 排除,避免将权限拒绝、调用方退出解释为价格服务损坏。配置含义见 CircuitBreaker 文档。
在 Spring 应用中,保护对象应由长生命周期 Bean 或 Registry 持有。若每次请求新建一个熔断器,窗口永远从零开始;按用户ID无限创建实例又会产生高基数对象和指标。通常以“依赖 + 调用类别”命名,只有确实需要独立配额或故障隔离时才进一步按有限租户组拆分。
熔断器从已完成调用判断下一次访问
窗口、样本和状态转换
计数窗口保存最近 N 次完成结果;时间窗口按时间桶汇总。两者都需要维护总调用数、失败数、慢调用数和耗时。窗口淘汰旧数据时扣掉旧桶贡献,获取汇总结果不需要重新扫描全部历史。Resilience4j 的具体状态对象与结果回调可在 2.3.0 CircuitBreakerStateMachine 中对应。
假设最近四次都是失败:前面三次尚未达到 minimumNumberOfCalls=4,失败率即使直觉上是100%,也不会触发阈值判断。第四次完成后,累计样本达到下限,失败率超过50%,状态进入 OPEN。阈值判断包含相等情况。
CircuitBreaker cb = CircuitBreaker.of("price",
CircuitBreakerConfig.custom()
.slidingWindowSize(4)
.minimumNumberOfCalls(4)
.failureRateThreshold(50)
.slowCallDurationThreshold(Duration.ofMillis(150))
.slowCallRateThreshold(100)
.waitDurationInOpenState(Duration.ofMillis(400))
.permittedNumberOfCallsInHalfOpenState(2)
.maxWaitDurationInHalfOpenState(Duration.ofSeconds(3))
.recordExceptions(DependencyFailure.class)
.ignoreExceptions(BusinessRejected.class, InterruptedCall.class)
.build());
String price = cb.executeSupplier(() -> downstream.call("ok"));四个样本和这些毫秒阈值只是便于观察的实验值。线上要先区分请求量和错误持续时间:低流量接口使用很大的最小样本会长时间不触发;样本太少则容易被偶发失败打开。时间窗口更容易表达“近期状况”,计数窗口在流量低时可能包含很久以前的结果。慢调用阈值通常要留在调用超时之前,否则只有超时异常进入失败统计,慢率已来不及起到提前保护作用。
默认没有后台线程自动把 OPEN 改成 HALF_OPEN。等待期结束后,下一次调用申请许可才推动转换;启用自动转换选项才由调度任务触发。半开允许两个探针,发出一个成功请求后仍应处于 HALF_OPEN,第二个完成才能形成这一轮结果。半开等待上限用于处理探针迟迟不结束,调用自身仍需要独立超时。
正常失败与恢复的输出
执行 circuit 得到:
after3 state=CLOSED buffered=3 failed=3
open state=OPEN httpCalls=4 rejected=1
probe1 state=HALF_OPEN
recovered state=CLOSED httpCalls=7httpCalls=4 来自下游 HTTP handler 的累计增量。四次失败请求确实到达下游,随后 OPEN 拒绝的请求未到达。恢复后的七次由“原四次 + 两个探针 + 一次普通查询”组成。单看状态 OPEN 只能知道保护对象作出了决定,调用数才显示被保护的函数是否还在执行。
执行 slow,四次下游均返回200,但每次等待250ms,超过150ms慢阈值:
state=OPEN failed=0 slow=4 slowRate=100慢调用统计在调用结束时更新。一个持续卡住、尚未返回的请求还没有成为完整样本,因此熔断不能代替执行超时或并发隔离。即使窗口只记录四次结果,CLOSED 状态也可以同时放入数十个请求;窗口大小并不是四个执行槽。
classification 则连续发出四个真实403:
business403=4 buffered=0 state=CLOSED四个403都交给调用方,熔断窗口没有增加样本。用户请求统计仍要记录这些权限失败;它与熔断采样采用不同分母,用来回答用户是否完成了目标操作。
打开之后已经在途的调用
OPEN 影响后续许可申请,不撤回已发出的请求。第十个并发请求导致熔断打开时,前九个请求可能还在网络或数据库中执行。它们要靠请求超时、底层取消和业务幂等处理;强行把 Future 标成完成,也不能让远端副作用自动消失。取消与剩余预算的具体做法见超时与端到端预算。
管理操作还可以进入 FORCED_OPEN、DISABLED 或 METRICS_ONLY。强开适合临时停止对已知坏依赖的访问;禁用会放开全部调用;仅统计保留观察而不触发打开。管理开关需要权限、变更记录和恢复安排,不能用定时 reset() 每隔几秒擦除故障窗口。
用执行槽和速率配额控制资源占用
信号量隔离与线程池隔离
信号量隔离在调用前申请许可,调用完成或抛出异常后归还。业务仍在原来的线程执行,新增开销较小,适合同步调用和已经拥有合适执行器的服务。许可数应对应所保护资源,例如给报告查询限制两个槽,避免它占完数据库连接或请求线程。
Bulkhead reports = Bulkhead.of("reports",
BulkheadConfig.custom()
.maxConcurrentCalls(2)
.maxWaitDuration(Duration.ZERO)
.build());
String result = reports.executeSupplier(() -> downstream.call("hold"));maxWaitDuration=0 表示没有许可就立即抛出 BulkheadFullException。设置正等待时间时,等待者仍占用原调用线程,而且这段排队消耗端到端预算。队列并没有增加下游容量,只是把拒绝延后;如果调用通常耗时已接近总预算,排队后的工作即使获得许可也可能来不及完成。
线程池隔离另外提供一组执行线程和有界队列,把阻塞调用从共享线程中分离出去,代价是线程、排队、上下文传递和关闭管理。线程池满时必须有明确拒绝方式,不能在拒绝策略中无条件回到调用者线程执行,重新占满本想保护的入口线程。两类实现及参数见 Resilience4j Bulkhead。
异步调用要把许可生命周期延续到真正的异步结果结束。下面这种写法只保护“提交任务”的几微秒,返回 Future 就会释放同步包装器的许可:
// 同步 Supplier 很快返回 Future,远端工作尚未结束。
reports.executeSupplier(() -> client.sendAsync(request, bodyHandler));此时使用与 CompletionStage 对应的装饰API,把许可释放放到异步完成时;具体回调见 Bulkhead 2.3.0。
CompletionStage<HttpResponse<String>> response =
Bulkhead.decorateCompletionStage(reports,
() -> client.sendAsync(request, bodyHandler)).get();async 用同一个被暂停的HTTP下游比较两种包装:同步包装已经显示一个空闲许可,但服务端还在执行;异步包装在工作期间保持0个空闲许可,另一个请求以 BulkheadFullException 为原因失败,且没有到达服务端。完成后许可才恢复为1。
syncWrapper serverActive=1 available=1
asyncWrapper serverActive=1 available=0 rejected=BulkheadFullException
asyncCompleted available=1取消是否终止了底层I/O,还要由HTTP客户端和工作自身处理;外层结果结束时,原远端调用仍可能继续消耗资源。
执行 bulkhead:
held=2 reportAvailable=0 reportRejected=1 payment=42
recovered reportAvailable=2 next=42两次报告HTTP请求先进入下游并被暂停,程序确认它们已经占住两个许可,才发送第三个报告请求和支付请求。第三个报告请求本地拒绝,下游调用计数不变;支付使用独立保护对象,仍得到42。释放暂停后,两次报告完成,许可恢复为2,随后一条新报告也能成功。
这种隔离只覆盖显式分开的资源。如果报告和支付最后共用同一个小数据库连接池、同一个CPU配额或同一个磁盘,仍可能互相拖累。隔离粒度要沿真正争用的对象选择;为每个用户建立独立线程池会把线程数本身变成新的压力来源。
速率、突发与并发分别计算
限流描述单位时间准入多少次。并发描述此刻有多少次尚未结束。当平均服务时间从50ms升到500ms,在相同到达速率下,在途数量可能增加一个数量级。因此“100次/秒”不能直接当成“最多100并发”。
常用算法有不同的时间形态:
| 算法 | 状态和准入方式 | 主要取舍 |
|---|---|---|
| 固定窗口 | 每个时间窗口计数,进入新窗口重置 | 简单;窗口交界处可能连续接受两批额度 |
| 滑动窗口 | 保存细分桶或近期请求记录,按滚动区间统计 | 更平滑;需要更多状态或估算 |
| 令牌桶 | 按速率补令牌,达到桶容量后停止积累 | 平均速率可控,允许受桶容量限制的短突发 |
| 漏桶/匀速排队 | 按固定或受控节奏放行排队工作 | 出口平滑;队列需要容量和等待上限 |
Resilience4j 默认 AtomicRateLimiter 把时间分为周期,按 limitForPeriod 更新许可;它不是 Redis 分布式令牌桶。内部状态包括当前周期、剩余许可和需要等待的纳秒数,等待预订可能使许可数为负。实现细节见 RateLimiter 文档和2.3.0 AtomicRateLimiter。
RateLimiter limiter = RateLimiter.of("read",
RateLimiterConfig.custom()
.limitForPeriod(2)
.limitRefreshPeriod(Duration.ofSeconds(2))
.timeoutDuration(Duration.ZERO)
.build());
String value = limiter.executeSupplier(() -> downstream.call("ok"));执行 rate,同一周期先发两次请求,再发第三次;下一个周期再查询:
periodAccepted=2 rejected=1 httpCalls=2
nextPeriod=42 httpCalls=3周期刷新恢复的是配额,依赖能否完成请求仍由超时、熔断等机制判断。每实例各限100次/秒时,10个实例合计约为1000次/秒;流量偏斜还会使部分实例拒绝请求,另一些实例保留闲置额度。
跨实例的租户总额可由统一入口或共享配额服务管理。共享服务不可用时,需要在本地保守额度、拒绝和放行之间作出选择,依据是被保护资源能够承受的风险。
公开接口因调用方超额可以返回429;HTTP规范允许附带 Retry-After,供调用方延后重试。系统依赖过载或暂不可服务时也可能使用503。状态码和等待建议要与业务契约一致,不能把所有 RequestNotPermitted 都转换成200。参见 RFC 6585 的429与 RFC 9110 的Retry-After。
配额放在哪一层
计费或租户套餐额度通常按一个逻辑请求扣减;保护下游容量的速率额度应考虑每次实际尝试。如果一次请求允许重试三次,只在最外层扣一次下游额度,实际产生的三次调用便会绕过这层容量计算。
把拒绝放在昂贵工作之前,能尽早释放资源。单纯扩大队列、提高超时,在已过载时往往会积累更多无法及时完成的工作。负载削减还可以按请求成本、优先级和已耗预算选择保留哪些请求,相关方法见 Google SRE:Handling Overload。
组合保护后,响应仍要表达真实业务结果
装饰顺序改变统计单位
下面两种组合都可能发出三次HTTP请求,但熔断器看到的调用数不同:
// 每次重试都经过熔断器:统计一次实际尝试。
retry.executeSupplier(() -> cb.executeSupplier(remoteCall));
// 整轮重试结束才交给熔断器:统计一次逻辑调用。
cb.executeSupplier(() -> retry.executeSupplier(remoteCall));执行 order,两种组合分别访问持续503的HTTP下游:
retryOutside failedSamples=3 circuitOutside failedSamples=1 httpCalls=6外层重试、内层熔断会较快看到依赖的实际失败尝试,也可能在重试尚未用完时拒绝下一次;外层熔断则看到重试后的最终结果,耗时包含退避与所有尝试。选择取决于阈值要保护的对象,不能把两种采样方式使用同一组数字后直接比较。
实验重试仅允许 DependencyFailure,maxAttempts=3 包含首次调用。CallNotPermittedException、BulkheadFullException 和本地配额拒绝不应因一个宽泛 RuntimeException 重试规则而形成紧密循环。生产重试还需总时间预算和退避,具体参数见 Resilience4j Retry及重试、幂等与补偿。
对一个可重试的只读依赖,常见组合可以写为:
入口业务额度
→ 逻辑调用总预算
→ 重试(仅指定瞬时失败)
→ 每次尝试的下游配额
→ 并发许可
→ 熔断许可
→ 带超时的 HTTP 调用
→ 业务允许时才选择 fallback这个排列让本地配额和隔离拒绝留在熔断采样外面,避免将“自己没槽位”误记为依赖失败。另一种排列可能让熔断器在最外层先快速拒绝,减少其他许可开销,但必须忽略内部的本地拒绝异常。队列等待是否应计入慢调用也随顺序变化,需要按选定的统计口径配置。
通过注解接入时,不能凭Java源码上注解的上下位置推断执行顺序。应读取实际AOP配置,并用服务端调用计数、异常类别和事件验证最终包装关系。核心API能直接呈现嵌套关系,适合先确定策略,再迁移到项目约定。
旧值可以返回,但要有来源和期限
降级属于业务逻辑。价格展示可在短时故障时显示缓存价格;结算金额、库存扣减或支付结果通常需要重新确认。如果写请求实际上未完成,返回一个自造订单号或“支付成功”会制造更严重的数据错误。
一次可用的读降级至少要确定这些字段:
{
"value": "42",
"source": "CACHE",
"ageMs": 87
}ageMs 是本次读取时距缓存写入的间隔,具体值每次不同。生产接口还可给出缺失字段、缓存版本和可用场景。已经过期、没有历史值、请求无权限,均不能沿同一条“返回缓存”分支处理。
实验 fallback 先请求失败、确认没有缓存可用,再成功读取42建立缓存。随后真实503触发旧值替代,403保持权限拒绝,缓存超过350ms后再次503则保持失败:
first=LIVE failure=CACHE forbidden=BusinessRejected
expired=DependencyFailure缓存本身也要保护。所有实例一起刷新、fallback改查另一台更小的数据库、访问缓存时又同步回源,都可能把压力转移到新的共享依赖。降级应优先减少计算和依赖数量;需要增加替代依赖时,也要为它设置自己的配额和超时。
生产问题从哪一项观察开始
| 现象 | 先看什么 | 常见原因 | 调整后的复测 |
|---|---|---|---|
| 下游大量503但熔断一直CLOSED | buffered、failed和实际HTTP状态 | HTTP结果没翻译为失败,或每次新建保护对象 | 四次固定失败应进入同一窗口,OPEN后计数不再增长 |
| OPEN后下游仍有请求 | 在途数量、绕过保护的调用点 | 旧调用尚未结束,或另一路未使用该对象 | 停止新准入,等待旧调用结束后再核对 |
| 长期停在HALF_OPEN | 探针完成数、请求超时、半开最长等待 | 探针卡住或流量不足 | 给探针有界执行,完成少量真实查询后恢复 |
| 请求迅速拒绝、线程和连接仍满 | 后台在途、连接借出数、队列年龄 | 取消只结束外层结果,底层工作持续 | 实际工作退出后水位下降,新请求恢复 |
| 限流后整体依赖仍过载 | 实例数、每次重试的实际调用数 | 本地额度相加,重试绕过配额,流量偏斜 | 以总成功吞吐和实际依赖到达量检验额度 |
| fallback让错误率变绿但用户投诉 | CACHE占比、age、缺失能力 | 将降级当实时成功统计 | 实时成功与降级结果分列,核对允许陈旧度 |
运行中可从应用指标获取各保护对象的状态、失败/慢调用数、未许可数、可用并发槽、等待线程和成功/降级结果。指标标签使用有限的依赖名、策略名和结果类别;operationId适合日志和Trace,不适合作为时间序列标签。发生过载时同时观察入口到达量和完成量:如果到达量已经被入口拒绝得接近零,错误率下降不表示容量恢复。
Linux上先检查实际进程资源,再按依赖调用定位:
docker stats --no-stream
docker logs --tail 120 app
ss -sapp 替换为待查应用容器名。容器CPU持续接近配额、连接数上涨、隔离许可长期为0,需要分别追踪CPU计算、连接等待或在途调用;不要直接同时调大线程池、连接池和超时。对应用端点进行对照请求时控制curl客户端状态:
TARGET='http://127.0.0.1:8080/price'
curl -q --noproxy '*' --fail-with-body \
--connect-timeout 1 --max-time 3 "$TARGET"这里的地址是需要替换的应用接口,并非ZIP实验的对外端点;实验服务使用容器内随机回环端口。curl因网络错误失败与服务器完整返回429/503是两类现象。需要保留预期拒绝响应时,不使用 --fail-with-body 提前把它混成普通传输失败,而是分别检查:
status=$(curl -q --noproxy '*' --connect-timeout 1 --max-time 3 \
-D response.headers -o response.json -w '%{http_code}' "$TARGET")
transport=$?
printf 'transport=%s status=%s\n' "$transport" "$status"
if [ "$transport" -eq 0 ] && [ "$status" = 429 ]; then
cat response.headers response.json
else
printf '未得到完整的预期429响应\n' >&2
false
fi这组命令仅用于预期429的限流测试。还要按接口契约确认响应体中的限流原因与 Retry-After;其他状态应停止按“限流成功”解释。curl选项含义可查 curl官方手册。
恢复时先消除下游故障,保留有限探针和并发限制,观察普通请求能否在预算内成功。逐渐恢复流量之后,还要确认队列年龄、在途数量和缓存旧值占比回到正常区间。直接关闭所有保护,可能在依赖刚恢复时重新压满它。
权威资料与规范地址
Resilience4j 实现与配置
- 2.3.0发布说明:https://github.com/resilience4j/resilience4j/releases/tag/v2.3.0
- 熔断配置:https://resilience4j.readme.io/docs/circuitbreaker
- 熔断状态实现:https://github.com/resilience4j/resilience4j/blob/v2.3.0/resilience4j-circuitbreaker/src/main/java/io/github/resilience4j/circuitbreaker/internal/CircuitBreakerStateMachine.java
- 并发隔离:https://resilience4j.readme.io/docs/bulkhead
- 异步许可实现:https://github.com/resilience4j/resilience4j/blob/v2.3.0/resilience4j-bulkhead/src/main/java/io/github/resilience4j/bulkhead/Bulkhead.java
- 周期限流:https://resilience4j.readme.io/docs/ratelimiter
- 原子限流器实现:https://github.com/resilience4j/resilience4j/blob/v2.3.0/resilience4j-ratelimiter/src/main/java/io/github/resilience4j/ratelimiter/internal/AtomicRateLimiter.java
- 重试配置:https://resilience4j.readme.io/docs/retry
HTTP 与过载处理
- HTTP 429:https://www.rfc-editor.org/rfc/rfc6585.html#section-4
- Retry-After:https://www.rfc-editor.org/rfc/rfc9110.html#name-retry-after
- 过载处理:https://sre.google/sre-book/handling-overload/
- curl手册:https://curl.se/docs/manpage.html
