Java 基础体系 · 第 99/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 服务韧性:超时、重试、限流、熔断、隔离和降级

服务韧性(resilience)是指系统在依赖变慢、失败、过载、网络抖动或局部故障时,仍能控制影响范围,并以可预期的方式恢复或返回有限结果。

韧性不是“让请求永不失败”。一个有韧性的系统通常会主动拒绝一部分请求、减少重试、切断故障依赖,并明确告诉调用方“暂时无法完成”。如果系统为了追求成功率而无限等待、无限重试,最终可能把一个依赖的局部故障扩散成全链路故障。

本文讨论六类核心机制:

  • 超时:限制一次等待最多持续多久;
  • 重试:在特定失败条件下再次执行操作;
  • 限流:限制进入系统或某个依赖的请求速率;
  • 熔断:根据近期失败情况,暂时停止访问异常依赖;
  • 隔离:限制不同任务之间共享的线程、连接、并发额度和资源;
  • 降级:无法提供完整结果时,返回缓存、默认值、部分结果或明确失败。

这些机制不是互相独立的开关,而是共同组成一条故障控制路径:

flowchart LR
    A[客户端请求] --> B[入口限流]
    B -->|允许| C[业务线程/任务隔离]
    C --> D[熔断器]
    D -->|关闭或半开允许| E[带截止时间的调用]
    E --> F{结果}
    F -->|成功| G[正常响应]
    F -->|超时/可重试错误| H[有限重试]
    H --> E
    F -->|不可恢复失败| I[降级]
    D -->|打开| I
    B -->|拒绝| J[快速失败或排队]

关键原则是:先限制资源,再控制等待;只对可安全重试的失败重试;在故障扩散前熔断;最终使用降级或快速失败结束请求。


一、先建立故障模型:失败不只有一种

在设计策略前,必须区分失败类型。不同失败的处理方式相反。

1. 业务失败

例如:

  • 商品不存在;
  • 余额不足;
  • 用户无权限;
  • 请求参数非法。

这类失败通常不应重试,因为再次发送相同请求不会改变结果。它们也不应被简单归类为“依赖异常”,否则会污染熔断统计。

2. 瞬时传输失败

例如:

  • TCP 连接建立失败;
  • DNS 暂时失败;
  • 连接被对端重置;
  • 依赖返回 HTTP 503;
  • 请求在网络路径中丢失。

这类失败有时适合重试,但必须同时满足:

  1. 操作具有幂等性,或请求带有幂等键;
  2. 失败发生在可以安全判断的阶段;
  3. 重试次数和总耗时有上限;
  4. 重试不会超过下游剩余容量。

3. 持续性失败

例如:

  • 依赖实例已经宕机;
  • 认证配置错误;
  • 数据库连接池配置错误;
  • 请求格式始终非法;
  • 依赖持续返回 500。

持续性失败不适合连续重试。重试只会制造更多负载,因此应尽快熔断或失败。

4. 过载

过载通常表现为:

  • 请求排队时间增加;
  • 线程池队列增长;
  • 连接池耗尽;
  • GC 时间增加;
  • 下游开始大量超时。

过载时,单纯增加线程或重试次数通常会使问题更严重。限流、隔离和快速失败才是直接控制手段。


二、超时:把“等待”变成有上限的资源

2.1 超时的定义

超时是一个操作允许等待的最大时间。它不是“失败后等待多久”,而是从某个明确起点开始计算的时间预算。

一次 HTTP 调用至少可能包含:

  1. DNS 解析;
  2. 建立 TCP 连接;
  3. TLS 握手;
  4. 从连接池获取连接;
  5. 写入请求;
  6. 等待响应头;
  7. 读取响应体。

如果只配置了“读取超时”,连接池等待或 TLS 握手仍可能无限拖延。生产系统需要明确每个阶段的超时,或者使用一个贯穿全链路的总截止时间(deadline)。

2.2 超时、截止时间和剩余预算

设入口请求在时间 t0t_0 开始,允许的总预算为 DD,当前时间为 tt,则剩余预算为:

R(t)=D(tt0)R(t) = D - (t - t_0)

