shop-starter-http-client:Spring Boot 3.5 微服务 HTTP 客户端基础设施设计
目录
代码背景来自开源项目 meirongdev/shop,Java 25 + Spring Boot 3.5 + Spring Cloud 微服务电商平台。
本文讲设计——starter 内部为什么这么写。如果你的目标是集成 starter,跟着步骤跑通第一个客户端,请直接看 shop-starter-http-client 集成指南。
1. 背景#
项目是典型的 BFF(Backend For Frontend)模式:
每个 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. 架构总览#
代理工厂"] 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-service、order-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-Id与traceId重叠,所以只放在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.url、http.method、http.status_code - 自定义:
downstream.service、downstream.http.status、downstream.error.code
7.3 Logging(精确定位)#
每行日志带 traceId、spanId(自动)+ 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 三层联动排查流程#
定位异常服务] 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 |
新增下游服务的演进路径:
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——connectTimeout是HttpClient实例级属性,per-service 定制要新建ShopHttpExchangeSupportbean。完整决策表见 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/getOptionalhelper、以及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 为例:
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自动覆盖