代码背景来自开源项目 meirongdev/shop,Java 25 + Spring Boot 3.5 + Spring Cloud 微服务电商平台。

本文讲设计——starter 内部为什么这么写。如果你的目标是集成 starter,跟着步骤跑通第一个客户端,请直接看 shop-starter-http-client 集成指南

配套阅读:Spring Boot 3.5 微服务 tracing 为什么会断链

1. 背景#

项目是典型的 BFF(Backend For Frontend)模式:

graph LR C[Client] --> G[api-gateway] G --> BB[buyer-bff] G --> SB[seller-bff] BB --> M[marketplace] BB --> O[order] BB --> L[loyalty] BB --> P[promotion] SB --> M SB --> O

每个 BFF 都要向多个 domain service 发出站请求。shop-starter-http-client 把以下四件事提取到共享 starter,避免每个 BFF 重复实现:

关注点 由谁负责
身份传播 TracingHeaderInterceptor:Micrometer Baggage → 直接 HTTP header
错误语义保留 SharedDownstreamErrorHandler:4xx/5xx → DownstreamException(带原始状态码)
可观测性 DownstreamServiceObservationConvention:metric / span 加 downstream.service tag
代理工厂 ShopHttpExchangeSupport:统一构造 @HttpExchange 代理

2. 架构总览#

graph TB subgraph starter["shop-starter-http-client"] SP["ShopHttpExchangeSupport
代理工厂"] TI["TracingHeaderInterceptor
身份传播"] EH["SharedDownstreamErrorHandler
错误语义"] OC["DownstreamServiceObservationConvention
metric tag"] end SP --> TI SP --> EH SP --> OC SP -- builds --> Proxy[("@HttpExchange 代理")] BFF[buyer-bff / seller-bff] -- "@Bean inject" --> Proxy Proxy -- "HTTP" --> DS[domain services] Tracer((Micrometer Tracer)) -.-> TI Tracer -.-> EH

四个 bean 都是 @ConditionalOnMissingBean,应用层可以替换任一组件。

3. 快速上手#

调用方在自己的配置类里注入 ShopHttpExchangeSupport,按服务建代理:

@Configuration(proxyBeanMethods = false)
class BuyerBffClientConfiguration {

    @Bean
    MarketplaceServiceClient marketplaceClient(
            ShopHttpExchangeSupport support,
            @Value("${shop.buyer.marketplace-service-url}") String url) {
        return support.createClient(url, MarketplaceServiceClient.class);
    }

    // 慢服务单独覆盖 read-timeout
    @Bean
    SearchServiceClient searchClient(
            ShopHttpExchangeSupport support,
            @Value("${shop.buyer.search-service-url}") String url) {
        return support.createClient(url, SearchServiceClient.class, Duration.ofSeconds(10));
    }
}

@HttpExchange 接口声明在调用方自己的模块里,只列自己用到的端点:

// buyer-bff/src/main/java/.../client/MarketplaceServiceClient.java
@HttpExchange
public interface MarketplaceServiceClient {
    @PostExchange(MarketplaceApi.LIST)
    ApiResponse<MarketplaceApi.ProductsView> listProducts(
            @RequestBody MarketplaceApi.ListProductsRequest request);
}

ArchUnit 规则 SPRING-03 约束 @HttpExchange 留在调用方模块——避免共享 shop-clients 模块那种“改一个接口牵连所有消费方”的问题。

4. 核心组件#

4.1 TracingHeaderInterceptor#

把 Baggage 中的身份字段(X-Buyer-Id 等)转成出站请求的直接 HTTP header:

public class TracingHeaderInterceptor implements ClientHttpRequestInterceptor {

    private static final Set<String> DENIED_HEADERS =
            Set.of("Authorization", "Cookie", "Set-Cookie");

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body,
                                        ClientHttpRequestExecution execution) throws IOException {
        if (tracer == null) return execution.execute(request, body);

        for (BaggageMapping mapping : baggageMappings) {
            if (DENIED_HEADERS.contains(mapping.headerName())) continue;
            String value = tracer.getBaggage(mapping.baggageName()).get();
            if (value != null && !value.isBlank()) {
                request.getHeaders().set(mapping.headerName(), value);
            }
        }
        return execution.execute(request, body);
    }
}

