本文代码背景来自开源项目 meirongdev/shop

如果你看过上一篇 Java 项目怎么做 contract testing:一次 Spring Cloud Contract 实践,那篇文章讨论的是"接口兼容性边界";这一篇讨论的是"调用链可观测性边界"。

Tracing 断链时,该换客户端还是换设计?#

在一个典型的 Spring Boot 3.5 微服务架构里,当 tracing 断链时,很多团队的第一反应是换一个更优雅的 HTTP 客户端抽象方式,比如用 @HttpExchange 替代手写 RestClient,或者抽一个公共客户端。但这往往不是问题的核心。

实际请求通常会经过:

api-gateway -> buyer-bff / seller-bff -> order-service / profile-service / wallet-service ...

如果链路里某一跳自己 new 了一个 HTTP client,或者只会复制业务 header、不会传播 trace context,结果通常就是:

  1. Grafana Tempo 里看到 trace 断链,下游服务冒出新的 traceId
  2. 日志里虽然有 traceId,但 buyerIdsellerIdorderId 这种业务上下文又丢了
  3. 不同服务对 header 的处理方式各自为政,后面越改越乱

所以这篇文章不再把 @HttpExchange 当主角,而是先回答更本质的问题:

在 Spring Boot 3.5 微服务架构中,怎样设计一套稳定的 tracing 传播方案,让 trace context 和业务上下文都能跨服务连续传播?


Tracing 传播究竟是几层问题?#

很多团队会把“tracing 传播”当成一个点状问题,但它其实至少分成三层:

层次 要解决的问题 典型失败方式
Trace Context 上游 span 如何成为下游 span 的 parent 手动 RestClient.builder(),绕过 Spring Boot 自动附加的 tracing interceptor1
Business Context buyerId / sellerId / orderId 这类业务上下文如何跟着请求走 直接复制请求头,逻辑散落在 controller / interceptor
Client Governance 谁来保证所有出站调用都遵守同一传播规则 每个服务、每个模块都自己造 RestClient.builder()

这也是我想强调的一点:

@HttpExchange 只能解决“客户端接口怎么写得更声明式”,但它本身并不自动等于“tracing 传播设计已经正确”。


常见的三种做法分别够用吗?#

方案一:手动复制 X-Trace-Id / X-Buyer-Id 一类 header#

这是很多项目的第一反应:在网关或 BFF 里把 incoming headers 原样抄到 outgoing request。

它能解决的问题很直接:实现快、可见性强、容易 debug。

但 trade-off 也很明显:

  • 不标准:分布式 tracing 的主协议应该是 W3C Trace Context,而不是自定义 X-Trace-Id
  • 不可靠:一旦有某个调用点忘了复制 header,链路就断
  • 不易扩展:HTTP 线程里能拿 HttpServletRequest,异步场景、消息场景就不一定行

如果系统很小,这种方式能顶一段时间;但放到多服务、多团队、多调用链的场景里,维护成本会越来越高。

方案二:主要依赖 Spring Boot 自动 tracing#

Spring Boot 官方文档已经讲得很明确:想让 traces 自动跨网络传播,应该使用 auto-configuredRestTemplateBuilderRestClient.BuilderWebClient,而不是手动 new 一个 client。参见 Spring Boot Tracing 文档

这套做法对 trace context 很有效,因为 Micrometer Tracing + OpenTelemetry/Brave instrumentation 会自动处理 traceparent / tracestate

但它仍然留了两个空白:

  • 业务上下文怎么统一传播?例如 buyerIdorderId
  • 谁来保证所有出站调用都真的用了 auto-configured builder

也就是说,它能解决“trace 不断”,但不自动解决“业务上下文统一”和“客户端治理统一”。

方案三:分层传播设计(本文采用的方向)#

这次重构里我最后采用的方案,是把问题拆成三层分别治理:

  1. 标准层:trace context 统一走 W3C Trace Context
  2. 业务层:业务上下文统一收敛为 baggage 白名单
  3. 工程层:所有出站 HTTP client 统一从 Spring Boot auto-configured builder 出生

@HttpExchange 在这里仍然有价值,但它的角色从“解决 tracing 问题的核心工具”降级为“统一客户端接口表达的一种实现手段”。

这个方案的代价也很真实:

  • 需要先定义一套跨服务统一的 baggage/header contract
  • 需要把已有的手写 client 和散落的 interceptor 统一起来
  • 需要团队接受“不要直接 RestClient.builder()”这种工程纪律

但从当前项目看,这个 trade-off 值得尝试,因为它解决的是系统级一致性问题。


trace context 为什么更适合走标准协议?#

它解决什么问题#

