qinghuan
← Spring Cloud / 全部笔记

单元 112 / Spring Cloud

P0 · 必会主线L3 · 深入

远程调用治理:超时、重试与熔断如何配合

先设整个请求的等待上限,再判断重试会不会重复写入,并用熔断和资源配额限制故障影响。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能按一条调用链分配时间预算,并决定哪些错误和业务操作可以重试。

学习理由、准备要求与知识点

为什么学它

远程调用的多层重试会放大流量,缺少总预算和幂等设计时故障会迅速扩散。

先读这些内容

知识点清单

  • 连接超时
  • 读取超时
  • 总截止时间
  • 重试放大
  • 幂等键
  • 熔断状态
  • 隔离与舱壁
  • 回退语义

运行前先确认Spring Cloud OpenFeign 4.3.x 与 Resilience4j 2.3.0;框架默认值以对应版本配置为准。Java 17 示例计算计数滑窗失败率,不实现网络调用或完整熔断状态机。

01先弄懂它是什么

远程请求可能在等连接、解析地址、建连接、服务排队、执行业务和读响应时花时间。超时规定最多等多久,重试决定是否再来一次,熔断在近期失败较多时暂时拒绝部分调用,隔离限制一个依赖能占多少线程或连接。它们用途不同;只把超时调大,可能只是让更多请求占着资源等待。

超时只说明调用方没及时拿到结果,不证明服务端没做事。支付超时后可能已经扣款,若换新业务单号重试,就可能再扣一次。重试前应有固定业务标识、结果查询或明确的可重试规则,保证同一操作不会重复产生效果。写操作是否可重试,要按具体业务判断,不能因为网络失败就直接再做一遍。

熔断器像一个有记忆的门卫:健康时收集结果,达到条件后暂时关门,等待后只允许少量探测,再根据探测恢复或继续关闭。它不负责修好数据库,也不会自动减少所有并发。只有将总时间预算、请求并发、重试次数和服务容量一起设计,故障才不容易沿调用链放大。

文中这几个词,先认一认

总截止时间 Deadline
整个业务操作最多允许耗时到什么时候,包含排队、多次尝试及重试间的等待。
重试预算
限制故障时额外重试多少次或占多少请求,避免重试流量挤占正常处理能力。
熔断 Circuit Breaker
根据近期结果暂时拒绝部分请求,等待后再允许少量探测,决定是否恢复。
隔离 Bulkhead
给某个依赖限制并发、线程或连接配额,减少它变慢时牵连其他业务。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

先确定用户最多能等多久,再把这段时间分给连接、读取响应、重试前的等待和失败后的补救。连接超时与读取超时通常各管一段,不能直接当成“整个操作到点就结束”的保证。每次重试前都要检查还剩多少时间;即使次数还没用完,剩余时间不够也应停止,并明确告诉调用方结果是成功、失败,还是尚不确定。

熔断器需要观察一段历史。按次数的滑动窗口保留最近 N 次结果,按时间的窗口保留最近一段时间:前者在低流量时可能跨很久,后者则可能样本很少。设置最小调用数,可以避免只有一次失败就达到比例阈值;统计慢调用还能发现尚未报错、却已明显拖慢的依赖。阈值应结合真实流量和恢复成本调整。

三个状态可以直接记成:Closed 正常放行并记录结果,Open 暂时拒绝调用,HalfOpen 只放少量请求试探是否恢复。若多台应用各有本地熔断器,一台进入 Open,不代表其他机器也进入了同一状态。恢复试探必须限量,否则刚恢复的服务可能又被请求压垮。暂时无法调用时,可以按业务约定返回缓存或“暂不可用”等替代结果;不能把支付或扣库存失败改写成成功。

正在加载流程图…

