Spring Boot 3.5 微服务 tracing 为什么会断链:一次 HTTP 客户端治理复盘
目录
本文代码背景来自开源项目 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,结果通常就是:
- Grafana Tempo 里看到 trace 断链,下游服务冒出新的
traceId - 日志里虽然有
traceId,但buyerId、sellerId、orderId这种业务上下文又丢了 - 不同服务对 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-configured 的 RestTemplateBuilder、RestClient.Builder 或 WebClient,而不是手动 new 一个 client。参见 Spring Boot Tracing 文档。
这套做法对 trace context 很有效,因为 Micrometer Tracing + OpenTelemetry/Brave instrumentation 会自动处理 traceparent / tracestate。
但它仍然留了两个空白:
- 业务上下文怎么统一传播?例如
buyerId、orderId - 谁来保证所有出站调用都真的用了 auto-configured builder?
也就是说,它能解决“trace 不断”,但不自动解决“业务上下文统一”和“客户端治理统一”。
方案三:分层传播设计(本文采用的方向)#
这次重构里我最后采用的方案,是把问题拆成三层分别治理:
- 标准层:trace context 统一走 W3C Trace Context
- 业务层:业务上下文统一收敛为 baggage 白名单
- 工程层:所有出站 HTTP client 统一从 Spring Boot auto-configured builder 出生
@HttpExchange 在这里仍然有价值,但它的角色从“解决 tracing 问题的核心工具”降级为“统一客户端接口表达的一种实现手段”。
这个方案的代价也很真实:
- 需要先定义一套跨服务统一的 baggage/header contract
- 需要把已有的手写 client 和散落的 interceptor 统一起来
- 需要团队接受“不要直接
RestClient.builder()”这种工程纪律
但从当前项目看,这个 trade-off 值得尝试,因为它解决的是系统级一致性问题。
trace context 为什么更适合走标准协议?#
它解决什么问题#
它解决的是“为什么同一个请求到了下游服务会突然变成另一条 trace”。
W3C 的 Trace Context 规范 定义了 traceparent 和 tracestate,核心目标就是让不同服务、不同中间件、不同 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-Id、X-Seller-Id、X-Order-Id 这样的 header 形式存在。
如果这些值只靠 RequestContextHolder 或 controller 手动透传,就会出现两个问题:
- 传播逻辑散落在各处,没有统一策略
- 它只适合“当前线程就是一个 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 方案?#
它解决什么问题#
它解决的是两个工程问题:
- 客户端接口重复定义
- 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);
}
}
这里有三个职责被明确固定下来:
- trace context 依赖 auto-configured builder 自动传播
- business context 通过
TracingHeaderInterceptor从 baggage 读取并写入请求头 - 错误语义 通过
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-Id、X-Seller-Id、X-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]
这里的核心不是某个类,而是三条纪律:
- Trace 用标准协议,不用自定义 trace header 当主协议
- 业务上下文走 baggage 白名单,不走散落的 header copy
- 所有出站 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 不断、日志能关联、业务上下文不丢的,是下面这套分层设计:
traceparent/tracestate作为标准 trace context- baggage 白名单作为业务上下文传播机制
- auto-configured
RestClient.Builder作为统一出站基础设施 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 |
-
Spring Boot 官方 Tracing 文档指出,要让 traces 自动跨网络传播,应使用 auto-configured 的 builder:https://docs.spring.io/spring-boot/reference/actuator/tracing.html 。该行为也在 spring-projects/spring-boot#42502 中被明确讨论过。 ↩︎