任何下游调用的超时都不应超过 R(t)R(t)。如果下游调用固定使用 2 秒,而上游只剩 300 毫秒,那么这个配置实际上允许下游调用破坏上游的 SLA。

更准确的做法是向下游传递截止时间:

X-Request-Deadline: 2025-01-01T12:00:00.250Z

下游收到后计算自己的剩余时间:

Rdownstream=deadlinenowR_{\text{downstream}} = \text{deadline} - \text{now}

实际请求超时还应扣除本地处理和响应编码的安全余量:

Tcall=max(0,Rdownstreamϵ)T_{\text{call}} = \max(0, R_{\text{downstream}} - \epsilon)

其中 ϵ\epsilon 是预留给本地逻辑的时间。这样可以避免下游刚返回结果,上游已经超时的情况。

2.3 Java 25 中使用 HttpClient 设置请求超时

JDK 的 HttpClient 可以设置连接超时,HttpRequest 可以设置单次请求超时:

import java.net.URI;
import java.net.http.*;
import java.time.Duration;

public class TimeoutExample {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofMillis(300))
                .build();

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("https://example.com/api"))
                .timeout(Duration.ofMillis(800))
                .GET()
                .build();

        try {
            HttpResponse<String> response =
                    client.send(request, HttpResponse.BodyHandlers.ofString());

            System.out.println("status = " + response.statusCode());
        } catch (HttpTimeoutException e) {
            System.out.println("request timed out");
        } catch (java.io.IOException e) {
            System.out.println("transport failure: " + e.getMessage());
        }
    }
}

这里有两个不同含义:

  • connectTimeout 限制建立连接阶段;
  • request.timeout 限制该请求的整体完成时间。

HttpClient 的具体连接复用和取消行为由实现负责;应用不能把“抛出超时异常”简单理解为远端一定停止了处理。请求可能已经到达远端,甚至已经执行成功,只是响应没有及时返回。因此,超时后的重试必须考虑重复执行问题。

2.4 超时的常见错误

只配置服务端超时

服务端超时不能替代客户端超时。服务端可能已经停止等待,但客户端连接、代理、连接池或 DNS 仍可能继续等待。

只设置一个很大的超时

“大超时”不是稳定性策略。假设每个请求占用一个线程,1000 个请求各等待 60 秒,就可能同时占用大量线程和连接。故障依赖越慢,调用方自身越容易耗尽资源。

超时不释放资源

使用连接池、信号量或线程池时,超时路径必须释放许可和连接。否则每次超时都会泄漏一个并发槽,最终表现为“后续请求全部被拒绝”。


三、重试:重新执行操作,但不重新制造事故

3.1 重试的必要条件

重试不是“发生异常就再调用一次”。一个调用适合重试,需要同时回答三个问题:

  1. 失败是否可能是瞬时的?
  2. 重复执行是否安全?
  3. 总预算是否允许再次等待?

例如:

  • GET /inventory/sku-1 通常具有幂等语义,可以有限重试;
  • POST /payments 可能已经扣款成功但响应丢失,不能盲目重试;
  • Idempotency-Key 的支付请求,可以由服务端按键去重后重试。

幂等键的基本语义是:服务端保存某个键对应的最终结果;同一个键再次到达时返回原结果,而不是重新执行扣款。

3.2 指数退避和抖动

若所有实例在同一时间重试,会形成同步重试风暴。常用等待时间为:

dn=min(dmax,d0×2n)+Jd_n = \min(d_{\max}, d_0 \times 2^n) + J

其中:

  • d0d_0:初始等待时间;
  • nn:已经完成的重试次数,从 0 开始;
  • dmaxd_{\max}:单次等待上限;
  • JJ:随机抖动。

例如 d0=100msd_0=100\text{ms},上限为 2 秒,随机抖动在 [0, 100ms)

  • 第一次重试前:100~200 ms;
  • 第二次:200~300 ms;
  • 第三次:400~500 ms;
  • 第四次:800~900 ms。

实际总耗时还必须受到请求截止时间约束。即使计算出的退避时间是 800 ms,只要剩余预算只有 300 ms,也不应再等待后发起调用。