状态决定是否准入,滑窗提供统计;示例算法只实现统计部分。
查看流程图源码(Mermaid)
stateDiagram-v2
 state "Closed · 收集调用结果" as Closed
 state "Open · 快速拒绝调用" as Open
 state "HalfOpen · 有限探测" as HalfOpen
 [*] --> Closed
 Closed --> Open: 样本达标且失败或慢调用超阈值
 Open --> HalfOpen: 等待结束
 HalfOpen --> Closed: 探测满足恢复条件
 HalfOpen --> Open: 探测失败超阈值

按顺序看,每一步做了什么

  1. 明确业务幂等和错误分类,禁止无条件重试所有异常。
  2. 从总截止时间分配连接、读取、退避与补救预算。
  3. 为依赖配置并发隔离、最小样本和熔断阈值。
  4. 打开期间快速失败;半开期间限定探测并发。
  5. 结合尝试次数、超时分布、拒绝量和业务结果验证恢复行为。

03对照实现代码

固定滑窗通过减去过期桶维护统计 (新标签页)↗

Resilience4j 2.3.0 · FixedSizeSlidingWindowMetrics#record / moveWindowByOne

原创精简 Java 模型,展示淘汰时扣减和预聚合;真实源码还统计持续时间与多种结果,并用 ReentrantLock 保护一致快照。

核心机制代码 · java
final class FailureWindowModel {
    private final boolean[] failed = new boolean[4];
    private int head, used, failures;
    synchronized int record(boolean failure) {
        if (used == failed.length && failed[head]) failures--; // 先扣除最旧失败。
        if (used < failed.length) used++; // 未填满前只扩大有效样本数。
        failed[head] = failure;
        if (failure) failures++; // 增量维护总计,避免反复扫描整个数组。
        head = (head + 1) % failed.length; // 环形复用存储槽位。
        return failures;
    }
}

measurements 保存每次测量,totalAggregation 保存已经算好的总计。查询统计时直接读取总计,就不用每次重新遍历整个窗口,窗口变长也不会让这一步按比例变慢。代价是每次增加或移除测量时,都必须同步维护总计,始终保证它等于当前窗口内各项之和。

record 在锁内更新总计、移动窗口、记录新测量并创建快照。窗口移动会移除旧桶贡献,再重置桶供新结果使用。不能只覆盖数组而忘记从总计减去旧值,否则失败数会随历史不断累加,所谓滑窗就退化成了不正确的终身统计。

这把锁让统计桶、下标、失败数和样本数一起保持正确。只把 failures 换成原子变量,虽然能保护这一个数,也不能保证其他字段与它属于同一份统计结果。锁里只做短暂的统计维护,实际网络调用在外面执行;如果把网络等待也放进这把锁,所有请求就会被迫排队。

OpenFeign 4.3 的默认 Retryer.NEVER_RETRY 与原生 Feign 的默认行为有区别。阅读框架源码时必须沿自动配置确认实际注入的策略,而不能凭另一层库的文档推断运行结果。重试和熔断装饰顺序也会改变一次业务请求被统计为一次还是多次尝试,应通过故障测试核对。

代码先调用谁,再调用谁

  1. 远程调用完成或被判定失败、慢调用
  2. 统计组件在临界区记录结果
  3. 滑窗移除旧桶贡献并写入新测量
  4. 读取预聚合快照计算阈值
  5. 状态机依据最小样本与阈值更新准入状态

04动手写一个例子

O(1) 更新的失败率滑窗

保留最近 capacity 次调用结果;达到 minSamples 后,失败百分比大于等于 threshold 才报警。空窗不能报警,阈值比较避免浮点误差,旧结果必须及时淘汰。

输入 1
容量4,最小4,阈值50;成功、失败、成功、失败
输出 1
前3次false,第4次true
输入 2
继续成功、成功
输出 2
true、false,第二次覆盖旧失败

用布尔环形数组表示每次是否失败,head 始终指向下一次要覆盖的槽位。数组未满时增加有效样本数,数组已满时先减去被覆盖结果的失败贡献。再写入新结果并更新总计,最后移动下标。这种先移除再加入的顺序使总计始终只包含当前窗口。