两个关键问题:

为什么不读 RequestContextHolder BFF 的聚合接口用虚拟线程并发调多个服务(见 §5),新虚拟线程不继承父线程的 ThreadLocal;而 Baggage 可以被 ContextSnapshot 捕获并跨线程恢复。

为什么需要这个拦截器? Spring Boot 自动配置的 RestClient.Builder 把 Baggage 序列化为 W3C baggage: header,但下游服务读的是 X-Buyer-Id: 直接 header——两个通道不同,所以需要 bridge。

4.2 SharedDownstreamErrorHandler#

标准 RestClient 遇 4xx/5xx 会抛 Spring 自己的通用异常类型(例如 RestClientResponseException)。它仍然会带出 status code,但上层业务代码拿到的仍是框架异常,错误码语义、响应体解析和 tracing tag 都不统一。这里把响应体解析为 DownstreamException,并给 Span 打 tag:

public void handleError(HttpRequest request, ClientHttpResponse response) {
    int status = response.getStatusCode().value();
    String code = CommonErrorCode.DOWNSTREAM_ERROR.getCode();
    String message = "Downstream request failed: " + request.getURI();

    try (var is = response.getBody()) {
        if (is != null) {
            byte[] body = is.readAllBytes();
            if (body.length > 0) {
                JsonNode json = objectMapper.readTree(body);
                String parsedCode = extractCode(json);
                String parsedMsg  = extractMessage(json);
                if (!parsedCode.isBlank()) code    = parsedCode;
                if (!parsedMsg.isBlank())  message = parsedMsg;
            }
        }
    } catch (IOException e) {
        log.warn("Failed to parse downstream error body [uri={}, status={}]",
                request.getURI(), status, e);
    }

    DownstreamException ex = new DownstreamException(status, code, message);
    enrichSpan(ex);
    throw ex;
}

private void enrichSpan(DownstreamException ex) {
    if (tracer == null) return;
    Span span = tracer.currentSpan();
    if (span == null) return;
    span.tag("downstream.http.status", String.valueOf(ex.getDownstreamHttpStatus()));
    span.tag("downstream.error.code", ex.getDownstreamCode());
    if (ex.getDownstreamHttpStatus() >= 500) span.error(ex);  // 4xx 不标 error
}

extractCode 兼容三种格式:

private static String extractCode(JsonNode body) {
    String code = body.path("status").asText();           // ApiResponse: SC_*
    if (!code.isBlank() && code.startsWith("SC_")) return code;

    code = body.path("code").asText();                    // 通用 code 字段
    if (!code.isBlank()) return code;

    String type = body.path("type").asText();             // ProblemDetail (RFC 7807)
    if (!type.isBlank() && type.contains("/"))
        return type.substring(type.lastIndexOf('/') + 1);

    return "";
}

DownstreamException 携带原始状态码:

public class DownstreamException extends BusinessException {
    private final int downstreamHttpStatus;  // 404 / 422 / 503 ...
    private final String downstreamCode;     // SC_NOT_FOUND / validation-failed ...
}

GlobalExceptionHandler 因此可以做语义透传——order 的 404 直接传给客户端,而不是统一变成 502。

4.3 ShopHttpExchangeSupport#

@HttpExchange 代理的工厂:

public class ShopHttpExchangeSupport {

    private static final ClientRequestObservationConvention OBSERVATION_CONVENTION =
            new DownstreamServiceObservationConvention();

    private final Supplier<RestClient.Builder> restClientBuilderSupplier;
    private final HttpClient httpClient;  // 共享连接池

