远程客户端与容错组合:Feign、Resilience4j 与失败边界
上一页把网关边缘的可靠性拆成身份、限流、超时与观测。这一页把视角移到调用方:一个 Java 服务怎样把接口变成真实 HTTP 请求,连接失败、读超时、已提交丢响应分别意味着什么,Feign 的 Retryer、Apache HC5、Spring Cloud CircuitBreaker 与 Resilience4j 又怎样叠在一起。本页的可运行结论来自 labs/remote-client 的两个真实 loopback 工程:20 个 Feign 用例、15 个容错用例,没有 mock 代理,也没有直接调用内部方法。注册中心/LB 选实例、日志脱敏、Micrometer 导出等只做源码或配置说明,并在对应小节单独标注。
本页是 U33(#70)的 L03/EX2 权威正文:声明式客户端、超时与重试边界、熔断/隔离/时限组合、已提交丢响应的幂等恢复。通用 deadline、UNKNOWN、放大与恢复模型仍在超时、重试、熔断、限流与降级;RestClient 与 HTTP Interface 的默认工厂见HTTP 客户端;网关侧超时与限流见 Gateway 可靠性;实例选择与摘流见 Cloud 发现与负载均衡;幂等与重复消费见重复消费、幂等与顺序消息;Dubbo 的 Failover 见Dubbo 调用链。Sentinel 规则归 U37,配置中心归 U34,真实注册中心适配归 U35/U40。
一条出站调用的五种结局
五种结局与能否重试
同一个“调用失败”,在客户端层的含义并不相同:
| 结局 | 请求是否发出 | 服务端是否可能已执行 | 能否直接重试 |
|---|---|---|---|
| 连接被拒绝 / DNS 失败 | 没有 | 没有 | 通常可以,但要先确认没有代理重发 |
| 连接建立后读超时 | 已发出 | 可能已执行 | 只有幂等请求或带幂等键才能重试 |
| 服务端已提交但响应丢失 | 已发出 | 已执行 | 不能盲目重试,要按键查结果 |
| 收到 4xx | 已发出 | 通常未执行 | 一般不重试,先修请求 |
| 收到 5xx / 429 | 已发出 | 可能已执行 | 按状态、方法和幂等性决定 |
异常类型与 cause 树
这五种结局在代码里可能都表现为一个 RetryableException。异常类型只说明“框架认为可以重试”,不说明“重试不会产生第二个副作用”。这句话是整页的主线。
一条 Feign 出站请求会经过这些层次,越靠内层,越接近真实 socket:
JDK 动态代理
→ SpringMvcContract / Encoder
→ Feign SynchronousMethodHandler(Retryer)
→ Apache HttpClient5(连接池、连接/读超时、隐藏的 503 重试)
→ 网络与服务端外层的 CircuitBreaker、Retry、Bulkhead、TimeLimiter 又可能包住其中一段。每一层都只看见自己那一层的“一次尝试”,所以同一份配置在不同层次上会得到完全不同的尝试次数。
版本、工程与观察入口
固定 Spring Cloud OpenFeign 5.0.4(Feign 13.6.1)、Spring Cloud CircuitBreaker 5.0.3、Resilience4j 2.3.0;Boot 4.0.9、Cloud 2025.1.4、Spring Framework 7.0.10。Feign 核心的默认客户端是 Client.Default,spring-cloud-starter-openfeign 不传递 feign-hc5;本实验在 feign/pom.xml 显式加入 feign-hc5,Spring Cloud OpenFeign 的自动配置因此选用 ApacheHttp5Client,实际解析到 Apache HttpClient5 5.5.2。运行环境是 macOS 27.0.1 arm64、BellSoft Liberica JDK 21.0.11、Maven 3.9.9。查询与运行日期 2026-10-27。
公开入口是 labs/remote-client 的两个独立模块:
python3 labs/remote-client/run.pyfeign 模块启动一个真实的 com.sun.net.httpserver.HttpServer,用随机端口监听;resilience 模块用真实 JDK HttpClient 和第二个 loopback HTTP 上游观察已提交丢响应。两个模块都不依赖外部服务,不写用户数据,测试结束回收进程。完整命令、退出码、Surefire 计数、观察行与文件 SHA-256 见运行证据,工程边界见实验说明。
本页框架原码均为固定发布提交的连续片段,元数据含整文件 SHA-256 与行范围。发布源码包与官方仓库作比对,R4j 源码包 CRLF 与仓库 LF 的整文件哈希不同,以元数据的官方原始文件为门禁依据;教学配置和运行观察另行标注。源码、配置、编译、HTTP 实跑是四种证据,不能互相替代。
维护状态必须先说清。Spring Cloud OpenFeign 官方参考文档将项目标为 feature-complete,以缺陷修复和小型社区改动为主,建议迁移到 Spring HTTP Service Clients(查询于 2026-10-27)。本页服务存量系统理解与维护;替代路径在后文通过真实调用验证。
声明式代理:从接口到请求
工厂、代理与固定目标
接口本身没有实现:
// labs/remote-client/feign/src/main/java/study/remoteclient/feign/ItemApi.java
@FeignClient(name = "item", url = "${lab.upstream}")
public interface ItemApi {
@GetMapping("/items/{id}")
Item get(@PathVariable("id") long id, @RequestHeader("X-Lab-Token") String token);
}@EnableFeignClients 注册 FeignClientFactoryBean,由它按接口类型创建 JDK 动态代理。name 是服务标识;未给 contextId 时也参与客户端配置上下文命名,指标标签取决于所用能力;url 存在时使用 HardCodedTarget,即使 classpath 上有 LoadBalancer,工厂也会把 FeignBlockingLoadBalancerClient 拆回普通客户端:
固定源码 FeignClientFactoryBean.java,480–493 行:
if (!StringUtils.hasText(url) && !isUrlAvailableInConfig(contextId)) {
if (LOG.isInfoEnabled()) {
LOG.info("For '" + name + "' URL not provided. Will try picking an instance via load-balancing.");
}
if (!name.startsWith("http://") && !name.startsWith("https://")) {
url = "http://" + name;
}
else {
url = name;
}
url += cleanPath();
return (T) loadBalance(builder, feignClientFactory, new HardCodedTarget<>(type, name, url));
}固定源码 FeignClientFactoryBean.java,500–514 行:
Client client = getOptional(feignClientFactory, Client.class);
if (client != null) {
warnIfAlreadyResolved();
if (client instanceof FeignBlockingLoadBalancerClient) {
// not load balancing because we have a url,
// but Spring Cloud LoadBalancer is on the classpath, so unwrap
client = ((FeignBlockingLoadBalancerClient) client).getDelegate();
}
if (client instanceof RetryableFeignBlockingLoadBalancerClient) {
// not load balancing because we have a url,
// but Spring Cloud LoadBalancer is on the classpath, so unwrap
client = ((RetryableFeignBlockingLoadBalancerClient) client).getDelegate();
}
builder.client(client);
}url、name 与 LoadBalancer
这解释了两种常见配置的差别:url 指向固定实例,不经过实例目录;没有 url 时才会走 name 对应的 LoadBalancer。实验全部使用固定 url,所以这里观察的是客户端行为,不是服务发现行为。
Contract 与首个请求
SpringMvcContract 把 Spring MVC 注解翻译成 Feign 的 RequestTemplate。真实观察是:get(42, "synthetic") 把路径参数绑定成 /items/42,把 X-Lab-Token 放进请求头;上游只收到 1 次请求。Contract 在创建代理时就检查静态注解与参数元数据,因此 eager 注入的客户端可以在启动时因非法契约失败。随调用变化的参数展开、请求体编码和响应体解码在调用时发生,不能把两类错误都说成“首调才发现”。
建代理时先解析 Contract 的直接依据:
public <T> T newInstance(Target<T> target, C requestContext) {
TargetSpecificationVerifier.verify(target);
Map<Method, MethodHandler> methodToHandler =
targetToHandlersByName.apply(target, requestContext);
InvocationHandler handler = factory.create(target, methodToHandler);
T proxy =
(T)
Proxy.newProxyInstance(
target.type().getClassLoader(), new Class<?>[] {target.type()}, handler);Encoder/Decoder 与消息转换
SpringEncoder/SpringDecoder 通过 HttpMessageConverters 处理请求体与响应体。同一观察里,响应 JSON 被解码成 Item(42, "item-42");缺少对应转换器或媒体类型不匹配时,失败同样发生在首调。实验另通过 JSON POST 的合法输入→400非法输入→合法恢复验证 SpringEncoder,并验证非法静态契约在建代理时失败且未发出请求。编码解码的异常分类仍须按调用阶段判断。
Optional 与 ResponseEntity 的包装链
默认 Decoder 是 OptionalDecoder(ResponseEntityDecoder(SpringDecoder))。OptionalDecoder 处理 Optional 容器;ResponseEntityDecoder 剥开实体泛型、保留状态与头;SpringDecoder 做实际消息转换。Boot 4 / Spring 7 本版本使用 FeignHttpMessageConverters,不复制旧版 HttpMessageConverters 构造代码。 <!-- source-excerpt: {"repo":"spring-cloud/spring-cloud-openfeign","tag":"3e238ca530686bfdfe6007f6bcdfb24e800b65ee","path":"spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/FeignClientsConfiguration.java","sha256":"cf0f97c5a280c01b0814420b64a1fcb1b21e71ac6b8bc5eb4be2156242641fdd","start":113,"end":117} -->
@ConditionalOnMissingBean
public Decoder feignDecoder(ObjectProvider<FeignHttpMessageConverters> messageConverters) {
return new OptionalDecoder(new ResponseEntityDecoder(new SpringDecoder(messageConverters)));
}Optional<Item>:正常 JSON present,204 empty,非法 JSON 抛解码异常,再调用正常接口恢复。ResponseEntity<Item>:保留 200、X-Fixture 和 Item;204 无 body 但状态保留;非法 JSON 仍失败。不能把业务非 2xx 当正常 ResponseEntity。404 先经错误分流,OptionalDecoder 的 404 empty 分支需要 dismiss404 前提;本次没有跑 dismiss404,只跑 204。 <!-- source-excerpt: {"repo":"OpenFeign/feign","tag":"e90a94b81d7a0dec39eb1f91b00dfe509c49045a","path":"core/src/main/java/feign/optionals/OptionalDecoder.java","sha256":"7dd6611b21edffd7124a9be5fd802201fe4420ec70acc08306602251f9bf8132","start":35,"end":46} -->
@Override
public Object decode(Response response, Type type) throws IOException {
if (!isOptional(type)) {
return delegate.decode(response, type);
}
if (response.status() == 404 || response.status() == 204) {
return Optional.empty();
}
Type enclosedType = Util.resolveLastTypeParameter(type, Optional.class);
return Optional.ofNullable(delegate.decode(response, enclosedType));
} @Override
public Object decode(final Response response, Type type) throws IOException, FeignException {
if (isParameterizeHttpEntity(type)) {
type = ((ParameterizedType) type).getActualTypeArguments()[0];
if (type == Void.class) {
return createResponse(null, response);
}
Object decodedObject = this.decoder.decode(response, type);
return createResponse(decodedObject, response);
}
else if (isHttpEntity(type)) {
return createResponse(null, response);
}
else {
return this.decoder.decode(response, type);
}
}
private boolean isParameterizeHttpEntity(Type type) { try {
final boolean shouldDecodeResponseBody =
(response.status() >= 200 && response.status() < 300)
|| (response.status() == 404 && dismiss404 && !isVoidType(returnType));
if (!shouldDecodeResponseBody) {
throw decodeError(configKey, response);
}
if (isVoidType(returnType) && !decodeVoid) {
ensureClosed(response.body());本次可编译运行的完整 Decoder 方法直接取冻结测试;使用真实容器 ObjectProvider,未 mock:
private static Decoder configuredDecoder() {
return new OptionalDecoder(new ResponseEntityDecoder(
new SpringDecoder(decoderBeans().getBeanProvider(FeignHttpMessageConverters.class))));
}
private static DefaultListableBeanFactory decoderBeans() {
var beans = new DefaultListableBeanFactory();
beans.registerSingleton("converters", new FeignHttpMessageConverters(
beans.getBeanProvider(ClientHttpMessageConvertersCustomizer.class),
beans.getBeanProvider(HttpMessageConverterCustomizer.class)));
return beans;
}配置类隔离与拦截器
配置类的位置有一个真实陷阱。自定义 Retryer 放在 study.remoteclient.feignconfig,而不是 study.remoteclient.feign 包内:
// labs/remote-client/feign/src/main/java/study/remoteclient/feignconfig/RetryClientConfig.java
@Configuration(proxyBeanMethods = false)
public class RetryClientConfig {
@Bean
Retryer retryer() {
return new Retryer.Default(20, 50, 3);
}
}如果把这个 @Configuration 类放进父上下文的组件扫描路径(初始版本就是这样),它会被父上下文组件扫描,Retryer.Default 就泄漏给所有 Feign 客户端:原本只想给 retryItem 开重试,结果 item 也获得了同一个 Retryer。真正被放大的是带 Retry-After 的 502 和读超时这类 RetryableException;500/503 直接映射成 FeignException,不会被 Feign 的 Retryer 重试。修复不是改测试,而是把配置类移出扫描路径,让 @FeignClient(configuration = ...) 的隔离真正生效。Feign 的 per-client 配置不是命名空间隔离,而是 Spring 子上下文隔离;类被父扫描到就会变成全局 Bean。
拦截器与日志
标准 Feign Logger.Level 缺省 NONE;标准 Slf4jLogger 还要求接口分类 DEBUG。BASIC 输出方法/完整 URL/状态/耗时,HEADERS 加头,FULL 加 body。BASIC 也可能泄露 query secret;只覆写 shouldLogRequestHeader/shouldLogResponseHeader 不能净化 URL/body。HEADERS 的基类响应日志也会读出并重建 body,有缓冲成本。 <!-- source-excerpt: {"repo":"OpenFeign/feign","tag":"e90a94b81d7a0dec39eb1f91b00dfe509c49045a","path":"core/src/main/java/feign/Logger.java","sha256":"588653116cceb6fd9596f1c1b664aaba2f892e351c4ae5ba16f9e7c986157f26","start":68,"end":91} -->
protected void logRequest(String configKey, Level logLevel, Request request) {
String protocolVersion = resolveProtocolVersion(request.protocolVersion());
log(configKey, "---> %s %s %s", request.httpMethod().name(), request.url(), protocolVersion);
if (logLevel.ordinal() >= Level.HEADERS.ordinal()) {
for (String field : request.headers().keySet()) {
if (shouldLogRequestHeader(field)) {
for (String value : valuesOrEmpty(request.headers(), field)) {
log(configKey, "%s: %s", field, value);
}
}
}
int bodyLength = 0;
if (request.body() != null) {
bodyLength = request.length();
if (logLevel.ordinal() >= Level.FULL.ordinal()) {
String bodyText =
request.charset() != null ? new String(request.body(), request.charset()) : null;
log(configKey, ""); // CRLF
log(configKey, "%s", bodyText != null ? bodyText : "Binary data");
}
}
log(configKey, "---> END HTTP (%s-byte body)", bodyLength);以下完整保守示例放在应用扫描范围外,通过指定 FeignClient(configuration=SafeReadConfiguration.class) 使用。它只记录方法、接口方法标识、状态、耗时和异常类型,不输出 URL/query、头、body、异常消息;即便配置 FULL 也维持此白名单。此示例为源码依据的配置,未在本次六测试运行。
package example.feignconfig;
import feign.Logger;
import feign.Request;
import feign.RequestInterceptor;
import feign.Response;
import feign.Retryer;
import java.io.IOException;
import org.springframework.context.annotation.Bean;
public class SafeReadConfiguration {
@Bean public Logger safeLogger() { return new MetadataLogger(); }
@Bean public Logger.Level safeLevel() { return Logger.Level.BASIC; }
@Bean public Retryer noRetry() { return Retryer.NEVER_RETRY; }
@Bean public RequestInterceptor syntheticIdentity() {
return template -> {
template.removeHeader("X-Lab-Token");
template.header("X-Lab-Token", "synthetic");
// 不记录模板;真实凭据由受控提供方注入。
};
}
static final class MetadataLogger extends Logger {
private final org.slf4j.Logger sink = org.slf4j.LoggerFactory.getLogger("example.feign.SafeMetadata");
@Override protected void log(String key, String format, Object... args) {
sink.debug("{} {}", key, String.format(java.util.Locale.ROOT, format, args));
}
@Override protected void logRequest(String key, Level level, Request request) {
log(key, "outbound method=%s", request.httpMethod());
}
@Override protected Response logAndRebufferResponse(String key, Level level, Response response, long elapsed) {
log(key, "inbound status=%d elapsedMs=%d", response.status(), elapsed);
return response;
}
@Override protected IOException logIOException(String key, Level level, IOException failure, long elapsed) {
log(key, "io-failure type=%s elapsedMs=%d", failure.getClass().getSimpleName(), elapsed);
return failure;
}
}
}此自定义 logger 分类是 example.feign.SafeMetadata,需将它设 DEBUG;标准接口分类 DEBUG 不能替代自定义分类配置。若改用头过滤,至少双向大小写不敏感屏蔽 Authorization、Proxy-Authorization、Cookie、Set-Cookie/API key,另处理 URL/body。不要把“[REDACTED]”写回出站认证头。 interceptor 在每次发送前操作模板,Feign 重试可能重新执行。临时签名可更新,业务幂等键必须跨尝试稳定。多个拦截器有依赖时显式组合,不假设无条件排序。 <!-- source-excerpt: {"repo":"OpenFeign/feign","tag":"e90a94b81d7a0dec39eb1f91b00dfe509c49045a","path":"core/src/main/java/feign/SynchronousMethodHandler.java","sha256":"d1a69eb727aba357558f4de0e28d6e2e08b36e56f8f033a634a577369b386b9c","start":118,"end":124} -->
Request targetRequest(RequestTemplate template) {
for (RequestInterceptor interceptor : methodHandlerConfiguration.getRequestInterceptors()) {
interceptor.apply(template);
}
return methodHandlerConfiguration.getTarget().apply(template);
}压缩:开关、大小与 transport 条件
Spring Cloud request.enabled 与 response.enabled 开关缺省不启用,对应 ConditionalOnProperty 决定拦截器自动配置。手工 builder 不因 YAML 存在就自动安装拦截器。 <!-- source-excerpt: {"repo":"spring-cloud/spring-cloud-openfeign","tag":"3e238ca530686bfdfe6007f6bcdfb24e800b65ee","path":"spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/encoding/FeignContentGzipEncodingAutoConfiguration.java","sha256":"10cfdc4f1eadc4d4eb712143a38ebfc050e036bf91eac995c4287ad391389e92","start":36,"end":42} -->
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(FeignClientEncodingProperties.class)
@ConditionalOnClass(Feign.class)
@ConditionalOnProperty("spring.cloud.openfeign.compression.request.enabled")
@AutoConfigureAfter(FeignAutoConfiguration.class)
public class FeignContentGzipEncodingAutoConfiguration {@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(FeignClientEncodingProperties.class)
@ConditionalOnClass(Feign.class)
@ConditionalOnProperty("spring.cloud.openfeign.compression.response.enabled")
@AutoConfigureAfter(FeignAutoConfiguration.class)
public class FeignAcceptGzipEncodingAutoConfiguration {请求默认 MIME=text/xml,application/xml,application/json,阈值 2048 bytes。本版本要求 Content-Length 唯一、可解析,且严格 length > minRequestSize;等于 2048 不命中。MIME 用 contains 精确比较,带 charset 不应想当然命中;MIME 列表为空代表匹配所有,不是禁用。 <!-- source-excerpt: {"repo":"spring-cloud/spring-cloud-openfeign","tag":"3e238ca530686bfdfe6007f6bcdfb24e800b65ee","path":"spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/encoding/FeignClientEncodingProperties.java","sha256":"bf070faf16675565d56ec0db4bb659b10d1d474850046050a3680a5851f768f5","start":33,"end":49} -->
/**
* The list of supported mime types.
*/
private String[] mimeTypes = new String[] { "text/xml", "application/xml", "application/json" };
/**
* The minimum threshold content size.
*/
private int minRequestSize = 2048;
/**
* The list of content encodings (applicable encodings depend on the used client).
*/
private String[] contentEncodingTypes = new String[] { HttpEncoding.GZIP_ENCODING, HttpEncoding.DEFLATE_ENCODING };
public String[] getMimeTypes() {
return mimeTypes; try {
if (contentLength == null || contentLength.size() != 1) {
return false;
}
final String strLen = contentLength.iterator().next();
final long length = Long.parseLong(strLen);
return length > getProperties().getMinRequestSize();
}
catch (NumberFormatException ex) {
return false;
}
} if (getProperties().getMimeTypes() == null || getProperties().getMimeTypes().length == 0) {
// no specific mime types has been set - matching everything
return true;
}
for (String mimeType : getProperties().getMimeTypes()) {
if (contentTypes.contains(mimeType)) {
return true;
}
}
return false;
}重要反例:默认请求 content-encoding-types 包含 gzip 和 deflate,但 feign-hc5 13.6.1 对请求 deflate 直接抛 IllegalArgumentException,因此 HC5 示例显式设 gzip。拦截器只加 Content-Encoding,HC5 用 GzipCompressingEntity 做实际字节压缩。 <!-- source-excerpt: {"repo":"OpenFeign/feign","tag":"e90a94b81d7a0dec39eb1f91b00dfe509c49045a","path":"hc5/src/main/java/feign/hc5/ApacheHttp5Client.java","sha256":"579da76372f04dc8ffeb749b66811ce578aff947481052c0c053b7840342d1e7","start":141,"end":149} -->
isGzip = headerEntry.getValue().stream().anyMatch(Util.ENCODING_GZIP::equalsIgnoreCase);
boolean isDeflate =
headerEntry.getValue().stream().anyMatch(Util.ENCODING_DEFLATE::equalsIgnoreCase);
if (isDeflate) {
// DeflateCompressingEntity not available in hc5 yet
throw new IllegalArgumentException(
"Deflate Content-Encoding is not supported by feign-hc5");
}
} if (isGzip) {
entity = new GzipCompressingEntity(entity);
}
requestBuilder.setEntity(entity);
} else {响应开关添加 Accept-Encoding: gzip,deflate,是协商请求,不保证上游真的压缩,也不按 request 的阈值控制响应。transport 还可能自行协商/解压,Spring response 开关关闭不等于线上绝无压缩。不要把其他 transport 的透明压缩条件套给 HC5。 <!-- source-excerpt: {"repo":"spring-cloud/spring-cloud-openfeign","tag":"3e238ca530686bfdfe6007f6bcdfb24e800b65ee","path":"spring-cloud-openfeign-core/src/main/java/org/springframework/cloud/openfeign/encoding/FeignAcceptGzipEncodingInterceptor.java","sha256":"85fec53387de86fa6e30ef25fcdf2c66475c6d3a4d2757a6c8885ad03dc2b3bb","start":44,"end":49} -->
public void apply(RequestTemplate template) {
addHeader(template, HttpEncoding.ACCEPT_ENCODING_HEADER, HttpEncoding.GZIP_ENCODING,
HttpEncoding.DEFLATE_ENCODING);
}取舍:大文本节省网络但耗 CPU/缓冲;小 body 未必收益;下游须支持 Content-Encoding。本次未跑压缩字节、gzip/deflate 解压与服务端压缩策略,仅有固定源码和显式配置。
错误解码:同样的异常,不同的后果
IO 异常的统一包装
Feign 把 IO 异常统一包装成 RetryableException,状态码固定为 -1:
固定源码 FeignException.java,295–304 行:
static FeignException errorExecuting(Request request, IOException cause) {
final Long nonRetryable = null;
return new RetryableException(
-1,
format("%s executing %s %s", cause.getMessage(), request.httpMethod(), request.url()),
request.httpMethod(),
cause,
nonRetryable,
request);
}注意 nonRetryable = null 并不是“不可重试”,而是“没有 Retry-After 时间”。真正的重试许可由 Retryer 决定。
状态异常与被升级的可重试异常
收到 HTTP 响应时,ErrorDecoder.Default 先按状态构造 FeignException;只有响应头里有合法的 Retry-After,才把异常升级成 RetryableException:
固定源码 ErrorDecoder.java,100–115 行:
@Override
public Exception decode(String methodKey, Response response) {
FeignException exception =
errorStatus(methodKey, response, maxBodyBytesLength, maxBodyCharsLength);
Long retryAfter = retryAfterDecoder.apply(firstOrNull(response.headers(), RETRY_AFTER));
if (retryAfter != null) {
return new RetryableException(
response.status(),
exception.getMessage(),
response.request().httpMethod(),
exception,
retryAfter,
response.request());
}
return exception;
}真实观察分四组:
| 场景 | 客户端看到的异常 | 上游尝试次数 | 说明 |
|---|---|---|---|
500 无 Retry-After | FeignException.InternalServerError | 1 | 状态异常,不进入 Retryer |
502 + Retry-After: 1 | RetryableException(status 502) | 1 | 默认 Retryer 是 NEVER_RETRY |
| 连接拒绝 | RetryableException,根因 ConnectException,status -1 | 未到达 | 请求没有发出 |
| 读超时 | RetryableException,根因 SocketTimeoutException,status -1 | 1,效果 1 | 请求已发出,服务端可能已执行 |
第三、四行是整页最重要的对照:连接拒绝和读超时在 Feign 里是同一个异常类型、同一个 status -1,但一个确定没发出,一个可能已经提交。 只看异常类型做重试决策,迟早会在读超时上重复写。
RetryableException 的 retryAfter 字段也不是“建议等待时间”的保证:ErrorDecoder.Default 只在响应头存在且能解析时设置它;连接拒绝和读超时这两条路径的 retryAfter 都是 null。
自定义业务错误与限额重试
示例政策:422 → ItemRejected(非 Retryable),429 → RetryableException,其他状态委托 Default。配 Retryer.Default(1, 1, 2),含首发最多两次,period/maxPeriod 单位毫秒;1ms 仅为合成测试。HC5 自动重试关闭。真实端点第一次 429、第二次 200;422 只发一次;之后正常调用恢复。
static final class ItemRejected extends RuntimeException {
ItemRejected() { super("ITEM_REJECTED"); }
}
static final class BusinessErrorDecoder implements ErrorDecoder {
private final ErrorDecoder delegate = new ErrorDecoder.Default();
@Override public Exception decode(String methodKey, Response response) {
if (response.status() == 422) return new ItemRejected();
if (response.status() == 429) return new RetryableException(429, "synthetic rate limit",
response.request().httpMethod(), (Long) null, response.request());
return delegate.decode(methodKey, response);
}
}绑定:builder.errorDecoder(new BusinessErrorDecoder()).retryer(new Retryer.Default(1, 1, 2))。反例:Default 对无 Retry-After 的 429 不自动升级;返回 RetryableException 而 Retryer=NEVER_RETRY 仍不重试。Spring Cloud 默认 NEVER_RETRY,不等同裸 Feign builder。 取舍:422 的业务含义取决于真实 API;不能泛化全部 422。此示例 429 不解析 Retry-After,不代表保留 Default 的等待语义;生产需尊重 Retry-After、加入抖动及剩余 deadline。策略只用于可重放读,不应直接套无幂等保障写请求。
Retryer:默认不重试,重试也不看幂等
默认不重试与三参数退避
Spring Cloud OpenFeign 的默认 Retryer 是:
固定源码 FeignClientsConfiguration.java,148–152 行:
@Bean
@ConditionalOnMissingBean
public Retryer feignRetryer() {
return Retryer.NEVER_RETRY;
}NEVER_RETRY 只是把 RetryableException 原样抛出,不睡眠、不重试。所以“Feign 默认会重试”是一个常见误判:默认不会,真正隐藏的重试在 HTTP 客户端层(下一节)。
需要重试时,Feign 自带 Retryer.Default:
固定源码 Retryer.java,57–81 行:
public void continueOrPropagate(RetryableException e) {
if (attempt++ >= maxAttempts) {
throw e;
}
long interval;
if (e.retryAfter() != null) {
interval = e.retryAfter() - currentTimeMillis();
if (interval > maxPeriod) {
interval = maxPeriod;
}
if (interval < 0) {
return;
}
} else {
interval = nextMaxInterval();
}
try {
Thread.sleep(interval);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
throw e;
}
sleptForMillis += interval;
}固定源码 Retryer.java,90–93 行:
long nextMaxInterval() {
long interval = (long) (period * Math.pow(1.5, attempt - 1));
return Math.min(interval, maxPeriod);
}四个细节:
attempt从 1 开始,attempt++ >= maxAttempts意味着new Retryer.Default(period, maxPeriod, 3)最多 3 次尝试,不是“首次之外再 3 次”。- 没有
Retry-After时按period × 1.5^(attempt-1)退避,并由maxPeriod封顶。 - 有
Retry-After时,等待时间被maxPeriod截断;如果响应给出的时间已经过去(interval < 0),直接返回并立即进入下一次尝试。 - Retryer 不读取 HTTP 方法,也不查幂等表。它只认异常类型。
POST /slow只要抛出RetryableException,就会重试——这正是下一节重复写入的来源。
实验配置 Retryer.Default(20, 50, 3) 给 retryItem:502 + Retry-After: 1 被截成 50ms,连续尝试 3 次;500 不进入 Retryer,只尝试 1 次。两组数同时证明“自定义 Retryer 只处理 RetryableException”和“3 次尝试不是 4 次”。
超时与 HC5:隐藏的 503 重试
Request.Options 与单次尝试
Request.Options 的默认值是连接 10 秒、读 60 秒:
固定源码 Request.java,437–439 行:
public Options() {
this(10, TimeUnit.SECONDS, 60, TimeUnit.SECONDS, true);
}Spring Cloud OpenFeign 通过 spring.cloud.openfeign.client.config.<client>.connectTimeout/readTimeout 覆盖它。实验给 item 配 300ms/2000ms,给 shortItem 和 retryItem 配 300ms/250ms。这两个超时都只作用于一次尝试:连接超时管 TCP 建连,读超时管“等响应/读响应”的阻塞;Feign 没有内建的总 deadline,重试之间的退避、排队时间都不在 Request.Options 里。
连接池、命名客户端与配置覆盖
下面是 Spring Cloud 自动配置示例;configuredRead 匹配 @FeignClient(name="configuredRead")。httpclient/compression 为全局装配前缀,client.config.<name> 为命名客户端;default 为客户端缺省。示例已按固定源码核对,未在这六个手工 builder 测试中启动完整 YAML 绑定。
spring:
cloud:
openfeign:
httpclient:
hc5:
enabled: true
pool-concurrency-policy: strict
pool-reuse-policy: fifo
connection-request-timeout: 100
connection-request-timeout-unit: milliseconds
socket-timeout: 1000
socket-timeout-unit: milliseconds
max-connections: 20
max-connections-per-route: 5
time-to-live: 900
time-to-live-unit: seconds
connection-timeout: 300 # milliseconds; Feign Options may override
follow-redirects: false # transport default; Options below also explicit
client:
config:
configuredRead:
connectTimeout: 300 # milliseconds
readTimeout: 1000 # milliseconds; becomes HC5 response timeout
followRedirects: false
loggerLevel: basic
compression:
request:
enabled: true
mime-types: application/json,text/xml,application/xml
min-request-size: 2048 # bytes; strictly greater than in this version
content-encoding-types: gzip # HC5 rejects deflate request encoding
response:
enabled: true
logging:
level:
study.remoteclient.feign.ConfiguredReadApi: DEBUG| 属性 | Spring Cloud 固定版本默认 | 本次显式测试值/责任 |
|---|---|---|
| max-connections / max-connections-per-route | 200 / 50 | 1 / 1;全池/每 route 连接数 |
| hc5.connection-request-timeout + unit | 3 MINUTES | 100 MILLISECONDS;池租连接等待 |
| hc5.socket-timeout + unit | 5 SECONDS | 1000 MILLISECONDS;socket 基线 |
| httpclient.connection-timeout | 2000 MILLISECONDS | 300ms;transport 默认建连 |
| Feign Options connect/read | 10/60 SECONDS | 300/1000ms;超时测试 read=100ms |
| follow redirects | true | false;请求级 Options 传入 HC5 |
| TTL | 900 SECONDS | 本次未专项设置/验证;不是响应预算 |
/**
* Default value for socket timeout.
*/
public static final int DEFAULT_SOCKET_TIMEOUT = 5;
/**
* Default value for socket timeout unit.
*/
public static final TimeUnit DEFAULT_SOCKET_TIMEOUT_UNIT = TimeUnit.SECONDS;
/**
* Default value for connection request timeout.
*/
public static final int DEFAULT_CONNECTION_REQUEST_TIMEOUT = 3;
/**
* Default value for connection request timeout unit.
*/
public static final TimeUnit DEFAULT_CONNECTION_REQUEST_TIMEOUT_UNIT = TimeUnit.MINUTES;
/**
* Pool concurrency policies.不要使用 pendingAcquireTimeout 代称 HC5 属性,不编造 hc5.response-timeout 属性。response timeout 用 client readTimeout / Request.Options 或自管 RequestConfig。自建 pool 不会自动继承 YAML 的池设置。 本次已实际编译运行的池配置如下,显式 total/per-route、connection request、socket、connect、response 和 disableAutomaticRetries:
private static CloseableHttpClient pooledTransport() {
var pool = PoolingHttpClientConnectionManagerBuilder.create()
.setPoolConcurrencyPolicy(PoolConcurrencyPolicy.STRICT)
.setMaxConnTotal(1).setMaxConnPerRoute(1)
.setDefaultSocketConfig(SocketConfig.custom().setSoTimeout(Timeout.ofMilliseconds(1000)).build())
.setDefaultConnectionConfig(ConnectionConfig.custom()
.setConnectTimeout(Timeout.ofMilliseconds(300))
.setSocketTimeout(Timeout.ofMilliseconds(1000)).build()).build();
return HttpClients.custom().setConnectionManager(pool).disableAutomaticRetries()
.setDefaultRequestConfig(RequestConfig.custom()
.setConnectionRequestTimeout(Timeout.ofMilliseconds(100))
.setResponseTimeout(Timeout.ofMilliseconds(1000)).build()).build();
}完整手工 Feign 装配方法:
private static ConfigurationApi client(ConfigurationUpstream upstream, CloseableHttpClient transport,
Decoder decoder, ErrorDecoder errors, Retryer retryer, int readMillis) {
return Feign.builder().contract(new SpringMvcContract()).client(new ApacheHttp5Client(transport))
.decoder(decoder).errorDecoder(errors).retryer(retryer)
.options(new Request.Options(300, TimeUnit.MILLISECONDS, readMillis, TimeUnit.MILLISECONDS, false))
.target(ConfigurationApi.class, upstream.baseUrl());
}若使用 Spring Cloud 自动创建的池,仅关闭其 transport retry 可在配置中放下面 Bean。已有 CloseableHttpClient 使默认自动配置退让时,需自行在自己的 builder 上关闭;该 Bean 示例本次未做 Spring 装配实跑。
@Bean
org.springframework.cloud.openfeign.clientconfig.HttpClient5FeignConfiguration.HttpClientBuilderCustomizer
noAutomaticTransportRetries() {
return builder -> builder.disableAutomaticRetries();
}关键覆盖关系:ApacheHttp5Client 每次从 client 默认 RequestConfig 复制,再以 Feign Options 覆写 connectTimeout、responseTimeout 和 redirectsEnabled。因此只设 transport response=1000ms,不能阻止 Feign read=100ms 生效;只设 transport redirects=false 也可能被 Options=true 覆盖。 <!-- source-excerpt: {"repo":"OpenFeign/feign","tag":"e90a94b81d7a0dec39eb1f91b00dfe509c49045a","path":"hc5/src/main/java/feign/hc5/ApacheHttp5Client.java","sha256":"579da76372f04dc8ffeb749b66811ce578aff947481052c0c053b7840342d1e7","start":96,"end":112} -->
protected HttpClientContext configureTimeoutsAndRedirection(Request.Options options) {
final HttpClientContext context = new HttpClientContext();
// per request timeouts
final RequestConfig requestConfig =
(client instanceof Configurable
? RequestConfig.copy(((Configurable) client).getConfig())
: RequestConfig.custom())
.setConnectTimeout(options.connectTimeout(), options.connectTimeoutUnit())
.setResponseTimeout(options.readTimeout(), options.readTimeoutUnit())
.setRedirectsEnabled(options.isFollowRedirects())
.build();
context.setRequestConfig(requestConfig);
return context;
}
ClassicHttpRequest toClassicHttpRequest(Request request, Request.Options options)
throws URISyntaxException {实际池用例用 latch 确认首请求占连接,第二个租连接失败,端点计数证明未发 HTTP,释放后恢复。单 route + total/per-route 都为 1 不能分别证明多 route 的独立限额。池限制连接资源,bulkhead 限制业务并发;线程仍可能通过舱壁后在池中排队。默认租连接等 3 分钟不能当 3ms;租连接、建连、响应、退避均需计入剩余总 deadline。readTimeout 不覆盖完整总预算。这六个Feign配置用例不承担四组件链;共享deadline组合另由下文DeadlineCompositionHttpTest验证,容量压测未运行。
重定向与一次调用的多个跳转
Feign Options 默认 followRedirects=true(connect=10s/read=60s)。显式 client.config.<name>.followRedirects=false 或 Options(...,false) 才落实请求级策略。关闭跟随后 3xx 不自动成为正常 ResponseEntity。 <!-- source-excerpt: {"repo":"OpenFeign/feign","tag":"e90a94b81d7a0dec39eb1f91b00dfe509c49045a","path":"core/src/main/java/feign/Request.java","sha256":"77a38fbd983624c56ca34c8206503e495dd529921eb62e52f83629b3cdafb2bf","start":437,"end":439} -->
public Options() {
this(10, TimeUnit.SECONDS, 60, TimeUnit.SECONDS, true);
}反例与取舍:一次 Feign 调用可含多个 redirect hop,它不是 Retryer 的重试计数。跨 host、协议变化、301/302/303/307/308 方法与 body 重放、凭据处理应按 transport 专项验收;本次所有用例显式 false,未跑 3xx 或跨 origin/TLS。
HC5 的隐藏重试与连接复用
真正容易被忽略的是 Apache HC5 自己在 Feign 下面做的重试。feign-hc5 使用 DefaultHttpRequestRetryStrategy,默认是:
- 最多 1 次重试(
maxRetries=1); - 默认间隔 1 秒;
- 只重试 429 和 503;
- I/O 异常分支先排除连接拒绝、读超时等异常,再检查方法幂等性;HTTP 响应状态分支不检查方法。可重复发送的 POST 收到 429/503 也可能重发,不能从“POST”推断不重试。
固定源码 DefaultHttpRequestRetryStrategy.java,199–215 行:
@Override
public boolean retryRequest(
final HttpResponse response,
final int execCount,
final HttpContext context) {
Args.notNull(response, "response");
if (context != null) {
final HttpClientContext clientContext = HttpClientContext.cast(context);
final RequestConfig requestConfig = clientContext.getRequestConfigOrDefault();
final Timeout responseTimeout = requestConfig.getResponseTimeout();
if (responseTimeout != null && defaultRetryInterval.compareTo(responseTimeout) > 0) {
return false;
}
}
return execCount <= this.maxRetries && retriableCodes.contains(response.getCode());
}固定源码 DefaultHttpRequestRetryStrategy.java,170–197 行:
@Override
public boolean retryRequest(
final HttpRequest request,
final IOException exception,
final int execCount,
final HttpContext context) {
Args.notNull(request, "request");
Args.notNull(exception, "exception");
if (execCount > this.maxRetries) {
// Do not retry if over max retries
return false;
}
if (this.nonRetriableIOExceptionClasses.contains(exception.getClass())) {
return false;
}
for (final Class<? extends IOException> rejectException : this.nonRetriableIOExceptionClasses) {
if (rejectException.isInstance(exception)) {
return false;
}
}
if (request instanceof CancellableDependency && ((CancellableDependency) request).isCancelled()) {
return false;
}
// Retry if the request is considered idempotent
return handleAsIdempotent(request);
}固定源码 HttpRequestRetryExec.java,110–133 行:
for (int execCount = 1;; execCount++) {
try {
final ClassicHttpResponse response = chain.proceed(currentRequest, scope);
try {
final HttpEntity entity = request.getEntity();
if (entity != null && !entity.isRepeatable()) {
if (LOG.isDebugEnabled()) {
LOG.debug("{} cannot retry non-repeatable request", exchangeId);
}
return response;
}
if (retryStrategy.retryRequest(response, execCount, context)) {
response.close();
final TimeValue delay = retryStrategy.getRetryInterval(response, execCount, context);
if (LOG.isInfoEnabled()) {
LOG.info("{} {} responded with status {}; " +
"request will be automatically re-executed in {} (exec count {})",
exchangeId, target, response.getCode(), delay, execCount + 1);
}
pause(delay);
currentRequest = ClassicRequestBuilder.copy(scope.originalRequest).build();
} else {
return response;
}最后三行是关键:如果 HC5 的默认重试间隔(1 秒)大于请求的响应超时,它直接放弃 503 重试。 这个判断发生在 Feign 看到响应之前,Feign 的 Retryer、指标和异常处理都感知不到这次“本来可能发生但没有发生”的重试。
实验用两个只有读超时不同的客户端请求同一个永远 503 的 GET 路径:
| 客户端 | 读超时 | 上游尝试次数 | 客户端异常 |
|---|---|---|---|
item | 2000ms | 2 | FeignException.ServiceUnavailable |
shortItem | 250ms | 1 | FeignException.ServiceUnavailable |
item 的第二次尝试来自 HC5:它先等 1 秒,再重发 GET,最终把 503 交给 Feign。shortItem 因为 250ms < 1000ms,HC5 在 retryRequest(response) 里直接返回 false,于是只有 1 次尝试。两边的 Feign Retryer 都是 NEVER_RETRY,差别完全在 HTTP 客户端层。
这带来两个运维推论:
- 调小 readTimeout 可能减少隐藏重试。 同一个 503,在你以为“只是把超时调短”时,实际尝试次数从 2 变成 1;下游负载和恢复行为都会变。
- 调大 readTimeout 可能放大隐藏重试。 HC5 的 1 秒等待仍小于 2 秒响应超时,于是一个 GET 会稳定打两次;如果下游已经过载,这多出来的一次是额外的压力。
反例实跑 POST /fixture/write-status?code=503:默认 NEVER_RETRY 仍得到 attempts=2、effects=2。上游每次先计效果再返回 503,隐藏重试已经造成两次副作用。将 HC5 构造为 HttpClients.custom().disableAutomaticRetries().build(),同时保留 Retryer.NEVER_RETRY,同一失败恢复为 attempts=1、effects=1;随后正常 GET 成功。关闭这一层自动重试不能代替服务端幂等或总预算。
连接复用是同一层的另一个观察:连续两次 itemApi.get 使用同一个远端端口,说明 HC5 连接池复用了 HTTP/1.1 连接。连接池每路由上限、获取连接等待与 keep-alive 策略未运行;这里只证明顺序调用复用,不证明池上限正确。
读超时之后:已提交的写与三次副作用
先看只有默认 Retryer 的写请求:
// 上游先 effects++,再 sleep(900),最后响应
shortItemApi.postSlow("once", 900); // 读超时 250ms客户端在 250ms 抛出 RetryableException,根因是 SocketTimeoutException。上游记录 attempts=1、effects("once")=1:请求已经到达并执行了写入,只是响应没能在客户端超时前回来。客户端“失败”与服务端“已提交”同时成立。
再看给 retryItem 配的自定义 Retryer:
retryItemApi.postSlow("retried", 700); // 读超时 250ms,Retryer.Default(20, 50, 3)结果是 attempts=3、effects("retried")=3。此处没有收到 HTTP 响应,而是读超时;HC5 的 I/O 分支排除 InterruptedIOException(包括 SocketTimeoutException)。三次来自 Feign 的 SynchronousMethodHandler 捕获了包装出的 RetryableException,Retryer 又不看 HTTP 方法。三次尝试各执行一次写入,业务侧产生三个副作用。
正确的处理顺序不是“给写请求加更聪明的 Retryer”,而是:
- 写路径默认不自动重试。 只有明确幂等的操作才进入重试白名单。
- 在请求发出前生成幂等键,把键与业务操作一起持久化;服务端按键去重,重复请求返回第一次的结果,而不是再执行一次。
- 超时后先查状态,再决定是否重试。 用
operation_id或业务单号查询“已提交/处理中/失败”,不要用“再发一次”代替查询。 - 把重试预算放在最外层。 如果底层 HC5 已经可能重试、Feign Retryer 又重试、外层 SCB 再重试,三层叠乘后的尝试次数要在容量与 deadline 里算清。
本节使用的 Feign 上游只负责每次 POST 的副作用,不实现幂等;幂等去重与状态查询由下一节的 IdempotentUpstream 单独演示。真实系统应把“幂等记录”和“业务效果”放在同一事务,或用状态机显式记录 PENDING/COMMITTED/FAILED,否则进程在两者之间崩溃仍会留下不一致。
维护状态与替代路径
feature-complete 与迁移四件事
OpenFeign 已进入维护模式,官方建议迁移到 Spring HTTP Service Clients。迁移不是把注解换个名字就结束,至少要重新处理四件事:
- 契约:Feign 用
SpringMvcContract解释@GetMapping;HTTP Service Client 用@GetExchange等org.springframework.web.service.annotation注解。 - 错误解码:Feign 的
ErrorDecoder没有直接对应物,需要在RestClient的defaultStatusHandler或调用方显式处理状态码。 - 重试:Feign 的
Retryer没有直接对应物;要用RestClient的请求工厂、外层 Resilience4j Retry 或自己实现的装饰器。 - 熔断:OpenFeign 有
spring.cloud.openfeign.circuitbreaker.enabled的内建集成;迁移后通常直接用 Spring Cloud CircuitBreaker 或 R4j 装饰调用。
替代路径在本实验里用同一份 loopback 契约做了真实调用:
RestClient restClient = RestClient.builder().baseUrl(upstream.baseUrl()).build();
HttpItemApi api = HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient))
.build()
.createClient(HttpItemApi.class);
assertEquals(new Item(7, "item-7"), api.get(7, "synthetic"));HttpItemApi 只有 @GetExchange("/items/{id}") 与 @RequestHeader,上游收到 1 次请求并返回 item-7。这证明“HTTP Service Client 能替代声明式调用”,不证明迁移后的错误映射、重试语义和生产可观测性与 Feign 等价;这些需要按项目逐项验收。实验也没有启用 OpenFeign 的内建 CircuitBreaker 集成,避免把两层熔断叠在一起而误判状态机行为。
Spring Cloud CircuitBreaker:工厂、执行器与 fallback
工厂执行器与 fallback 包装
Spring Cloud CircuitBreaker 提供的是抽象工厂,不是具体状态机。Resilience4j 实现的关键默认值是:工厂持有一个 Executors.newCachedThreadPool():
固定源码 Resilience4JCircuitBreakerFactory.java,62–64 行:
private ExecutorService executorService = Executors.newCachedThreadPool();
private Function<String, ExecutorService> groupExecutorServiceFactory = group -> Executors.newCachedThreadPool();存在执行器时,run 把任务提交到线程池并按配置套 TimeLimiter;是否有 Bulkhead provider、TimeLimiter 是否禁用共同决定实际链与异常包装。以下是无 Bulkhead provider 的连续源码分支:
固定源码 Resilience4JCircuitBreaker.java,104–140 行:
if (bulkheadProvider != null) {
if (executorService != null) {
Supplier<Future<T>> futureSupplier = () -> executorService.submit(toRun::get);
/* conditionally wrap in time-limiter */
Callable<T> timeLimitedCall = timeLimiter
.map(tl -> TimeLimiter.decorateFutureSupplier(tl, futureSupplier))
.orElse(() -> futureSupplier.get().get());
Callable<T> bulkheadCall = bulkheadProvider.decorateCallable(this.groupName, tags, timeLimitedCall);
Callable<T> circuitBreakerCall = io.github.resilience4j.circuitbreaker.CircuitBreaker
.decorateCallable(defaultCircuitBreaker, bulkheadCall);
return getAndApplyFallback(circuitBreakerCall, fallback);
}
else {
Callable<T> bulkheadCall = bulkheadProvider.decorateCallable(this.groupName, tags, toRun::get);
Callable<T> circuitBreakerCall = io.github.resilience4j.circuitbreaker.CircuitBreaker
.decorateCallable(defaultCircuitBreaker, bulkheadCall);
return getAndApplyFallback(circuitBreakerCall, fallback);
}
}
else {
if (executorService != null) {
Supplier<Future<T>> futureSupplier = () -> executorService.submit(toRun::get);
/* conditionally wrap in time-limiter */
Callable<T> restrictedCall = timeLimiter
.map(tl -> TimeLimiter.decorateFutureSupplier(tl, futureSupplier))
.orElse(() -> futureSupplier.get().get());
Callable<T> callable = io.github.resilience4j.circuitbreaker.CircuitBreaker
.decorateCallable(defaultCircuitBreaker, restrictedCall);
return getAndApplyFallback(callable, fallback);
}
else {
Supplier<T> decorator = io.github.resilience4j.circuitbreaker.CircuitBreaker
.decorateSupplier(defaultCircuitBreaker, toRun);
return getAndApplyFallback(decorator, fallback);
}
}getAndApplyFallback 捕获 Throwable 后直接调用 fallback:
固定源码 Resilience4JCircuitBreaker.java,152–159 行:
private static <T> T getAndApplyFallback(Callable<T> callable, Function<@Nullable Throwable, T> fallback) {
try {
return callable.call();
}
catch (Throwable t) {
return fallback.apply(t);
}
}本实验 classpath 上的 Bulkhead provider 包装链让 fallback 收到 ExecutionException;这是本次装配的观察,不能外推所有工厂。没有 Bulkhead provider、启用 TimeLimiter 时,TimeLimiter 会解包 Future 的 ExecutionException 并重新抛出业务异常;禁用 TimeLimiter 时又可能保留 Future 包装。短路拒绝在业务提交前发生,直接是 CallNotPermittedException。实验的 fallback 先记录原始类型、再解包描述异常:
var circuitBreaker = circuitBreakerFactory.create("lab");
AtomicReference<Class<?>> raw = new AtomicReference<>();
circuitBreaker.run(() -> { throw new IllegalStateException("boom"); },
throwable -> { raw.set(throwable.getClass()); return describe(throwable); });
// raw.get() == ExecutionException.class;describe 解包 getCause()观察结果是:前两次调用都走 fallback,原始类型都是 java.util.concurrent.ExecutionException,解包后得到 IllegalStateException:boom;窗口达到阈值后,第三次调用在进入业务代码前就得到 CallNotPermittedException 的 fallback,原始类型不再被 Future 包装;等待 150ms 后,第四次调用返回 ok。
fallback 与业务不变量
这也说明 fallback 是控制流的一部分,不是异常日志:它可以返回缓存、默认值或 PENDING,但不能假装写成功。
自定义 Customizer<Resilience4JCircuitBreakerFactory> 必须真的被 Spring 装配。实验初始把 @TestConfiguration 嵌套在测试类里,又用 @SpringBootTest(classes = ...) 显式指定配置,结果 customizer 没有被扫描,全部走默认配置;把 LabConfig.class 加入 classes 后才得到上述状态转换。生产代码里同样要检查 customizer 是否被组件扫描或显式注册,不能只看配置类的代码存在。
Resilience4j 状态机:窗口、阈值与半开
默认窗口与半开
Resilience4j 的默认值比很多人的直觉保守:
固定源码 CircuitBreakerConfig.java,42–50 行:
public static final int DEFAULT_FAILURE_RATE_THRESHOLD = 50; // Percentage
public static final int DEFAULT_SLOW_CALL_RATE_THRESHOLD = 100; // Percentage
public static final int DEFAULT_WAIT_DURATION_IN_OPEN_STATE = 60; // Seconds
public static final int DEFAULT_PERMITTED_CALLS_IN_HALF_OPEN_STATE = 10;
public static final int DEFAULT_MINIMUM_NUMBER_OF_CALLS = 100;
public static final int DEFAULT_SLIDING_WINDOW_SIZE = 100;
public static final int DEFAULT_SLOW_CALL_DURATION_THRESHOLD = 60; // Seconds
public static final int DEFAULT_WAIT_DURATION_IN_HALF_OPEN_STATE = 0; // Seconds. It is an optional parameter
public static final SlidingWindowType DEFAULT_SLIDING_WINDOW_TYPE = SlidingWindowType.COUNT_BASED;固定源码 CircuitBreakerConfig.java,77–94 行:
private float failureRateThreshold = DEFAULT_FAILURE_RATE_THRESHOLD;
private int permittedNumberOfCallsInHalfOpenState = DEFAULT_PERMITTED_CALLS_IN_HALF_OPEN_STATE;
private int slidingWindowSize = DEFAULT_SLIDING_WINDOW_SIZE;
private SlidingWindowType slidingWindowType = DEFAULT_SLIDING_WINDOW_TYPE;
private SlidingWindowSynchronizationStrategy slidingWindowSynchronizationStrategy =
DEFAULT_SLIDING_WINDOW_SYNCHRONIZATION_STRATEGY;
private int minimumNumberOfCalls = DEFAULT_MINIMUM_NUMBER_OF_CALLS;
private boolean writableStackTraceEnabled = DEFAULT_WRITABLE_STACK_TRACE_ENABLED;
private boolean automaticTransitionFromOpenToHalfOpenEnabled = false;
private transient IntervalFunction waitIntervalFunctionInOpenState = IntervalFunction
.of(Duration.ofSeconds(DEFAULT_WAIT_DURATION_IN_OPEN_STATE));
private transient Function<Either<Object, Throwable>, TransitionCheckResult> transitionOnResult
= DEFAULT_TRANSITION_ON_RESULT;
private float slowCallRateThreshold = DEFAULT_SLOW_CALL_RATE_THRESHOLD;
private Duration slowCallDurationThreshold = Duration
.ofSeconds(DEFAULT_SLOW_CALL_DURATION_THRESHOLD);
private Duration maxWaitDurationInHalfOpenState = Duration
.ofSeconds(DEFAULT_WAIT_DURATION_IN_HALF_OPEN_STATE);默认是 COUNT_BASED 窗口 100、最少 100 次调用、失败率 50%、OPEN 等 60 秒、HALF_OPEN 放行 10 次、无后台自动转换。这些默认值只做了源码核对:实验没有用默认配置跑满 100 次调用,状态机测试使用的是 4 次窗口、2 次最少调用、150ms OPEN 与 1 次半开。默认值意味着:
- 低流量服务可能永远凑不齐 100 次调用,CB 一直 CLOSED,看起来“没生效”;
- OPEN 后如果没有新请求,状态不会自动跳到 HALF_OPEN;
tryAcquirePermission在等待期过后的第一个请求里触发转换; - HALF_OPEN 默认放行 10 次,单个失败通常仍处在 HALF_OPEN;这 10 次填满窗口后失败率和慢调用率均低于各自阈值才关闭,不要求连续成功。实验把它改成 1 次,才能在小样本里观察转换。
状态转换的真实代码在 ClosedState 与 OpenState:
固定源码 CircuitBreakerStateMachine.java,596–600 行:
@Override
public void onError(long duration, TimeUnit durationUnit, Throwable throwable) {
// CircuitBreakerMetrics is thread-safe
checkIfThresholdsExceeded(circuitBreakerMetrics.onError(duration, durationUnit));
}固定源码 CircuitBreakerStateMachine.java,708–724 行:
@Override
public boolean tryAcquirePermission() {
// Thread-safe
if (clock.instant().isAfter(retryAfterWaitDuration)) {
toHalfOpenState();
// Check if the call is allowed to run in HALF_OPEN state after state transition
// super.tryAcquirePermission() doesn't work right that's why the code is copied
boolean callPermitted = stateReference.get().tryAcquirePermission();
if (!callPermitted) {
publishCallNotPermittedEvent();
circuitBreakerMetrics.onCallNotPermitted();
}
return callPermitted;
}
circuitBreakerMetrics.onCallNotPermitted();
return false;
}实验配置窗口 4、最少 2、失败率 50%、OPEN 等 150ms、HALF_OPEN 放行 1 次:
- 第 1 次失败后仍是 CLOSED(最少调用数未到);
- 第 2 次失败后 OPEN,
failedCalls=2; - OPEN 期间调用被拒绝,
notPermitted=1,业务代码没有执行; - 等 200ms 后第一次探测失败,立刻重新 OPEN;
- 再等 200ms 后探测成功,CLOSED。
半开窗口同样按失败率和慢调用率判断,不能把一次成功等同健康;对应固定源码:
private void checkIfThresholdsExceeded(Result result) {
if (Result.hasExceededThresholds(result) && isHalfOpen.compareAndSet(true, false)) {
transitionToOpenState();
}
if (result == BELOW_THRESHOLDS && isHalfOpen.compareAndSet(true, false)) {
transitionToClosedState();
}
} private Result checkIfThresholdsExceeded(Snapshot snapshot) {
float failureRateInPercentage = getFailureRate(snapshot);
float slowCallsInPercentage = getSlowCallRate(snapshot);
if (failureRateInPercentage == -1 || slowCallsInPercentage == -1) {
return Result.BELOW_MINIMUM_CALLS_THRESHOLD;
}
if (failureRateInPercentage >= failureRateThreshold
&& slowCallsInPercentage >= slowCallRateThreshold) {
return Result.ABOVE_THRESHOLDS;
}
if (failureRateInPercentage >= failureRateThreshold) {
return Result.FAILURE_RATE_ABOVE_THRESHOLDS;
}
if (slowCallsInPercentage >= slowCallRateThreshold) {
return Result.SLOW_CALL_RATE_ABOVE_THRESHOLDS;
}
return Result.BELOW_THRESHOLDS;时间窗口、慢成功与完整配置
COUNT_BASED 的 size 是最近完成调用数,TIME_BASED 的 size 是秒;时间窗口按整秒桶滚动,过期桶从聚合中减去,窗口内达到 minimumNumberOfCalls 才有失败率判定。它不限制同时执行的请求,也不会取消旧任务。半开始终使用独立的计数窗口,有效最小样本数是 min(minimumNumberOfCalls, permittedNumberOfCallsInHalfOpenState),所以自定义最小样本更小时可以提前判定,并非任何配置都要等所有许可用满。
以下是 Resilience4j 2.3.0 的完整程序化配置,单位由 Duration 与窗口类型明确:
CircuitBreakerConfig policy = CircuitBreakerConfig.custom()
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.TIME_BASED)
.slidingWindowSize(10) // 最近10秒,不是10次
.minimumNumberOfCalls(20) // 样本不足不判定
.failureRateThreshold(50) // 百分比
.slowCallDurationThreshold(Duration.ofMillis(300))
.slowCallRateThreshold(60) // 慢成功也计入慢调用
.waitDurationInOpenState(Duration.ofSeconds(5))
.permittedNumberOfCallsInHalfOpenState(4)
.automaticTransitionFromOpenToHalfOpenEnabled(false)
.build();
CircuitBreaker breaker = CircuitBreaker.of("orders", policy);实际公共用例 ResiliencePolicyTest 使用1秒时间窗、最少2次、慢阈值40ms/50%:先失败一次仍CLOSED,用受控 Clock 向前推进2000ms让旧桶过期,快成功使失败数归零(不是实际等待1秒),再80ms慢成功把慢调用率推到50%,即使失败率0%也OPEN。不能把HTTP200或业务成功当成熔断器必然健康;慢调用占着线程和连接,同样消耗容量。计数窗口适合按次数稳定抽样,时间窗口适合固定观察时长;低流量仍需校正最小样本,不能为了迅速打开而让单个噪声占满样本。
恢复:探测、放量与关闭
这条序列同时证明“阈值不是按单次失败判断”“OPEN 期间不是业务异常”“本实验半开放行数为1,一次失败即达到阈值并重新打开”。恢复不是“等 60 秒就自动好”:它需要真实探测,且默认要等 HALF_OPEN 的 10 次窗口填满、失败率和慢调用率均低于各自阈值才关闭。
Retry、Bulkhead、TimeLimiter:组合的顺序
Retry 与两种装饰顺序
Retry 的默认值是 3 次尝试、固定等待 500ms:
固定源码 RetryConfig.java,39–42 行:
public static final long DEFAULT_WAIT_DURATION = 500;
public static final int DEFAULT_MAX_ATTEMPTS = 3;
private static final IntervalFunction DEFAULT_INTERVAL_FUNCTION = numOfAttempts -> DEFAULT_WAIT_DURATION;
private static final IntervalBiFunction DEFAULT_INTERVAL_BI_FUNCTION = IntervalBiFunction.ofIntervalFunction(DEFAULT_INTERVAL_FUNCTION);R4j Retry 不解析 HTTP Retry-After,除非自定义 IntervalBiFunction;它按异常类型/结果谓词决定是否重试,并在每次失败后 sleep。它也不会读 HTTP 方法或幂等表——这一点和 Feign Retryer 相同。
配置异常白名单和退避时,先决定哪些失败具有重试价值:
RetryConfig policy = RetryConfig.custom()
.maxAttempts(3)
.intervalFunction(IntervalFunction.ofExponentialBackoff(20, 2.0))
.retryExceptions(IOException.class)
.ignoreExceptions(IllegalArgumentException.class,
CallNotPermittedException.class, BulkheadFullException.class)
.build();
Retry retry = Retry.of("orders", policy);该程序化例子使用20ms、40ms退避,最大3次尝试;无抖动,仅用于局部演示。实际 ResiliencePolicyTest 验证 IOException 两次后恢复为ok、真实间隔不少于20/40ms;非法参数只尝试1次。名单匹配 Throwable 类型,不会自动穿透任意业务包装,也不理解HTTP方法;本页幂等HTTP用例显式检查 UncheckedIOException 的 cause。拒绝/限额错误通常应向上游反馈并减载,盲重试只会争抢下一张许可。
装饰顺序与尝试放大
装饰顺序决定 CB 看见什么。实验用同一段失败逻辑做了两组对照:
// Retry 在外,CB 在内
Retry.decorateSupplier(retry, CircuitBreaker.decorateSupplier(circuitBreaker, this::fail));
// 结果:CB 记录 3 次失败// CB 在外,Retry 在内
CircuitBreaker.decorateSupplier(circuitBreaker, Retry.decorateSupplier(retry, this::fail));
// 结果:CB 只记录 1 次失败这不是“哪种写法更标准”的问题,而是两个不同的语义:
- Retry 在外:每个尝试都经过 CB,CB 看到真实的下游尝试数。适合“CB 要按每次尝试的失败率决策”,但 CB OPEN 后 Retry 仍可能继续尝试,通常要把
CallNotPermittedException加入 Retry 的忽略列表。 - CB 在外:CB 只看到用户调用的最终结果。适合“CB 保护的是用户请求的成功率”,但重试会把多次失败掩盖成一次最终失败,若重试最终成功则记录成功,否则只记录一次复合失败;失败率是否更低取决于最终结果分布,不能仅由失败计数推出。
Bulkhead 并发隔离
Bulkhead 控制并发,不是控制速率:
Bulkhead.of("lab", BulkheadConfig.custom()
.maxConcurrentCalls(1)
.maxWaitDuration(Duration.ZERO)
.build());第一个调用占用唯一许可并阻塞;第二个调用在 maxWaitDuration=0 下立刻抛 BulkheadFullException,而不是排队。观察时 availableConcurrentCalls 从 0 回到 1。默认值是 25 个并发、等待 0;R4j 还有线程池隔离舱(ThreadPoolBulkhead),它用独立队列与线程池,拒绝语义与信号量隔离舱不同。不要把“连接池上限”当成 Bulkhead:连接池限制的是到某个目标的连接数,Bulkhead 限制的是一段代码的并发执行数。
TimeLimiter 与取消边界
TimeLimiter 只负责让调用方在超时后不再等:
固定源码 TimeLimiterImpl.java,41–70 行:
@Override
public <T, F extends Future<T>> Callable<T> decorateFutureSupplier(Supplier<F> futureSupplier) {
return () -> {
Future<T> future = futureSupplier.get();
try {
T result = future.get(getTimeLimiterConfig().getTimeoutDuration().toMillis(),
TimeUnit.MILLISECONDS);
onSuccess();
return result;
} catch (TimeoutException e) {
TimeoutException timeoutException = TimeLimiter.createdTimeoutExceptionWithName(name, e);
onError(timeoutException);
if (getTimeLimiterConfig().shouldCancelRunningFuture()) {
future.cancel(true);
}
throw timeoutException;
} catch (ExecutionException e) {
Throwable t = e.getCause();
if (t == null) {
onError(e);
throw e;
}
onError(t);
if (t instanceof Error) {
throw (Error) t;
}
throw (Exception) t;
}
};
}实验分两条路径,避免把“超时”与“取消”混为一谈:
executeCompletionStage:100ms 超时,任务睡眠 300ms 后才计数。调用方在 100ms 收到CompletionException,cause 是java.util.concurrent.TimeoutException;但 2 秒内效果闩锁仍然打开,说明任务已经执行到了计数点。这条 API 的Timeout只调用future.completeExceptionally,不读取cancelRunningFuture,所以它证明的是“超时只结束等待,不停止任务”。decorateFutureSupplier/executeFutureSupplier:cancelRunningFuture=true,任务先计数再睡眠 300ms。100ms 超时后Future.isCancelled()=true,但效果已经发生。取消 Future 只是取消客户端的等待并尝试中断任务,不会回滚已经提交的工作。
如果这段代码写的是数据库或消息,超时只意味着“你不知道结果”,不意味着“没有发生”;即使收到中断,已经执行的写入也可能保留。
放大与预算:2 × 3 × 2
尝试乘法与预算倒算
把三层配置写成“每层最多几次尝试”,最坏尝试数是乘法:
网关 2 次尝试 × 服务 3 次尝试 × 客户端 2 次尝试 = 12 次真实请求这里每个数字都是尝试数,不是“重试次数”。如果配置项写的是“重试 2/3/2 次”,尝试数要各加 1,变成 3 × 4 × 3 = 36 次。很多容量事故就出在这个 +1 被忽略。
预算要倒着算。设单次尝试 500ms、退避总和 700ms、12 次尝试:
最坏耗时 ≈ 12 × 500ms + 退避 700ms = 6.7s如果客户端 deadline 只有 3s,客户端会在网关还在重试时先断开;用户看到“超时”,网关日志还在“第 3 次重试”。每一层都必须比它的调用方短,而且总预算要包含排队、建连、序列化与退避,不只是 readTimeout。
TimeLimiter 和 deadline 也不能替代“发送前检查”:
- 请求已经发出后,客户端取消只停止等待,不能撤回请求;
- 重试前应检查剩余预算,剩余时间不足以完成一次尝试时不要再发;
- 重试要加退避与抖动,避免所有实例在同一时刻重试;
- 过载时重试是额外负载,熔断/限流要优先于重试。
一个可执行的分配方式是:客户端总 deadline 3s;网关总预算 2.5s;服务层单次尝试 500ms、最多 3 次;最内层 HTTP 客户端不再做隐藏重试,或把 HC5 的默认重试显式关掉。任何一层出现“隐藏的第二次尝试”,预算都要重新算。
四层实际组合见公开 DeadlineCompositionHttpTest:Retry(CB(TimeLimiter(Bulkhead(HTTP))))。每次准备提交前以 System.nanoTime 比较同一个绝对 deadline 的剩余时间;必须还能容纳完整500ms发送预算才允许请求。实验总预算700ms,TimeLimiter 400ms,重试等待100ms,上游延迟900ms:第一次已发出并超时,第二次剩余不足500ms,在发送前抛 InsufficientDeadline 且不重试,上游只收到1次。随后恢复上游,为恢复操作重新分配700ms总预算,经过同一四层容错链发送1次并成功,失败取消后与成功后均检查Bulkhead许可归还为1。没有该检查的RED会继续重试并以TimeoutException耗尽。此例是本进程内共享预算,不是网关跨服务传播deadline已验证。
完整可复制代码、正常/失败断言与清理在DeadlineCompositionHttpTest。TimeLimiter取消不回滚在途效果;Bulkhead负责并发,而不决定业务幂等。恢复还需共享预算:设下游可持续200请求/s,正常流量150/s,积压回放30/s,则重试只剩20/s。按每次尝试扣额度,不按用户请求扣一次;正常流量升到180/s时必须将积压或重试相应压低。优先保证新鲜请求还是先清积压是业务选择,应以超期丢弃、最大等待和效果对账结果验证。这个200/150/30/20算例是容量模型,未做200QPS压测。
幂等恢复:稳定键与新键的差别
稳定键、新键与状态查询
最后一个实验直接模拟“服务端已提交、客户端读超时”:
IdempotentUpstream要求Idempotency-Key;第一次收到某个键时,把结果写进results、增加效果计数、睡眠 500ms;- 重复键直接返回
results里的已存结果,不再增加效果; GET /operations/{key}用于状态查询:已提交的键返回COMMITTED,未知键返回 404/UNKNOWN;- 客户端
HttpClient超时 150ms,R4j Retry 最多 3 次、间隔 30ms,只对HttpTimeoutException重试。
稳定键的结果:
第一次尝试:超时,但上游已记录 COMMITTED,effects=1
第二次尝试:同一个键,直接返回已存结果
最终:attempts=2,effects=1,响应 status=COMMITTED每次尝试换新键的结果:
第 1/2/3 次尝试:各自第一次见到新键,各执行一次,各睡眠 500ms
最终:attempts=3,effects=3,仍然超时失败状态查询的结果:
GET /operations/op-status → 200 COMMITTED
GET /operations/op-missing → 404 UNKNOWN这三组观察说明:
- 幂等键的价值不是“让请求更快”,而是让重复请求不产生第二个效果。 稳定键让第二次尝试变成一次查询/重放,业务效果仍然是 1。
- 没有幂等键的自动重试会把“未知结果”变成“确定的多重执行”。 新键三次尝试就是三次独立操作,哪怕每次都超时,效果已经发生。
- 重试成功也不能证明只执行了一次。 稳定键的第二次尝试成功,是因为服务端记住了键;不是因为 Retry 本身安全。
- 状态查询得到权威 COMMITTED 等终态时才能收敛 UNKNOWN;404 UNKNOWN 仍然未知,不能据此断言从未执行。 本实验的
GET /operations/{key}能区分COMMITTED与UNKNOWN;但清理持久化的PENDING、进程崩溃窗口和失败后的补偿仍未运行。
恢复后的效果计数与 UNKNOWN
生产实现要把幂等记录与业务效果绑定:
- 请求带业务唯一的
operation_id/idempotency_key; - 服务端在同一个事务里写“操作记录 + 业务效果”,或先写
PENDING再异步推进; - 重复键返回已有状态,而不是重新执行;
- 超时后客户端按键查询,状态是
COMMITTED就取结果,FAILED才重试,PENDING继续等或转人工; - fallback 只能返回
PENDING/UNKNOWN或缓存值,不能返回伪造的“成功”。
实验的上游是简化实现:结果在睡眠前写入,效果计数在睡眠前增加,并支持按 operation_id 查询 COMMITTED/UNKNOWN;它没有模拟“写效果成功、写幂等记录失败”的崩溃窗口,也没有清理持久化的 PENDING 记录。真实系统必须把这些状态交给事务或状态机处理,不能照搬这段测试代码。
持久 PENDING、断响应与同事务恢复
第二层实验 DurableRecoveryHttpTest 使用 H2 2.4.240 文件库,仅在每个测试的 JUnit TempDir 内,完全不连接本机 MySQL。先独立事务接收 operation_id 为 PENDING;完成事务 SELECT ... FOR UPDATE 锁定该行,若已 COMMITTED 则返回旧结果,否则写 effects 和终态后一次 commit。effects.operation_id 也是唯一主键,状态查询用单条SQL取得一致快照。以下为教学schema,完整DDL和HTTP装配见DurableUpstream:
CREATE TABLE operations (
operation_id VARCHAR(100) PRIMARY KEY,
state VARCHAR(16) NOT NULL CHECK (state IN ('PENDING','COMMITTED'))
);
CREATE TABLE effects (
operation_id VARCHAR(100) PRIMARY KEY REFERENCES operations(operation_id),
amount INT NOT NULL CHECK (amount = 1)
);真实故障时序有两条:
- POST 提交效果后在发送HTTP响应头之前关闭连接。调用方实际得到IOException,记录UNKNOWN;用同一 operation_id 通过HTTP查询,确认 COMMITTED:1;原键重放仍 COMMITTED:1。这里UNKNOWN是调用方知识状态,不是服务端操作表的终态。
- 已持久PENDING后,在效果INSERT与终态UPDATE之间显式rollback。HTTP查询仍PENDING:0;关闭所有连接和HTTP线程后重开同一文件库,PENDING和旧COMMITTED均保留;POST /recover 使用相同锁和完成事务将其推进COMMITTED:1,重复恢复仍1。另有12个同键POST与1个恢复请求并发,最终仍只有1个效果。
故意把效果提前commit的反例会得到PENDING:1,行为测试经HTTP即能发现错误。正确实现将效果与终态绑定,避免旧内存演示先发布COMMITTED再计effects的可见性窗口。客户端须保存稳定键并查权威终态;服务端须对PENDING设恢复责任、退避和可观测期限。404 UNKNOWN仍需查询正确租户/分片、确认记录保留期并对账,绝不自动推断为未执行。
本实验验证单进程H2、同文件优雅关闭重开与显式回滚,不是操作系统进程崩溃、断电或跨实例恢复;没有请求指纹冲突/键过期清理、生产数据库迁移或外部支付效果原子性。PENDING入库与效果同库适合可控本地事务;支付/邮件在库外时,须用外部幂等键、outbox或对账状态机承接,不能把本地唯一约束外推为跨系统exactly-once。恢复流量还须与正常/重试共享前节容量预算,而不是无界扫描后并发全部重放。
观测、恢复与旧内容归属
指标分工与恢复顺序
客户端层的观测至少要把四类数分开:
| 观测 | 说明 | 不能回答 |
|---|---|---|
| 请求数 / 尝试数 | Feign 调用次数与真实 HTTP 尝试次数 | 业务是否只执行一次 |
FeignException 状态分布 | 4xx/5xx 的来源 | 超时后服务端是否已提交 |
CB getNumberOfFailedCalls | 进入 CB 的失败次数 | 被 Retry 掩盖的每次尝试 |
CB getNumberOfNotPermittedCalls | OPEN 期间被拒绝的次数 | 下游是否真的恢复 |
getFailureRate | 当前窗口的失败率 | 低流量下是否达到最小调用数 |
| effect 计数 / 幂等表 | 业务是否重复执行 | 请求是否最终成功 |
R4j 的 CircuitBreaker.Metrics 提供失败数、成功数、慢调用数、未许可数与失败率;状态事件可以接日志或 Micrometer。实验在两次失败后断言 getFailureRate()=100.0,并读取 notPermitted=1;Micrometer/Prometheus 导出与 Feign 客户端尝试指标没有运行,这里只验证进程内指标对象。关键是不要把 CallNotPermittedException 计成“下游失败”:它表示保护生效,不是下游又失败了一次。反过来,CB 的失败率低也不代表没有重试放大——如果 CB 在 Retry 外层,CB 只看到最终结果,尝试数在 Feign/HC5 的日志里。
恢复顺序应当是:
- 确认下游业务健康,不是端口通;
- 先用小流量探测(HALF_OPEN 放行数从小开始),观察错误率与延迟;
- 逐步放量,保留熔断与总预算;
- 最后恢复重试;恢复期间可以先关掉重试,避免把刚恢复的下游再次打满;
- 检查 effect/幂等表,确认没有重复写入与未清理的
PENDING。
旧内容的归属不变:超时、重试、熔断、限流与降级保留 deadline、UNKNOWN、27 次放大、熔断状态机与恢复的通用模型;本页只补 Spring 客户端/容错库的装配与真实故障。HTTP 客户端保留 RestClient、HTTP Interface 与默认工厂;本页只讲 Feign 与迁移对照。Gateway 可靠性保留网关超时/限流/观测;Cloud 发现与负载均衡保留实例目录与摘流;本页只讲 Feign 的 LB 接入点。重复消费、幂等与顺序消息保留幂等/outbox/重复消费权威;本页只在 EX2 恢复实验中使用其术语并回指。Dubbo 的 Failover 与本地 future 归Dubbo 调用链;Sentinel 归 U37,配置中心归 U34,平台发现/凭据适配归 U35,Nacos 实现归 U40。
未运行且不能由本页证明的:真实注册中心与 LB 集群、Feign 与 Sentinel/Resilience4j 的生产组合、TLS/mTLS、跨进程网络分区、生产网关、容量与吞吐压测、HC5 连接池上限与排队、OpenFeign 内建 CircuitBreaker 集成、R4j 线程池隔离舱容量/拒绝/排队专项与 Micrometer 实际指标导出。这些按源码/配置推演或明确未运行标注,不用“配置写好了”冒充“运行通过”。
实验读法
本页对应两个模块的 35 个测试(Feign 20、容错 15),全部是真实 socket 或真实状态机对象。建议按下面的观察行复读:
U33 feignProxy=...、U33 feignBinding=...:HC5 客户端存在,路径/头/JSON 绑定正确。U33 feignErrors=...、U33 feignRetryer=...:400/500 各 1 次;429 被 HC5 隐藏重试为 2 次;502+Retry-After在自定义 Retryer 下 3 次,默认 Retryer 1 次。U33 feignHiddenRetry=...与U33 feignHiddenRetrySuppressed=...:同一个 503 在 2000ms 与 250ms 读超时下分别 2 次/1 次。U33 feignReadTimeout=...、U33 feignRetryWrite=...:读超时后 1 次效果;自定义 Retryer 重试写请求产生 3 次效果。U33 feignConnect=...、U33 feignPool=...:连接拒绝根因ConnectException;两次调用复用同一远端端口。U33 httpServiceClient=...:RestClient+HttpServiceProxyFactory对同一契约的真实替代调用。U33 cbStateMachine=...、U33 scbFallback=...:CLOSED→OPEN→HALF_OPEN→CLOSED,两次失败后失败率 100.0;fallback 原始包装是ExecutionException,短路拒绝直接是CallNotPermittedException。U33 decorationOrder=...:Retry 在外 3 次失败,CB 在外 1 次复合失败。U33 bulkhead=...、U33 timeLimiter=...、U33 timeLimiterFuture=...:并发拒绝;executeCompletionStage超时后任务继续,decorateFutureSupplier取消 Future 但已提交效果不回滚。U33 idempotentRecovery=...、U33 idempotentStatus=...:稳定键 2 次尝试 1 次效果;新键 3 次尝试 3 次效果;GET /operations/{key}区分COMMITTED与UNKNOWN。
tests=35 的计数、退出码、JDK/Maven/OS 版本、依赖版本、每个源文件的 SHA-256 与未运行清单都写入证据 JSON;本页只引用一次完整运行的观察,不把多次运行的数字混在一起。
闭卷验收
本页 32 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。