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;
- 请求在网络路径中丢失。
这类失败有时适合重试,但必须同时满足:
- 操作具有幂等性,或请求带有幂等键;
- 失败发生在可以安全判断的阶段;
- 重试次数和总耗时有上限;
- 重试不会超过下游剩余容量。
3. 持续性失败
例如:
- 依赖实例已经宕机;
- 认证配置错误;
- 数据库连接池配置错误;
- 请求格式始终非法;
- 依赖持续返回 500。
持续性失败不适合连续重试。重试只会制造更多负载,因此应尽快熔断或失败。
4. 过载
过载通常表现为:
- 请求排队时间增加;
- 线程池队列增长;
- 连接池耗尽;
- GC 时间增加;
- 下游开始大量超时。
过载时,单纯增加线程或重试次数通常会使问题更严重。限流、隔离和快速失败才是直接控制手段。
二、超时:把“等待”变成有上限的资源
2.1 超时的定义
超时是一个操作允许等待的最大时间。它不是“失败后等待多久”,而是从某个明确起点开始计算的时间预算。
一次 HTTP 调用至少可能包含:
- DNS 解析;
- 建立 TCP 连接;
- TLS 握手;
- 从连接池获取连接;
- 写入请求;
- 等待响应头;
- 读取响应体。
如果只配置了“读取超时”,连接池等待或 TLS 握手仍可能无限拖延。生产系统需要明确每个阶段的超时,或者使用一个贯穿全链路的总截止时间(deadline)。
2.2 超时、截止时间和剩余预算
设入口请求在时间 开始,允许的总预算为 ,当前时间为 ,则剩余预算为:
任何下游调用的超时都不应超过 。如果下游调用固定使用 2 秒,而上游只剩 300 毫秒,那么这个配置实际上允许下游调用破坏上游的 SLA。
更准确的做法是向下游传递截止时间:
X-Request-Deadline: 2025-01-01T12:00:00.250Z
下游收到后计算自己的剩余时间:
实际请求超时还应扣除本地处理和响应编码的安全余量:
其中 是预留给本地逻辑的时间。这样可以避免下游刚返回结果,上游已经超时的情况。
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 重试的必要条件
重试不是“发生异常就再调用一次”。一个调用适合重试,需要同时回答三个问题:
- 失败是否可能是瞬时的?
- 重复执行是否安全?
- 总预算是否允许再次等待?
例如:
GET /inventory/sku-1通常具有幂等语义,可以有限重试;POST /payments可能已经扣款成功但响应丢失,不能盲目重试;- 带
Idempotency-Key的支付请求,可以由服务端按键去重后重试。
幂等键的基本语义是:服务端保存某个键对应的最终结果;同一个键再次到达时返回原结果,而不是重新执行扣款。
3.2 指数退避和抖动
若所有实例在同一时间重试,会形成同步重试风暴。常用等待时间为:
其中:
- :初始等待时间;
- :已经完成的重试次数,从 0 开始;
- :单次等待上限;
- :随机抖动。
例如 ,上限为 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 次,最坏情况下可能产生:
次底层请求,而不是预期的 3 次。更准确地说,如果“初始调用 + 2 次重试”在每层都执行,放大因子为:
因此通常应在明确的一层统一重试,或者使用共享的重试预算。底层客户端、业务服务、网关同时重试,是生产事故中的常见放大器。
四、限流:限制进入系统的速率和并发
4.1 限流与并发控制不是一回事
速率限流限制一段时间内允许通过多少请求,例如每秒 100 个。
并发限制限制同时执行多少请求,例如同时最多 50 个。
二者控制的对象不同:
- 速率限流防止单位时间内进入过多任务;
- 并发限制防止正在执行的任务数量过多。
一个服务即使每秒只有 10 个请求,如果每个请求耗时 60 秒,也可能长期积累 600 个并发任务。因此生产系统经常同时使用速率和并发限制。
4.2 令牌桶模型
令牌桶有两个参数:
- 桶容量 ;
- 令牌产生速率 ,单位为令牌/秒。
每个请求消耗一个或多个令牌。若当前令牌不足,请求被拒绝、等待或排队。
令牌数量的更新可表示为:
请求到达时:
- 若 ,扣除一个令牌并放行;
- 若 ,根据策略拒绝或等待。
桶容量允许短时突发,速率决定长期平均吞吐。例如 、 表示最多瞬时放行 100 个请求,长期平均约为每秒 20 个。
4.3 限流的作用域
限流不能只看“整个服务”这一层。常见作用域包括:
- 全局入口;
- 每个租户;
- 每个用户;
- 每个 IP;
- 每个接口;
- 每个下游依赖;
- 每个高成本操作。
如果只做全局限流,一个大租户可能占满全部额度,使其他租户全部失败。多租户系统通常需要“全局上限 + 租户配额”的组合。
4.4 排队不是免费的
当限流后把请求放入队列,队列会增加等待时间和内存占用。设到达速率为 ,服务速率为 ,当:
队列长度会持续增长,最终表现为内存压力、超时和级联失败。排队只能吸收短时突发,不能解决长期容量不足。
因此队列必须有:
- 最大长度;
- 最大等待时间;
- 超时后的明确丢弃策略;
- 队列长度和等待时间监控。
五、熔断:停止向已知异常的依赖继续施压
5.1 熔断器状态
熔断器通常包含三个状态:
stateDiagram-v2
[*] --> CLOSED
CLOSED --> OPEN: 失败率/慢调用率达到阈值
OPEN --> HALF_OPEN: 等待窗口结束
HALF_OPEN --> CLOSED: 探测请求成功
HALF_OPEN --> OPEN: 探测请求失败
CLOSED:关闭
请求正常通过,熔断器记录成功、失败和慢调用等统计数据。
OPEN:打开
请求不再访问依赖,而是快速失败或直接进入降级逻辑。这样可以:
- 避免继续占用线程和连接;
- 避免把流量送给已经过载的依赖;
- 缩短调用方失败时间。
HALF_OPEN:半开
等待一段时间后,只允许少量探测请求通过。如果依赖恢复,回到 CLOSED;如果探测仍失败,回到 OPEN。
半开状态必须限制探测并发量。如果所有请求同时进入半开,熔断器就失去了保护作用。
5.2 熔断统计不能只看百分比
设窗口内共有 次调用,失败数为 ,失败率为:
当 超过阈值时打开熔断器。但如果 ,其中 1 次失败,失败率为 50%,这个统计没有足够样本。因此通常还需要最小调用数:
此外,超时和慢调用也可能代表依赖已经影响系统,即使最终返回了成功。实际策略可分别统计:
- 异常率;
- 超时率;
- 慢调用率;
- 特定状态码比例。
熔断器的失败定义必须由业务决定。例如 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:
推荐调用若只剩 180 ms,就不能执行“等待 100 ms + 请求 150 ms”的重试,因为:
此时应直接降级。否则推荐调用会消耗订单接口本来分配给响应处理的时间。
九、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释放逻辑。
十二、如何验证策略确实有效
韧性配置不能只靠代码审查,需要模拟故障验证。至少应覆盖:
- 依赖立即返回 500;
- 依赖持续返回 503;
- 依赖响应延迟超过客户端超时;
- 连接建立失败;
- 请求发送后响应丢失;
- 入口流量超过限流额度;
- 隔离资源耗尽;
- 熔断从关闭转打开,再转半开和关闭;
- 缓存存在、缓存过期、缓存不存在;
- 非幂等请求在超时后是否可能被重复执行。
每次测试都应记录:
- 实际下游请求次数;
- 单个用户请求总耗时;
- 线程、连接和队列使用量;
- 熔断状态变化;
- 降级结果是否符合业务语义;
- 依赖恢复后系统是否能恢复流量。
例如测试“HTTP 503 + 最大重试一次”时,预期不是“接口最终成功”,而是:
一次用户请求最多产生两次下游调用;
第二次仍失败后进入降级或返回 503;
总耗时不超过入口 deadline;
熔断统计只按既定规则增加;
没有遗留信号量许可或线程池任务。
结语:韧性是资源、时间和业务语义的共同约束
超时控制时间,重试处理可能的瞬时失败,限流控制进入速率,熔断停止访问异常依赖,隔离限制故障可占用的资源,降级定义无法提供完整结果时的业务边界。
它们之间存在明确因果关系:
- 没有超时,重试没有可靠的时间边界;
- 没有幂等性判断,重试可能造成重复业务操作;
- 没有限流和隔离,熔断前系统可能已经被拖垮;
- 没有最小样本和半开探测,熔断状态可能误判;
- 没有业务定义的降级,快速失败和返回默认值都可能错误;
- 没有指标和故障演练,配置中的“保护”只是未经验证的假设。
生产环境中真正需要优化的不是“成功率越高越好”,而是让系统在失败时满足几个可验证的条件:等待有上限、重试有边界、并发可控制、故障可隔离、恢复可探测、结果有明确语义。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java RabbitMQ:Exchange、确认、重试、死信、顺序和幂等
- 下一篇:Java 服务上 Kubernetes:资源、探针、JVM 容器感知和优雅关闭
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论