    public <T> T createClient(String baseUrl, Class<T> clientClass, Duration readTimeout) {
        var requestFactory = new JdkClientHttpRequestFactory(httpClient);
        requestFactory.setReadTimeout(readTimeout);

        RestClient restClient = restClientBuilderSupplier.get()  // 每次新 builder
                .requestFactory(requestFactory)
                .baseUrl(baseUrl)
                .requestInterceptor(tracingInterceptor)
                .defaultStatusHandler(HttpStatusCode::isError, errorHandler::handleError)
                .observationConvention(OBSERVATION_CONVENTION)
                .build();

        return HttpServiceProxyFactory
                .builderFor(RestClientAdapter.create(restClient))
                .build()
                .createClient(clientClass);
    }
}

两个设计决定:

Supplier<RestClient.Builder> 而不是缓存 builder。Spring Boot 的 RestClient.Builder 是 prototype bean,状态会随每次配置调用累积——.requestInterceptor() 是追加,不是替换。Supplier 保证每次 createClient() 拿到的是新实例。

共享 HttpClient。Oracle 的 HttpClient API 文档明确写了“一个 HttpClient 实例通常管理自己的连接池”。因此在构造函数里创建一次、让所有代理共享,能避免每个代理各起一套连接池造成碎片化:

this.httpClient = HttpClient.newBuilder()
        .connectTimeout(connectTimeout != null ? connectTimeout : Duration.ofSeconds(2))
        .build();

关于连接池隔离、超时按服务定制、HTTP/2 / h2c 内部调用的细节实战,整理在 Spring Boot 3.5 BFF 出站 HTTP 客户端:连接池、超时与 HTTP/2 实战 这篇。本文只保留与 shop-starter-http-client 设计相关的核心决定。

4.4 DownstreamServiceObservationConvention#

Spring Boot 默认给 http.client.requests 打的 tag 不含被调服务名。继承 DefaultClientRequestObservationConvention 加上 downstream.service

public class DownstreamServiceObservationConvention
        extends DefaultClientRequestObservationConvention {

    @Override
    public KeyValues getLowCardinalityKeyValues(ClientRequestObservationContext ctx) {
        return super.getLowCardinalityKeyValues(ctx)
                .and(KeyValue.of("downstream.service", extractServiceName(ctx)));
    }

    private static String extractServiceName(ClientRequestObservationContext ctx) {
        try {
            ClientHttpRequest carrier = ctx.getCarrier();
            if (carrier != null) {
                String host = carrier.getURI().getHost();
                if (host != null && !host.isBlank()) return host;
            }
        } catch (Exception ignored) {}
        return "unknown";
    }
}

K8s service hostname(marketplace-serviceorder-service)是稳定的低基数值,适合作 Prometheus label。

5. 虚拟线程 fan-out#

BFF 的聚合接口(loadDashboard 等)用虚拟线程并发调多个服务。如果不显式做上下文传播,新虚拟线程不会自动继承调用方的 Micrometer 上下文,TracingHeaderInterceptor 在新 VT 里读 Baggage 就会拿到空值。

正确的写法是先快照、再 wrap:

public BuyerApi.DashboardResponse loadDashboard(String buyerId) {
    ContextSnapshot snapshot = ContextSnapshot.captureAll();
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var profile     = executor.submit(snapshot.wrap(() -> getProfile(buyerId)));
        var wallet      = executor.submit(snapshot.wrap(() -> getWallet(buyerId)));
        var marketplace = executor.submit(snapshot.wrap(() -> listMarketplace()));
        var loyalty     = executor.submit(snapshot.wrap(() -> getLoyaltyAccount(buyerId)));
        // ...
    }
}

ContextSnapshot 来自 io.micrometer:context-propagation(Spring Boot 3.x 通过 micrometer-tracing-bridge-otel 传递依赖自带)。captureAll() 捕获所有注册了 ThreadLocalAccessor 的上下文(含 trace context 和 baggage);snapshot.wrap() 在新 VT 执行任务前先恢复快照。

6. 配置#

6.1 Baggage(决定身份传播能否工作)#

Micrometer 的 baggage 配置有两个独立但容易混淆的字段:

management:
  tracing:
    baggage:
      remote-fields: [X-Buyer-Id, X-Seller-Id, X-Username, X-Portal, X-Roles, X-Order-Id, X-Request-Id]
      correlation-fields: [X-Buyer-Id, X-Seller-Id, X-Username, X-Order-Id]
配置 作用 缺了会怎样
remote-fields 入站 HTTP header → Baggage TracingHeaderInterceptor 读不到值,下游收不到身份 header
correlation-fields Baggage → 日志 MDC 日志里没有 buyerId,只能用 traceId 检索

注意点:

  • traceId / spanId 由 Micrometer 自动写入 MDC,通常不用再列在 correlation-fields
  • X-Roles 太长、X-Request-IdtraceId 重叠,所以只放在 remote-fields,不进 MDC
  • 字段名应该与 TrustedHeaderNames 常量完全一致

6.2 starter 配置#

@ConfigurationProperties record:

shop:
  http-client:
    connect-timeout: 2s
    read-timeout: 5s
    baggage-headers: []   # 空则使用 BaggageMapping.defaults() 的 6 个 header

6.3 Spring Boot 3.5 新增:write-trace-header#

让 HTTP 响应自动携带 X-Trace-Id,前端从 Network 面板拿到 trace id 直接去 Tempo 查询:

management:
  observations:
    http:
      server:
        requests:
          write-trace-header: true

在遵循 W3C Trace Context 的前提下,trace ID 通常是 128-bit 随机值,本身不承载业务语义,暴露给调用方通常是可接受的——Stripe(Stripe-Request-ID)、AWS(x-amzn-trace-id)、Cloudflare(cf-ray)都有类似做法,用户报问题时直接附带 ID,通常能减少支持成本。真正应该避免在响应里出现的是 hostname / stack trace / 内部错误码——这些才是 reconnaissance 的实际威胁。

7. 可观测性:从告警到排查#

7.1 Metrics(发现问题)#

Spring Boot 的 http.client.requests 在 Prometheus 暴露为两条 metric:http_client_requests_seconds_count(计数)+ http_client_requests_seconds_bucket(延迟直方图)。

每条样本带的 label:

Label 来源 示例
method Spring 默认 GET / POST
uri Spring 默认(模板化) /api/marketplace/products
status Spring 默认 200 / 404 / 500
outcome Spring 默认 SUCCESS / CLIENT_ERROR / SERVER_ERROR / UNKNOWN
downstream_service DownstreamServiceObservationConvention marketplace-service

Micrometer tag 名里的 . 在 Prometheus label 中会变成 _,所以代码里的 downstream.service 在 PromQL 中是 downstream_service

downstream_service 区分不同下游服务的常用查询:

# 1. 指定服务的错误率
sum(rate(http_client_requests_seconds_count{
    downstream_service="marketplace-service",
    outcome="SERVER_ERROR"}[5m]))
/
sum(rate(http_client_requests_seconds_count{
    downstream_service="marketplace-service"}[5m]))

# 2. 所有服务并排比较(每个服务一条线)
sum by (downstream_service) (rate(http_client_requests_seconds_count{
    outcome=~"SERVER_ERROR|CLIENT_ERROR"}[5m]))
/
sum by (downstream_service) (rate(http_client_requests_seconds_count[5m]))

# 3. 每个服务的 P99 延迟
histogram_quantile(0.99,
  sum by (downstream_service, le) (
    rate(http_client_requests_seconds_bucket[5m])))

# 4. 总 RPS 排名(识别热点服务)
topk(5, sum by (downstream_service) (
    rate(http_client_requests_seconds_count[5m])))

接入步骤#

不需要写任何指标采集代码,引入两个依赖即可:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

放开 actuator 端点:

management:
  endpoints:
    web:
      exposure:
        include: prometheus, health
  server:
    port: 8081     # 应用端口 8080,管理端口 8081 隔离

验证 metric 已经暴露:

$ curl -s http://buyer-bff:8081/actuator/prometheus | grep http_client_requests_seconds_count | head -2
http_client_requests_seconds_count{downstream_service="marketplace-service",method="GET",outcome="SUCCESS",status="200",uri="/api/marketplace/products",} 142.0
http_client_requests_seconds_count{downstream_service="loyalty-service",method="GET",outcome="SUCCESS",status="200",uri="/api/loyalty/account/{buyerId}",} 98.0

Grafana Dashboard#

项目预置了 shop-http-client.json dashboard(在 platform/k8s/observability/grafana/ 下,由 ConfigMap 挂载到 Grafana),所有面板都按 downstream_service 自动拆分。新增下游服务通常不用改 dashboard——downstream_service 由 hostname 自动提取,新服务的曲线会自己出现在面板里。

Dashboard 包含的核心面板:

  • 总 RPS(按 downstream_service 堆叠)
  • 错误率(按 downstream_service 拆分,4xx 和 5xx 双线)
  • P99 / P95 / P50 延迟(每个服务一张图)
  • 熔断器状态(resilience4j_circuitbreaker_state,红色填充表示 open)

Prometheus 告警规则#

把告警和 dashboard 的阈值绑在一起,每个新增服务都会自动生效(不需要每加一个服务改一份规则):

groups:
  - name: http-client
    rules:
      - alert: DownstreamHighErrorRate
        expr: |
          sum by (downstream_service) (
            rate(http_client_requests_seconds_count{outcome=~"SERVER_ERROR|CLIENT_ERROR"}[5m]))
          / sum by (downstream_service) (
            rate(http_client_requests_seconds_count[5m]))
          > 0.01          
        for: 5m
        labels:    { severity: warning }
        annotations:
          summary: "下游 {{ $labels.downstream_service }} 错误率 > 1%(5m)"

      - alert: DownstreamHighLatencyP99
        expr: |
          histogram_quantile(0.99,
            sum by (downstream_service, le) (
              rate(http_client_requests_seconds_bucket[5m]))) > 0.5          
        for: 5m
        labels:    { severity: warning }
        annotations:
          summary: "下游 {{ $labels.downstream_service }} P99 > 500ms(5m)"

      - alert: CircuitBreakerOpen
        expr: resilience4j_circuitbreaker_state{state="open"} > 0
        for: 1m
        labels:    { severity: critical }
        annotations:
          summary: "熔断器 {{ $labels.name }} 已开路"

新增下游服务的 checklist#

步骤 是否需要操作
metric 采集 ❌ 自动有
downstream_service label ❌ 自动从 hostname 提取
Grafana 面板 ❌ 自动出现在共享 dashboard
Prometheus 告警 ❌ 通用告警自动覆盖
服务专属调优(如更长 timeout) ✅ 在 createClient() 重载里设

7.2 Tracing(定位问题)#

ObservationRestClientCustomizer(自动配置)让每次 RestClient 请求自动创建子 Span,并把 W3C traceparent 注入下游。

出站 Span 的关键 tag:

  • 默认:http.urlhttp.methodhttp.status_code
  • 自定义:downstream.servicedownstream.http.statusdownstream.error.code

7.3 Logging(精确定位)#

每行日志带 traceIdspanId(自动)+ correlation-fields(如 buyerId)。

跨服务联合查询:

{app=~"buyer-bff|marketplace-service|order-service"}
  | logfmt | traceId = `3e8fab1c00000001`

按业务维度过滤:

{app="buyer-bff"} |= `DownstreamException`
  | logfmt | buyerId = `buyer-001`

开发环境查看完整请求/响应体(生产禁用):

logging:
  level:
    org.springframework.web.client.RestClient: DEBUG

7.4 三层联动排查流程#

flowchart LR A[Grafana 错误率告警] --> B[按 downstream_service
定位异常服务] B --> C[Tempo 按
downstream.error.code
过滤 trace] C --> D[展开 span
看 buyerId / 状态码] D --> E[Loki 用 traceId
拉所有服务日志] E --> F[定位根因]

8. Resilience:按需逐层叠加#