失败率比较不必用 double 除法。失败数乘以 100 与有效样本数乘以阈值比较,即可精确判断是否达到阈值。先将操作数提升到 long,避免 int 乘法先溢出再转型。阈值限定 1 到 100,最小样本在 1 到容量之间,非法配置应在构造时拒绝。

证明可以用归纳:空窗口总计为零;若旧不变量成立,删除被淘汰项后得到仍保留的历史结果,再加入新结果恰好得到新窗口。因此失败数与数组有效区间一致。该模型按方法同步提供单对象并发安全,但报警布尔值仍不是一个完整熔断器,不包含等待时钟、半开探测或请求许可。

为什么这样写是对的used 是有效窗口长度,failures 等于有效结果中的失败数,head 指向最旧槽位或下一个空槽位。

时间 每次 record 与 shouldOpen 均 O(1)额外空间 O(capacity)

每轮只覆盖一个槽位并进行常数次整数运算;构造数组需要 O(capacity) 初始化空间与时间。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 42 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
public class Main {
    static final class Window {
        final boolean[] values;
        final int minimum, threshold;
        int head, used, failures;
        Window(int capacity, int minimum, int threshold) {
            if (capacity <= 0 || minimum < 1 || minimum > capacity
                    || threshold < 1 || threshold > 100)
                throw new IllegalArgumentException("invalid window configuration");
            this.values = new boolean[capacity];
            this.minimum = minimum; this.threshold = threshold;
        }
        synchronized boolean record(boolean failed) {
            if (used == values.length && values[head]) failures--; // 淘汰旧失败。
            if (used < values.length) used++;
            values[head] = failed;
            if (failed) failures++; // 加入新结果,维持精确总计。
            head = (head + 1) % values.length;
            return shouldOpen();
        }
        synchronized boolean shouldOpen() {
            return used >= minimum && (long) failures * 100 >= (long) used * threshold;
            // 先提升为 long;最小样本检查同时排除空窗口。
        }
    }
    static void check(boolean ok) { if (!ok) throw new AssertionError(); }
    public static void main(String[] args) {
        Window w = new Window(4, 4, 50);
        check(!w.shouldOpen());
        check(!w.record(false) && !w.record(true) && !w.record(false));
        check(w.record(true));
        check(w.record(false));
        check(!w.record(false));
        Window one = new Window(1, 1, 100);
        check(one.record(true) && !one.record(false));
        try { new Window(2, 3, 50); throw new AssertionError(); }
        catch (IllegalArgumentException expected) { }
        try { new Window(0, 1, 50); throw new AssertionError(); }
        catch (IllegalArgumentException expected) { }
        System.out.println("PASS 8");
    }
}

这些特殊情况也要试一试

  • 窗口未满且样本不足不报警。
  • 恰好达到阈值按大于等于处理。
  • 容量为1时每次完全替换历史。
  • 无效容量、最小样本和阈值构造即拒绝。
进阶选读 · 多层重试的预算传播

场景入口、订单与支付三层都允许初次请求加2次重试,下游一次业务操作最坏收到27次尝试。

做法让各层共用同一个最终截止时间和幂等编号,也就是重复提交同一业务操作时仍沿用的编号,并只由负责该操作的一层决定重试。两次重试之间留出等待时间,再加一点随机变化,避免大家同时重试;同时限制额外请求总量和并发数。日志中用同一个业务编号串起全部尝试,再给每次尝试单独编号,才能看出一次操作到底发了多少请求。

复杂度深度 d、每层最多 a 次尝试,独立嵌套重试最坏 O(a^d);限制为单层 r 次可将下游尝试界定在 O(r)。

好处与代价少重试可能降低瞬时故障自愈率,但可减少故障时负载放大;最终取舍应由业务幂等能力与剩余时间决定。

05项目里怎么用

先做故障分类:连接尚未建立的失败、请求可能已送达的读取超时、明确业务拒绝和限流提示,不能统一处理。对可安全重试的请求,使用稳定业务键并遵守服务端提供的重试节奏;对不确定写结果,优先查询原请求状态或交给补偿流程。