3.3 一个带总截止时间的 Java 重试示例

下面的示例只对连接异常和 HTTP 503 重试,并且使用 Instant deadline 限制总时间:

import java.io.IOException;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.ThreadLocalRandom;

public class RetryExample {
    private static final HttpClient CLIENT = HttpClient.newBuilder()
            .connectTimeout(Duration.ofMillis(200))
            .build();

    public static HttpResponse<String> getWithRetry(
            URI uri, Instant deadline, int maxRetries) throws Exception {

        int attempt = 0;

        while (true) {
            long remainingMillis =
                    Duration.between(Instant.now(), deadline).toMillis();

            if (remainingMillis <= 0) {
                throw new java.util.concurrent.TimeoutException(
                        "deadline exceeded before attempt");
            }

            long requestTimeout = Math.min(remainingMillis, 500);

            HttpRequest request = HttpRequest.newBuilder()
                    .uri(uri)
                    .timeout(Duration.ofMillis(requestTimeout))
                    .header("X-Request-Deadline", deadline.toString())
                    .GET()
                    .build();

            try {
                HttpResponse<String> response =
                        CLIENT.send(request, HttpResponse.BodyHandlers.ofString());

                int status = response.statusCode();

                if (status != 503 || attempt >= maxRetries) {
                    return response;
                }
            } catch (IOException e) {
                if (attempt >= maxRetries) {
                    throw e;
                }
            }

            long baseDelay = Math.min(2_000L, 100L << attempt);
            long jitter = ThreadLocalRandom.current()
                    .nextLong(0, 100);
            long delay = baseDelay + jitter;

            long afterDelay =
                    Duration.between(Instant.now(), deadline).toMillis();

            if (afterDelay <= delay) {
                throw new java.util.concurrent.TimeoutException(
                        "deadline too close for another retry");
            }

            Thread.sleep(delay);
            attempt++;
        }
    }

    public static void main(String[] args) throws Exception {
        Instant deadline = Instant.now().plusMillis(2_000);

        HttpResponse<String> response = getWithRetry(
                URI.create("https://example.com/api"),
                deadline,
                2);

        System.out.println(response.statusCode());
    }
}

这个例子有几个重要边界:

  • HTTP 400 不重试,因为通常是请求本身有问题;
  • HTTP 503 可以重试,但仍受次数和截止时间限制;
  • IOException 不一定表示远端没有执行请求,因此对非幂等操作不能直接套用;
  • Thread.sleep 会阻塞当前线程。在高并发异步系统中,应使用调度器或异步延迟,不能无界增加阻塞线程。

3.4 重试放在哪一层

如果一条调用链有三层,每层最多重试 3 次,最坏情况下可能产生:

33=273^3 = 27

次底层请求,而不是预期的 3 次。更准确地说,如果“初始调用 + 2 次重试”在每层都执行,放大因子为:

3×3×3=273 \times 3 \times 3 = 27

因此通常应在明确的一层统一重试,或者使用共享的重试预算。底层客户端、业务服务、网关同时重试,是生产事故中的常见放大器。


四、限流:限制进入系统的速率和并发

4.1 限流与并发控制不是一回事

速率限流限制一段时间内允许通过多少请求,例如每秒 100 个。

并发限制限制同时执行多少请求,例如同时最多 50 个。

二者控制的对象不同:

  • 速率限流防止单位时间内进入过多任务;
  • 并发限制防止正在执行的任务数量过多。

一个服务即使每秒只有 10 个请求,如果每个请求耗时 60 秒,也可能长期积累 600 个并发任务。因此生产系统经常同时使用速率和并发限制。

4.2 令牌桶模型

令牌桶有两个参数:

  • 桶容量 CC
  • 令牌产生速率 rr,单位为令牌/秒。

每个请求消耗一个或多个令牌。若当前令牌不足,请求被拒绝、等待或排队。

令牌数量的更新可表示为:

T(t)=min(C, T(t0)+r(tt0))T(t) = \min(C,\ T(t_0) + r(t-t_0))