常见的错误是把所有 resilience 模式(timeout / circuit breaker / fallback / retry)一开始就全开。这会带来:

  • 隐藏故障:fallback 让所有失败"看起来正常",监控拿不到信号
  • 复杂度爆炸:每个调用都有 6+ 个 Resilience4j 配置项要维护
  • 错误的容错:4xx 业务拒绝被熔断器误判为故障

我更倾向于分层叠加,只在有证据需要时才往上加一层

时机 工具
L0 timeout 我更倾向默认开启 HttpClient connect/read timeout
L1 observability 我更倾向默认开启 metric + tracing + logging(§7)
L2 circuit breaker 出过 1 次以上事故、且服务非关键 Resilience4j @CircuitBreaker
L3 fallback 业务上有合理"默认值" fallbackMethod
L4 retry 极少需要 Resilience4j @Retry

新增下游服务的演进路径:

flowchart LR A["Day 0
L0 timeout + L1 监控"] --> B["看几天指标
建立基线"] B --> C{有事故
或慢调用?} C -- 否 --> D[保持 L0+L1
不加复杂度] C -- 是 --> E[排查根因
修复源头] E --> F{服务非关键
且有 fallback?} F -- 否 --> G[只加监控告警
不加 CB] F -- 是 --> H[+ L2 CB + L3 fallback]

8.1 L0:Timeout(我更倾向默认开启)#

connect-timeout 防 TCP 握手挂住,read-timeout 防响应读取挂住。两者都设:

shop:
  http-client:
    connect-timeout: 2s
    read-timeout: 5s

响应不稳定的服务可单独覆盖 read-timeout:

support.createClient(searchUrl, SearchServiceClient.class, Duration.ofSeconds(10));

createClient() 故意只允许覆盖 read-timeout 而不允许覆盖 connect-timeout——connectTimeoutHttpClient 实例级属性,per-service 定制要新建 ShopHttpExchangeSupport bean。完整决策表见 BFF HTTP 客户端连接池与 HTTP/2 实战 第 3 节。

为什么不用 Resilience4j TimeLimiter? 项目用同步 RestClient + 虚拟线程;TimeLimiter 工作在 CompletableFuture 上,要把同步调用包成 async 才能用。HttpClient 层 timeout 已经覆盖了网络层,再叠一层逻辑超时只增加复杂度。如果以后真有"包含 retry 的整体预算"这种需求,再引入也不迟。

8.2 L2:Circuit Breaker(按需,我不建议默认开)#

判断要不要加,我通常会先看三个信号:

✅ 加:

  • 历史上该服务出过故障(看 incident 记录)
  • 该服务对核心流程非必需
  • 业务上能给出合理的 fallback 值(见 L3)

❌ 不建议加:

  • 服务从未出过问题,纯防御式编程
  • 服务是核心依赖,没有合理 fallback——熔断后聚合接口照样失败
  • 单纯想"看起来稳定"

加的方式(仍以 loyalty 为例):

@CircuitBreaker(name = "loyalty-service", fallbackMethod = "getLoyaltyAccountFallback")
public LoyaltyApi.AccountResponse getLoyaltyAccount(String buyerId) {
    return loyaltyServiceClient.getAccount(buyerId).data();
}
resilience4j:
  circuitbreaker:
    instances:
      loyalty-service:
        sliding-window-size: 10
        failure-rate-threshold: 50           # 50% 失败率触发熔断
        wait-duration-in-open-state: 30s     # 熔断后 30s 再尝试半开
        permitted-number-of-calls-in-half-open-state: 3
        ignore-exceptions:
          - dev.meirong.shop.common.error.DownstreamException  # 4xx 业务拒绝不计失败

DownstreamException 加入 ignore-exceptions:4xx 是业务正常分支(如 404 用户不存在),不应被熔断器统计为故障。如果想让 5xx 触发熔断、4xx 不触发,需要在 SharedDownstreamErrorHandler 里按状态码抛不同的子类异常(DownstreamServerException / DownstreamClientException),然后只 ignore 后者。

熔断器状态自动暴露为 resilience4j_circuitbreaker_state metric。