上线前模拟慢响应、连接耗尽、全部实例失效与恢复抖动,观察真正的尝试数和端到端延迟。只看最终成功率会掩盖大量额外流量;只看平均延迟会隐藏尾延迟。还应检查线程池排队、连接池等待、熔断拒绝与半开探测的数量。

降级后仍要让用户知道业务处于什么状态。查询功能可以按事先约定返回标有数据时间的缓存;写入功能可以返回“处理中”或“暂不可用”,但不能让客户端误以为已经提交成功。日志中分别记录本地等待超时、远端业务失败,以及熔断器主动拒绝调用,才能分清是依赖出了问题,还是保护措施正在生效。

容易踩的坑

  • 只配置readTimeout便宣称覆盖整个调用生命周期。
  • 超时后更换业务键重试,制造重复写。
  • 每层默认重试叠加,故障期间流量指数放大。
  • 半开阶段放行全部请求,压垮恢复中的依赖。
  • 用熔断替代并发隔离,线程仍被慢请求占满。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
计数滑窗结果数量明确,更新简单低流量时可能保留很旧的样本流量较稳定的依赖健康判断
时间滑窗更贴近最近时间段低流量样本可能不足需要明确时间敏感度的健康检测
缓存降级提高读取可用性引入陈旧数据与失效管理业务允许短暂过期的查询
快速失败节省资源并暴露真实状态调用方需要处理失败不允许伪造成功的关键写操作

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01为什么超时不能证明服务端没有执行?

展开参考答案与推导

调用方只观测等待结果,服务端可能已经提交但响应丢失;必须通过幂等键与结果查询消除不确定性。

02熔断器为什么还需要最小样本数?

展开参考答案与推导

少量样本的失败率波动很大,最小样本可避免单个偶发结果主导状态,但会延迟低流量故障判断。

03原子失败计数为什么不足以保护滑窗?

展开参考答案与推导

样本数、槽位和失败总计共同定义状态;单字段原子性不能保证这些字段之间的不变量。

换个情况,还会处理吗

01成功率仍高但依赖流量增长9倍,如何排查?

展开参考答案与推导

检查各层重试次数与装饰顺序,按业务ID关联独立尝试;再看超时和慢调用是否触发多层嵌套重试。

02支付请求读超时,客户端应立刻重新付款吗?

展开参考答案与推导

保持原业务键查询原结果;若重试则必须遵守服务端幂等契约,不能更换订单号把不确定请求变成新请求。

03服务恢复后立即再次熔断,如何定位?

展开参考答案与推导

检查半开探测上限、恢复容量、积压请求集中释放和同步重试;通过限量探测与抖动逐步恢复流量。

写代码试一试

01实现失败率滑窗并测试淘汰。

输入 / 条件
4,4,50;成功失败成功失败成功成功
输出 / 预期
false,false,false,true,true,false

提示 head指向覆盖槽位,先减旧值。

展开参考答案与推导

按正文环形数组与预聚合方案,每次O(1)。

02扩展同时统计慢调用比例。

输入 / 条件
阈值100ms;耗时20,150,200
输出 / 预期
慢调用数2,总数3

提示 每槽位存两个布尔结果。

展开参考答案与推导

淘汰时分别扣除失败与慢调用计数,慢调用边界是否包含等于值需先定义。

03计算多层重试最大尝试数并防溢出。

输入 / 条件
每层尝试上限3,3,3
输出 / 预期
27

提示 Math.multiplyExact检查乘法。

展开参考答案与推导

逐层乘法累计,负数或零配置拒绝,溢出时返回明确错误或使用BigInteger。

商户项目 · 本篇加练

商户查询的超时与重试预算

核查日期:2026-10-11。状态:重试、超时和故障实验均未运行;没有测得延迟、成功率或性能结论。

目标与前置知识