请求到达时:

  • T(t)1T(t) \geq 1,扣除一个令牌并放行;
  • T(t)<1T(t) < 1,根据策略拒绝或等待。

桶容量允许短时突发,速率决定长期平均吞吐。例如 C=100C=100r=20/sr=20/s 表示最多瞬时放行 100 个请求,长期平均约为每秒 20 个。

4.3 限流的作用域

限流不能只看“整个服务”这一层。常见作用域包括:

  • 全局入口;
  • 每个租户;
  • 每个用户;
  • 每个 IP;
  • 每个接口;
  • 每个下游依赖;
  • 每个高成本操作。

如果只做全局限流,一个大租户可能占满全部额度,使其他租户全部失败。多租户系统通常需要“全局上限 + 租户配额”的组合。

4.4 排队不是免费的

当限流后把请求放入队列,队列会增加等待时间和内存占用。设到达速率为 λ\lambda,服务速率为 μ\mu,当:

λμ\lambda \geq \mu

队列长度会持续增长,最终表现为内存压力、超时和级联失败。排队只能吸收短时突发,不能解决长期容量不足。

因此队列必须有:

  • 最大长度;
  • 最大等待时间;
  • 超时后的明确丢弃策略;
  • 队列长度和等待时间监控。

五、熔断:停止向已知异常的依赖继续施压

5.1 熔断器状态

熔断器通常包含三个状态:

stateDiagram-v2
    [*] --> CLOSED
    CLOSED --> OPEN: 失败率/慢调用率达到阈值
    OPEN --> HALF_OPEN: 等待窗口结束
    HALF_OPEN --> CLOSED: 探测请求成功
    HALF_OPEN --> OPEN: 探测请求失败

CLOSED:关闭

请求正常通过,熔断器记录成功、失败和慢调用等统计数据。

OPEN:打开

请求不再访问依赖,而是快速失败或直接进入降级逻辑。这样可以:

  • 避免继续占用线程和连接;
  • 避免把流量送给已经过载的依赖;
  • 缩短调用方失败时间。

HALF_OPEN:半开

等待一段时间后,只允许少量探测请求通过。如果依赖恢复,回到 CLOSED;如果探测仍失败,回到 OPEN

半开状态必须限制探测并发量。如果所有请求同时进入半开,熔断器就失去了保护作用。

5.2 熔断统计不能只看百分比

设窗口内共有 NN 次调用,失败数为 FF,失败率为:

p=FNp = \frac{F}{N}

pp 超过阈值时打开熔断器。但如果 N=2N=2,其中 1 次失败,失败率为 50%,这个统计没有足够样本。因此通常还需要最小调用数:

NNminN \geq N_{\min}

此外,超时和慢调用也可能代表依赖已经影响系统,即使最终返回了成功。实际策略可分别统计:

  • 异常率;
  • 超时率;
  • 慢调用率;
  • 特定状态码比例。

熔断器的失败定义必须由业务决定。例如 HTTP 404 通常是正常业务结果,不应计入依赖故障;HTTP 503 通常应计入。

5.3 熔断和重试的先后关系

常见调用路径是:

限流 → 熔断判断 → 调用 → 超时/失败 → 有限重试 → 记录最终结果 → 降级

但重试是否每次都经过熔断器,要有明确设计:

  • 如果每次重试都算一次熔断样本,能更快识别依赖异常;
  • 如果一次用户请求的所有重试只算一个样本,统计更接近用户视角;
  • 无论采用哪种方式,都必须避免重试把熔断统计和负载同时放大。

实际系统中还要区分“熔断拒绝”和“依赖实际失败”。熔断打开时没有访问依赖,不能把这类请求统计成新的下游 500,否则会形成错误反馈。


六、隔离:让故障只能占用有限资源

隔离(bulkhead)来自船舱隔离的概念:一个舱室进水,不应淹没整条船。在服务中,隔离是为不同调用分配独立或有上限的资源。

6.1 需要隔离的资源

常见资源包括:

  • 工作线程;
  • 虚拟线程任务数量;
  • 数据库连接;
  • HTTP 连接;
  • 信号量许可;
  • 队列容量;
  • CPU 和内存预算;
  • 每个租户或依赖的并发额度。