在聚合接口里如何把"关键 / 非关键"翻译成 getCritical / getOptional helper、以及 Future.get()ExecutionException 时怎么 unwrap 才不会丢失下游 status code,见 集成指南 §进阶 5

8.3 L3:Fallback(前提是有合理"默认值")#

只有业务上能定义"失败时返回什么"才加 fallback:

服务 有合理 fallback? 可接受的降级值
loyalty(积分) 空账户
promotion(活动) 空促销列表
marketplace(商品) 商品都没有,聚合接口也没意义
order(下单) 不适合假装下单成功
private LoyaltyApi.AccountResponse getLoyaltyAccountFallback(String buyerId, Throwable t) {
    log.warn("loyalty-service unavailable [buyerId={}]", buyerId);
    return LoyaltyApi.AccountResponse.empty();
}

如果某个依赖属于关键路径,通常不建议写 fallback:直接抛 DownstreamException,让上层快速失败。否则 fallback 很容易掩盖关键依赖的故障。

也可以分开使用:先加 L2 熔断(避免雪崩),暂不加 L3 fallback。熔断器 open 时仍抛 CallNotPermittedException,由全局异常处理器返回标准错误。等业务上想清楚 fallback 该返回什么再加上。

8.4 L4:Retry(极少需要)#

只对幂等 GET + 连接级错误启用:

@Retry(name = "search-service")
@GetExchange(SearchApi.SEARCH)
ApiResponse<SearchApi.SearchResult> search(@RequestParam String q);
resilience4j:
  retry:
    instances:
      search-service:
        max-attempts: 2
        wait-duration: 100ms
        retry-exceptions:
          - java.net.ConnectException
          - java.net.SocketTimeoutException
        ignore-exceptions:
          - dev.meirong.shop.common.error.DownstreamException  # 4xx/5xx 一律不重试

DownstreamException 通常应该列入 ignore-exceptions:4xx 重试不会改变结果,5xx 经常是下游过载——任何一种重试都只会加重下游负担。

9. 完整请求路径#

buyer-bff 调用 marketplace-service 为例:

sequenceDiagram participant G as api-gateway participant B as buyer-bff participant VT as Virtual Thread participant M as marketplace-service participant O as Observability G->>B: 转发请求 附 X-Buyer-Id Note over B: Micrometer 按 remote-fields 把 header 存入 Baggage B->>VT: ContextSnapshot.wrap 后提交任务 Note over VT: snapshot 在新 VT 恢复 Baggage VT->>M: GET 商品列表 附 traceparent 和 X-Buyer-Id M-->>VT: ApiResponse 或错误响应 Note over VT: 错误时 SharedDownstreamErrorHandler 抛 DownstreamException 并 enrichSpan VT-->>B: 返回数据或抛异常 Note over B: 关键路径通常直接抛出 非关键路径可走 CircuitBreaker fallback VT-->>O: metric span log 自动上报 含 downstream_service traceId buyerId

10. 小结#

设计 shop-starter-http-client 的几条关键决定:

  • 从 Micrometer Baggage 读身份——能被 ContextSnapshot 捕获,虚拟线程 fan-out 也可靠工作
  • W3C baggage ≠ 直接 HTTP header——auto-config 只送 W3C,TracingHeaderInterceptor 提供必要的 bridge
  • Supplier<RestClient.Builder> 包装原型 bean——builder 状态会累积,每次都需要新实例
  • 共享 HttpClient 实例——所有代理共用同一个连接池,避免连接碎片化
  • DownstreamException 携带原始状态码——上层可做语义透传,避免统一 502
  • Resilience 按需逐层叠加——L0 timeout + L1 监控通常值得先开;L2 熔断 / L3 fallback 更适合放在出过事故、且业务能定义降级值时再加;避免开局全开导致故障被掩盖
  • Retry 极少需要——只对幂等 GET + 连接级错误启用,DownstreamException 应该 ignore,避免放大下游负载
  • 三层可观测——Metrics 发现 → Tracing 定位 → Logging 还原;新增服务无需改 dashboard、告警,downstream_service 自动覆盖