能解释一次用户请求最多触发多少次下游调用、最多愿意等多久,以及哪些失败根本不应重试。先掌握 Java HTTP 客户端、HTTP 状态码、异常传播与只读商户查询。

步骤

  1. 先画调用路径,找出 HTTP 客户端、Resilience4j、网关和模型 SDK 各自是否会重试。多个层次独立重试会放大请求次数,测试时先固定一个明确的重试负责者。
  2. 单次超时只约束某一次等待;总预算还包括排队、每次尝试、重试间隔及结果处理。用单调时钟记 deadline;每次准备重试前重新计算剩余预算。剩余时间不够,就停止重试并返回明确失败。
  3. 设置 maxAttempts = 3 时,含首次调用在内最多 3 次,并不是首次之后再试 3 次。只读查询可先限定为明确的 503 或指定临时网络异常;参数错误、授权拒绝、业务不存在和编程错误不重试。不能把所有 RuntimeException 都当临时失败。
  4. 写出实际包装顺序。Retry(TimeLimiter(call)) 使每次尝试各自受超时约束,不自动等于整个重试过程有同样上限;外层总预算和底层连接/读超时也要接到真实调用路径。TimeLimiter 返回超时,不足以证明服务器已经停止执行或远端业务回滚。
  5. 自拟预算例:总预算 1000ms、单次上限 200ms、最多 3 次、两次间隔各 50ms,理想算术为 700ms,余下留给其他开销。这只是设计估算,不是实测上界;实际断言应同时检查总预算、调度容差和是否在截止后又发起新请求。
  6. 对非幂等写入,响应丢失可能发生在服务端已经提交之后。无可靠幂等契约或结果核对手段时,标记“结果未知、待核对”,不能盲目再次提交,也不能谎报失败已回滚。

验收与交付物

逐个验证正常一次、指定暂时失败后成功、持续暂时失败、不可重试业务错误、慢响应、预算用尽与写入响应丢失。记录每次 attempt 序号、开始/结束、异常分类、剩余预算、下游接收次数和最终结果。HTTP 重试次数应以实际客户端和服务端接收记录为准。

交付调用层次图、允许重试清单、预算配置、请求时间线及断言;实际数据留空。写入故障仅使用独立虚构测试服务,不能接真实订单或支付。

原始来源与版本边界

  • Retry:读 Create and configure Retry,核对 maxAttempts 包含首调、结果/异常 predicate 与排除项。
  • TimeLimiter:读 timeoutDuration、cancelRunningFuture 和执行方式;按所用异步类型和客户端验证取消传播。
  • 组合顺序:读 Setup 和 Aspect order。核查时标题明确针对 resilience4j-spring-boot2 / spring-boot3;抽取页面未显示可确认的 2.2.0 固定版本。资料标注 2.2.0 不等于当前项目已使用或验证该版本,更不能直接拼到 Boot 4。
  • RFC 9110:读 9.2.2,区分请求预期效果的幂等性与响应是否相同。不能仅凭“没收到响应”认定非幂等操作没发生。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能按一条调用链分配时间预算,并决定哪些错误和业务操作可以重试。

本篇重点加练

总截止时间和重试预算共同约束一次业务操作的成本,单层配置正确仍可能导致系统性放大。

  • 总截止时间
  • 重试放大

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    请求总预算 700 毫秒,先调用商户服务再调用库存服务,两次连接及读取均可能耗时。自拟三条时间线并核算剩余预算。

    提交内容
    预算表与调用时间线
    怎样算完成
    • 区分连接、读取和总截止时间
    • 包含第一段已耗尽预算

    练到的知识点连接超时 · 读取超时 · 总截止时间

  2. 作业 02面试场景

    浏览器、网关、业务客户端各自最多尝试两次,库存接口可能已执行但响应丢失。分析一次业务操作的调用数量及识别重复的依据。

    提交内容
    尝试树与请求身份约定
    怎样算完成
    • 次数含义清楚
    • 同键不同业务负载有明确处理

    练到的知识点重试放大 · 幂等键

  3. 作业 03代码推演

    入口传递绝对截止时刻,但两个服务时钟相差明显。分析各服务计算剩余预算所需的假设,并为时钟回拨记录设计异常判据。

    提交内容
    跨服务计时表
    怎样算完成
    • 区分墙钟与持续时间
    • 记录无法可靠换算的情况

    练到的知识点总截止时间

  4. 作业 04原理问答

    同一请求因客户端自动重试、用户重复点击和后台补偿再次执行。分别定义业务尝试与技术尝试,说明统计重试率时采用什么分母。

    提交内容
    尝试分类与指标口径
    怎样算完成
    • 三类重复独立记录
    • 不能仅按 HTTP 请求数判断业务量

    练到的知识点重试放大