它解决的是“为什么同一个请求到了下游服务会突然变成另一条 trace”。

W3C 的 Trace Context 规范 定义了 traceparenttracestate,核心目标就是让不同服务、不同中间件、不同 tracing vendor 之间,至少能对同一条 trace 的身份达成一致。

OpenTelemetry 的 Context Propagation 文档 也明确说明了:HTTP 跨服务调用里,传播的核心是 trace ID、span ID 以及父子关系,而这通常应该由 instrumentation 自动处理。

为什么我会把它放在更靠前的优先级#

因为如果底层 trace context 都不连续,后面所有的日志关联、业务上下文分析、性能定位都会失去基础。

Trade-off#

  • 好处:标准、互通、工具链兼容性好
  • 代价:你不能再把 trace ID 当业务协议的一部分来依赖;应用代码不应该围绕 X-Trace-Id 之类 header 写业务逻辑

这也是为什么我不建议把“自定义 trace header 透传”写成架构主线,它最多只能是 debug 辅助手段,不能成为主协议。


业务上下文怎么跨服务传?#

它解决什么问题#

微服务链路里,很多时候我们真正想保留的不只是 trace 本身,还有一些业务身份信息:

  • 当前 buyer / seller 是谁
  • 当前 orderId 是多少
  • 当前 portal / roles 是什么

shop 这个项目里,这类信息长期以 X-Buyer-IdX-Seller-IdX-Order-Id 这样的 header 形式存在。

如果这些值只靠 RequestContextHolder 或 controller 手动透传,就会出现两个问题:

  1. 传播逻辑散落在各处,没有统一策略
  2. 它只适合“当前线程就是一个 HTTP 请求线程”的场景

Spring Boot 的 tracing 文档对这里的推荐很明确:通过 management.tracing.baggage.remote-fields 定义哪些字段要跨网络传播;如果还想让它们进入日志 MDC,再配 correlation-fields。参见 Spring Boot Tracing 文档

OpenTelemetry 的 Baggage 文档 也强调了 baggage 的定位:它是“随 context 一起传播的额外 key-value”,适合传递请求起点就已知、且下游仍需要的少量上下文。

我这里采用的做法#

我更推荐把业务上下文收敛成一个显式白名单,例如:

management:
  tracing:
    baggage:
      remote-fields:
        - x-buyer-id
        - x-seller-id
        - x-order-id
        - x-username
        - x-portal
      correlation-fields:
        - x-buyer-id
        - x-seller-id
        - x-order-id

Trade-off#

  • 好处:统一、可配置、能和日志关联整合
  • 代价:需要克制字段数量,且要避免把敏感信息和高基数字段一股脑塞进去

OTel 官方文档专门提醒过 baggage 的安全风险:它会随着网络请求传播,可能进入第三方系统,也可能被日志记录,因此我不建议把凭证、敏感 PII、大体量数据放进 baggage。参见 OTel Baggage Security Considerations

换句话说,baggage 是“少量、稳定、跨服务有意义的上下文字段白名单”,不是“任何你想传的东西都可以塞进去”。


为什么不能自己 new RestClient.builder()?#

它解决什么问题#

它解决的是“为什么有些调用点 tracing 正常,有些调用点 trace 一到下游就断”。

shop 仓库这次重构里最关键的工程结论,不是 @HttpExchange 本身,而是下面这条纪律:

我不建议把手动调用 RestClient.builder() 当成默认做法;更稳妥的是优先注入 Spring Boot auto-configured 的 RestClient.Builder

原因很现实。Spring Boot tracing 文档已经说明,要想让 traces 自动跨网络传播,通常要使用 auto-configured builders。对应到 RestClient,如果你自己 RestClient.builder(),就可能绕过 Boot 附加的 observation/tracing customization。

这个行为在 spring-projects/spring-boot#42502 里也被明确讨论过。

反例#

@Bean
RestClient.Builder restClientBuilder(JdkClientHttpRequestFactory factory) {
    return RestClient.builder()
            .requestFactory(factory);
}

这段代码的问题不是语法,而是它把“传播能力”变成了调用方自己要记住的细节

我这里采用的做法#

把底层 builder 统一交给 Spring Boot,再在统一工厂上叠加业务需要的拦截器、超时、错误处理:

@AutoConfiguration
@ConditionalOnClass(RestClient.class)
public class ShopHttpClientAutoConfiguration {