例如订单服务调用推荐服务变慢时,如果推荐调用和支付调用共享无限队列及同一个线程池,推荐故障可能占满线程,导致支付也无法执行。

6.2 信号量隔离

信号量隔离限制同时执行的调用数:

import java.util.concurrent.*;

public class SemaphoreIsolation {
    private final Semaphore permits = new Semaphore(20);

    public String call(Callable<String> operation)
            throws Exception {

        if (!permits.tryAcquire()) {
            throw new RejectedExecutionException(
                    "dependency concurrency limit exceeded");
        }

        try {
            return operation.call();
        } finally {
            permits.release();
        }
    }
}

这段代码的关键不是 Semaphore 本身,而是 finally 中的释放。若异常和超时路径没有释放许可,最终所有请求都会被误认为“并发已满”。

信号量隔离不创建额外线程,它适合限制同步或异步任务的并发数量,但调用线程仍可能被阻塞。

6.3 线程池隔离

线程池隔离为某类任务分配独立线程池和有限队列。它可以避免一个依赖占用整个应用线程池,但代价是:

  • 线程和队列有额外内存成本;
  • 调度和上下文切换增加;
  • 线程池配置不当会造成大量排队;
  • 超时后,底层任务可能仍在执行。

因此线程池隔离必须同时定义“队列满”和“任务超时”的行为。

6.4 虚拟线程不是无限容量

Java 25 中的虚拟线程适合高并发阻塞式 I/O,但它们降低的是线程创建和阻塞成本,不会增加:

  • 数据库最大连接数;
  • 对端处理能力;
  • CPU;
  • 内存;
  • 单个依赖可承受的并发量。

如果每个虚拟线程都持有一个数据库连接,数据库仍然可能被打满。虚拟线程通常仍应配合信号量、连接池和下游并发限制。


七、降级:明确牺牲什么,以保住什么

降级不是捕获异常后返回 null。它是对功能优先级的明确选择:当完整结果不可用时,系统返回一个业务上可接受的替代结果。

7.1 常见降级形式

缓存结果

例如商品详情读取失败时返回最近一次成功缓存。必须明确:

  • 缓存最大允许陈旧时间;
  • 缓存不存在时怎么办;
  • 过期缓存是否允许继续返回;
  • 如何标识结果可能过期。

默认值

例如推荐服务失败时返回空推荐列表。空列表必须和“没有推荐”在业务语义上可区分,必要时增加:

{
  "items": [],
  "degraded": true,
  "reason": "recommendation_unavailable"
}

部分结果

聚合接口依赖多个服务时,可以返回已经成功的部分数据,同时标识缺失模块。不能把缺失数据静默伪装成完整数据。

快速失败

支付、库存扣减等不能安全返回默认值的操作,通常应快速失败并让调用方稍后重试,而不是伪造成功。

7.2 降级也可能造成数据错误

读取场景通常可以使用陈旧缓存,但写入场景不能把“写入失败”降级为“写入成功”。例如:

扣款服务超时 → 返回“支付成功”

这会把技术失败转化成业务数据错误,后续对账和退款都会更加复杂。

降级策略应按业务不变量设计,而不是按异常类型机械配置。


八、把六种机制组合成一条可推理的调用路径

假设订单接口总截止时间为 1 秒,调用库存服务和推荐服务。

目标:

  • 库存是交易关键依赖;
  • 推荐是非关键依赖;
  • 库存不能返回错误默认值;
  • 推荐可以返回空结果。

可以设计为:

订单入口限流
  ↓
订单线程/任务隔离
  ↓
库存熔断
  ↓
库存并发上限
  ↓
库存请求,单次最多 250ms
  ↓
仅对连接异常或 503 重试一次
  ↓
失败则订单快速失败

推荐熔断
  ↓
推荐并发上限
  ↓
推荐请求,单次最多 150ms
  ↓
最多一次重试,但受剩余 deadline 限制
  ↓
失败则返回缓存或空推荐

为什么库存和推荐要不同处理?因为它们的业务后果不同。韧性策略不是“所有下游统一超时 500 ms、重试 3 次、失败返回空对象”,而是由业务不变量决定。