综合应用

  1. 作业 05原理问答

    慢依赖导致 40 个请求同时占用工作线程。比较熔断状态与并发隔离限制分别能表达什么,并设计状态变化的观测字段。

    提交内容
    状态图与资源表
    怎样算完成
    • 包括恢复探测阶段
    • 区分拒绝、等待和已执行调用

    练到的知识点熔断状态 · 隔离与舱壁

  2. 作业 06设计评审

    订单确认接口失败时团队准备返回“暂时无库存”,商品推荐失败时准备返回空列表。分别审查回退响应对上游决策和数据含义的影响。

    提交内容
    回退契约与客户端行为表
    怎样算完成
    • 区分业务否定和系统未知
    • 保留错误分类与可追踪信息

    练到的知识点回退语义

  3. 作业 07编程实践

    用本地 HTTP 桩服务提供快、慢、断开响应三种模式,编写带截止时间的客户端,记录尝试数、并发占用和总耗时。

    提交内容
    代码、配置和逐请求报告
    怎样算完成
    • 请求数量与运行时长设上限
    • 实验不访问外部依赖

    练到的知识点连接超时 · 读取超时 · 总截止时间 · 重试放大 · 熔断状态 · 隔离与舱壁 · 回退语义

  4. 作业 08设计评审

    上游设置 800 毫秒预算,客户端排队 600 毫秒后才发出第一个调用。审查排队、连接、重试间隔和响应序列化如何计入总预算。

    提交内容
    端到端预算评审
    怎样算完成
    • 所有阶段有计时边界
    • 覆盖发起前已超预算

    练到的知识点总截止时间 · 重试放大

  5. 作业 09编程实践

    写本地预算检查器,读取跨层调用日志和请求关联 ID,计算每个业务操作的累计尝试及超预算阶段;加入缺失 span 与重叠调用。

    提交内容
    程序和样本报告
    怎样算完成
    • 未知记录不填成零
    • 累计资源成本与墙钟耗时分开

    练到的知识点总截止时间 · 重试放大

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    一次请求同时调用价格、库存、优惠三个服务,其中只有价格允许陈旧数据。制定故障组合下的服务目标与资源预算。

    提交内容
    依赖矩阵与故障验收表
    怎样算完成
    • 覆盖两项依赖同时退化
    • 每项回退可从响应或指标辨识

    练到的知识点总截止时间 · 重试放大 · 幂等键 · 熔断状态 · 隔离与舱壁 · 回退语义

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 条件变化

    新增“首个成功结果即可返回”的并行竞速请求后,研究低延迟目标与调用成本上限如何同时度量。设计本地小规模对照实验。

    提交内容
    假设、指标及实验计划
    怎样算完成
    • 计入输掉竞速的调用
    • 不默认返回后所有任务已停止

    练到的知识点总截止时间 · 重试放大

  2. 作业 12思维拓展 · 反例构造

    构造每层重试次数都未超配置、每层调用也未超局部超时,但整次请求仍违背服务目标的历史。

    提交内容
    反例时间线与缺失预算
    怎样算完成
    • 覆盖至少两层和排队阶段
    • 所用次数与配置含义一致

    练到的知识点总截止时间 · 重试放大

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