    @Bean
    @ConditionalOnBean({RestClient.Builder.class, TracingHeaderInterceptor.class})
    public ShopHttpExchangeSupport shopHttpExchangeSupport(
            RestClient.Builder restClientBuilder,
            ObjectMapper objectMapper,
            TracingHeaderInterceptor tracingInterceptor,
            @Value("${shop.http-client.connect-timeout:2s}") Duration connectTimeout,
            @Value("${shop.http-client.read-timeout:5s}") Duration readTimeout) {
        return new ShopHttpExchangeSupport(
                restClientBuilder,
                objectMapper,
                tracingInterceptor,
                connectTimeout,
                readTimeout);
    }
}

Trade-off#

  • 好处:传播策略、错误处理、超时配置都能统一管理
  • 代价:需要维护一个 shared starter / support module;个别特殊 client 的定制能力要通过扩展点设计出来,而不是人人直接逃逸到手写 builder

从架构角度看,我觉得这个 trade-off 更稳妥,因为它把“传播是否正确”从调用点个人习惯,提升成平台级保障。


@HttpExchange 到底算不算 tracing 方案?#

它解决什么问题#

它解决的是两个工程问题:

  1. 客户端接口重复定义
  2. BFF 配置类里大量样板代码

例如在 shop 仓库里,buyer-bff 的配置已经被收敛成这种形式:

@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(BuyerClientProperties.class)
public class BuyerBffConfig {

    @Bean
    OrderServiceClient orderServiceClient(ShopHttpExchangeSupport support,
                                          BuyerClientProperties properties) {
        return support.createClient(properties.orderServiceUrl(), OrderServiceClient.class);
    }
}

这里真正重要的不是“用了 @HttpExchange”,而是:

  • 所有代理都走同一个 ShopHttpExchangeSupport
  • 这个 support 内部使用的是 auto-configured RestClient.Builder
  • tracing、baggage、错误处理都在同一层被治理

Trade-off#

  • 好处:接口清晰,BFF 配置大幅瘦身,共享客户端可复用
  • 代价:如果某些调用非常动态、需要复杂请求拼装,直接写 RestClient 仍然可能更合适

所以我现在更愿意把 @HttpExchange 的定位写成一句话:

它是传播设计落地后的一个优雅表达层,而不是传播设计本身。


这套设计在 meirongdev/shop 里怎么落地?#

上面是设计,下面再落到这次重构里真正有代表性的代码。

1. 用统一 support 创建所有下游客户端#

ShopHttpExchangeSupport 是整个方案里最关键的落点:

public class ShopHttpExchangeSupport {

    private final RestClient.Builder restClientBuilder;
    private final SharedDownstreamErrorHandler errorHandler;
    private final TracingHeaderInterceptor tracingInterceptor;

    public <T> T createClient(String baseUrl, Class<T> clientClass) {
        RestClient restClient = restClientBuilder
                .requestFactory(requestFactory)
                .baseUrl(baseUrl)
                .requestInterceptor(tracingInterceptor)
                .defaultStatusHandler(HttpStatusCode::isError, errorHandler::handleError)
                .build();

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

这里有三个职责被明确固定下来:

  1. trace context 依赖 auto-configured builder 自动传播
  2. business context 通过 TracingHeaderInterceptor 从 baggage 读取并写入请求头
  3. 错误语义 通过 defaultStatusHandler 统一映射

这比“每个 BFF 各写一套 createClient 逻辑”更像一个可持续的平台设计。

2. 用 BaggageField 读取业务上下文,而不是从 Servlet 上下文硬抄#

shop-starter-http-client 里的实现是这样的:

public class TracingHeaderInterceptor implements ClientHttpRequestInterceptor {

    private final List<BaggageMapping> baggageMappings;

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body,
                                        ClientHttpRequestExecution execution) throws IOException {
        for (BaggageMapping mapping : baggageMappings) {
            String value = mapping.field().getValue();
            if (value != null && !value.isBlank()) {
                request.getHeaders().set(mapping.headerName(), value);
            }
        }
        return execution.execute(request, body);
    }
}

这段代码背后的设计意义,比代码本身重要:

  • 它不再依赖 RequestContextHolder
  • 它把“传播哪些业务字段”变成可配置策略,而不是硬编码复制
  • 它保留了现有 X-Buyer-Id / X-Order-Id 这套 header contract,对现有 controller 和下游服务更平滑

3. 为什么这里还需要一个自定义拦截器#

这也是一个容易让人困惑的点。

如果你的系统已经完全围绕标准 baggage 工作,并且所有服务都严格共享同一套 propagation 配置,那么很多时候 framework auto propagation 已经够用了。

shop 这个项目里,业务接口层仍然广泛依赖 X-Buyer-IdX-Seller-IdX-Order-Id 这种显式 header contract。于是这里出现了一个很典型的现实 trade-off:

  • 理想状态:全部只依赖标准 propagation,业务代码不感知 header 细节
  • 当前现实:已有服务和 controller 已经围绕这些 trusted headers 建立了协作约定

所以这个 TracingHeaderInterceptor 的价值,本质上是一个桥接层

  • 上游继续使用 tracing/baggage 机制保存上下文
  • 下游继续收到现有业务所依赖的 trusted headers

代价就是你需要维护一份“允许传播哪些业务字段”的白名单,并持续收敛命名。

顺带一提,HTTP header 名大小写本身不敏感,但工程上最好统一一种写法。否则即便功能不坏,配置和排障成本也会上升。

4. defaultStatusHandler 让声明式客户端保留统一错误语义#

@HttpExchange 只是调用方式变了,不代表错误处理可以散回每个 BFF 自己写。

Spring Framework 的 REST Clients 文档 说明了 RestClient 可以通过 status handlers 统一处理错误响应。放在这里的意义是:

  • BFF 不需要每个 client 都复制一套下游错误转换逻辑
  • tracing 和错误语义可以在同一个出站层统一落地

这也是“统一 support 层”优于“每个模块自己 new client”的另一个原因。


最终的分层架构长什么样?#

如果把这次复盘压缩成一张图,我会这么总结:

[Inbound Request]
      |
      v
[Gateway / BFF]
      |
      | 1. 接收 traceparent / tracestate
      | 2. 将 buyerId / sellerId / orderId 收敛为 baggage 白名单
      v
[Auto-configured RestClient.Builder]
      |
      | 3. 自动传播 trace context
      | 4. 通过统一 interceptor / support 补齐业务上下文与错误处理
      v
[@HttpExchange / RestClient]
      |
      v
[Downstream Service]

这里的核心不是某个类,而是三条纪律:

  1. Trace 用标准协议,不用自定义 trace header 当主协议
  2. 业务上下文走 baggage 白名单,不走散落的 header copy
  3. 所有出站 client 统一从 auto-configured builder 出生

只要这三条没立住,@HttpExchange、Feign、WebClient、RestClient 用哪个都只是表面差异。


这套设计什么时候不够用?#

这篇文章刻意把范围收在 HTTP 同步调用链路 上,没有展开 Kafka、定时任务、异步线程池这些边界。

这是一个有意的设计取舍。

因为一旦进入异步边界,问题会从“HTTP header 怎么传播”变成“context 在消息、线程和调度器之间怎么恢复”。那已经是另一篇文章的范围了。

所以如果你要把这套设计继续扩展到事件驱动场景,我建议额外检查:

  • 消息中间件是否也接入了 tracing instrumentation
  • baggage 是否真的需要进入消息头
  • 线程池 / 虚拟线程 / @Async 的上下文传播是否一致

我不建议假设“HTTP 传播已经通了,所以系统 tracing 就全通了”。


总结#

结合 meirongdev/shop 的实践,如果只保留一句话,我会留下这句:

在 Spring Boot 3.5 微服务架构里,服务之间 tracing 能不能连续传播,决定因素不是 @HttpExchange,而是传播协议、业务上下文 contract,以及统一的出站客户端治理。

@HttpExchange 很有用,但它解决的是“客户端表达”和“重复代码”问题;真正让 trace 不断、日志能关联、业务上下文不丢的,是下面这套分层设计:

  1. traceparent / tracestate 作为标准 trace context
  2. baggage 白名单作为业务上下文传播机制
  3. auto-configured RestClient.Builder 作为统一出站基础设施
  4. ShopHttpExchangeSupport 这类共享层作为统一治理入口

如果你也在做 Spring Boot 3.5 微服务,我会先把这四层想清楚,再决定 @HttpExchange、Feign 或 RestClient 到底怎么选。


参考链接#

资源 链接
完整源码(meirongdev/shop) https://github.com/meirongdev/shop
Spring Boot Tracing 文档 https://docs.spring.io/spring-boot/reference/actuator/tracing.html
Spring Framework REST Clients 文档 https://docs.spring.io/spring-framework/reference/integration/rest-clients.html
W3C Trace Context https://www.w3.org/TR/trace-context/
OpenTelemetry Context Propagation https://opentelemetry.io/docs/concepts/context-propagation/
OpenTelemetry Baggage https://opentelemetry.io/docs/concepts/signals/baggage/
Spring Boot issue: RestClient.builder() 与 trace propagation https://github.com/spring-projects/spring-boot/issues/42502

  1. Spring Boot 官方 Tracing 文档指出,要让 traces 自动跨网络传播,应使用 auto-configured 的 builder:https://docs.spring.io/spring-boot/reference/actuator/tracing.html 。该行为也在 spring-projects/spring-boot#42502 中被明确讨论过。 ↩︎