8.1 一个完整预算算例

假设入口总预算为 1000 ms:

  • 本地业务处理预留:150 ms;
  • 库存调用预算:500 ms;
  • 推荐调用预算:200 ms;
  • 响应编码和网络余量:150 ms。

库存调用允许初次请求 250 ms,失败后等待 50 ms,再请求 200 ms:

250+50+200=500ms250 + 50 + 200 = 500\text{ms}

推荐调用若只剩 180 ms,就不能执行“等待 100 ms + 请求 150 ms”的重试,因为:

100+150>180100 + 150 > 180

此时应直接降级。否则推荐调用会消耗订单接口本来分配给响应处理的时间。


九、Spring Boot 中的落地边界

Spring Framework 和 Spring Boot 提供 Web、HTTP 客户端、配置、生命周期和观测等基础能力,但它们本身不是一个完整的“限流、熔断、重试产品”。官方文档中的客户端、Web MVC、WebFlux、配置和任务执行能力可以作为基础设施:

完整的熔断器、分布式限流器或重试组件通常来自独立库。引入库时必须核对该库与当前 Spring Boot、Java 25 的兼容矩阵,不能因为存在一个注解就假设所有异常、线程和超时语义都已经正确处理。

9.1 Controller 层的错误处理

Spring MVC 示例:

@RestController
class InventoryController {

    private final InventoryService inventoryService;

    InventoryController(InventoryService inventoryService) {
        this.inventoryService = inventoryService;
    }

    @GetMapping("/orders/{id}")
    OrderView getOrder(@PathVariable long id) {
        return inventoryService.getOrderView(id);
    }
}

如果库存服务熔断或超时,业务层应抛出有明确语义的异常,例如 DependencyUnavailableException,再由统一异常处理器映射为合适的 HTTP 响应:

@RestControllerAdvice
class ErrorHandler {

    @ExceptionHandler(DependencyUnavailableException.class)
    ResponseEntity<ProblemDetail> unavailable(
            DependencyUnavailableException ex) {

        ProblemDetail problem =
                ProblemDetail.forStatus(503);
        problem.setTitle("dependency unavailable");
        problem.setDetail("inventory service is temporarily unavailable");

        return ResponseEntity.status(503).body(problem);
    }
}

这里的 503 表示服务暂时无法完成请求,不等同于库存为 0,也不应被前端误解为业务成功。

对于可降级的推荐数据,则可以在服务层返回包含降级标记的结果,而不是通过全局异常处理器把所有情况都转成 503。

9.2 配置应外置,但语义不能外置

超时时间、最大重试次数、熔断阈值和限流额度通常适合放在配置中:

resilience:
  inventory:
    request-timeout: 250ms
    max-retries: 1
    max-concurrency: 40
    circuit:
      minimum-calls: 20
      failure-rate: 50%
  recommendation:
    request-timeout: 150ms
    max-concurrency: 20

配置只是参数来源,不能替代设计。修改配置前仍要回答:

  • 这个超时是否小于上游剩余预算;
  • 重试是否会放大下游流量;
  • 并发上限是否超过连接池和下游容量;
  • 熔断打开后是否存在可接受的降级结果;
  • 配置变更如何验证和回滚。

不同 resilience 库的配置键、默认值和状态定义并不统一,以上配置是表达策略的示意,不应直接假定可被任意库识别。


十、诊断:从“请求失败”还原故障路径

只记录最终 HTTP 500 不足以诊断韧性问题。至少需要区分以下事件:

  • 入口限流拒绝;
  • 并发隔离拒绝;
  • 熔断打开拒绝;
  • 依赖连接失败;
  • 依赖响应 5xx;
  • 单次调用超时;
  • 重试发生;
  • 重试耗尽;
  • 使用缓存降级;
  • 无可用降级而快速失败。

每次请求应携带相关标识,例如:

trace_id
request_deadline
dependency=inventory
attempt=2
resilience_event=timeout

指标也应分层统计:

inventory.calls.total
inventory.calls.failed
inventory.calls.timed_out
inventory.retries.total
inventory.circuit.open
inventory.bulkhead.rejected
inventory.fallback.used

否则会出现一种误判:业务成功率看起来很高,但实际上大量请求依赖了陈旧缓存;或者依赖返回成功率正常,但调用方因为本地线程池排队而大量超时。

10.1 需要特别观察的相关性

  • 超时增加之前,连接池是否已经耗尽;
  • 重试增加之后,下游 QPS 是否被放大;
  • 熔断打开时,应用线程数是否下降;
  • 降级率上升时,缓存命中率和缓存年龄如何;
  • 限流拒绝是否集中在某个租户或接口;
  • 依赖恢复后,半开探测是否过多。

日志中不要记录完整敏感请求体,也不要对每次重试都打印高成本堆栈。高频故障下日志本身可能成为新的性能问题。


十一、常见错误方案及其失败原因

11.1 “所有异常都重试三次”

错误原因:

  • 业务异常不会因重试消失;
  • 非幂等操作可能被重复执行;
  • 持续性故障会被放大;
  • 三层重试可能变成 27 次底层调用。

11.2 “超时后继续增加线程”

错误原因:

  • 依赖没有恢复;
  • 线程、连接和内存消耗增加;
  • 排队更长,超时更多;
  • 最终可能触发 OOM 或线程调度抖动。

11.3 “熔断打开后返回成功”

错误原因:

  • 伪造业务结果;
  • 上游可能继续执行后续流程;
  • 数据一致性和对账无法保证。

正确做法是返回明确的降级结果或明确失败。

11.4 “缓存永远可以降级”

错误原因:

  • 缓存可能已经违反业务时效要求;
  • 库存、余额、权限等数据不能任意使用旧值;
  • 陈旧数据必须带有可接受时间边界。

11.5 “有超时就不会有资源泄漏”

错误原因:

  • 超时只结束调用方等待,不一定终止底层任务;
  • 信号量、连接、线程池任务仍可能被占用;
  • 必须检查取消语义和 finally 释放逻辑。

十二、如何验证策略确实有效

韧性配置不能只靠代码审查,需要模拟故障验证。至少应覆盖:

  1. 依赖立即返回 500;
  2. 依赖持续返回 503;
  3. 依赖响应延迟超过客户端超时;
  4. 连接建立失败;
  5. 请求发送后响应丢失;
  6. 入口流量超过限流额度;
  7. 隔离资源耗尽;
  8. 熔断从关闭转打开,再转半开和关闭;
  9. 缓存存在、缓存过期、缓存不存在;
  10. 非幂等请求在超时后是否可能被重复执行。

每次测试都应记录:

  • 实际下游请求次数;
  • 单个用户请求总耗时;
  • 线程、连接和队列使用量;
  • 熔断状态变化;
  • 降级结果是否符合业务语义;
  • 依赖恢复后系统是否能恢复流量。

例如测试“HTTP 503 + 最大重试一次”时,预期不是“接口最终成功”,而是:

一次用户请求最多产生两次下游调用;
第二次仍失败后进入降级或返回 503;
总耗时不超过入口 deadline;
熔断统计只按既定规则增加;
没有遗留信号量许可或线程池任务。

结语:韧性是资源、时间和业务语义的共同约束

超时控制时间,重试处理可能的瞬时失败,限流控制进入速率,熔断停止访问异常依赖,隔离限制故障可占用的资源,降级定义无法提供完整结果时的业务边界。

它们之间存在明确因果关系:

  • 没有超时,重试没有可靠的时间边界;
  • 没有幂等性判断,重试可能造成重复业务操作;
  • 没有限流和隔离,熔断前系统可能已经被拖垮;
  • 没有最小样本和半开探测,熔断状态可能误判;
  • 没有业务定义的降级,快速失败和返回默认值都可能错误;
  • 没有指标和故障演练,配置中的“保护”只是未经验证的假设。

生产环境中真正需要优化的不是“成功率越高越好”,而是让系统在失败时满足几个可验证的条件:等待有上限、重试有边界、并发可控制、故障可隔离、恢复可探测、结果有明确语义。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。