Spring Boot 3.5 BFF 出站 HTTP 客户端:连接池、超时与 HTTP/2 实战
目录
配套阅读:shop-starter-http-client 设计 介绍完整的 starter 设计;本文专注于其中"连接配置"这一块的细节决定。
1. 问题场景#
典型的 BFF 模式:
每个下游的特征可能完全不同:marketplace 响应 50ms,搜索可能要 5s;payment 是外部 HTTPS API,order 是内部 cleartext;某个服务上了 HTTP/2,其他还是 HTTP/1.1。
三个绕不开的问题:
- 不同下游应该共用一个连接池,还是每个独立?
connectTimeout/readTimeout怎么按服务定制?- 内部服务用 HTTP/2(h2c)值不值得开?怎么避坑?
下面逐个讲清楚。
2. 连接池:共享还是隔离?#
我通常会先共享一个 java.net.http.HttpClient 实例。
2.1 为什么很多时候先不需要隔离#
HttpClient 的连接复用天然按 origin 收敛#
Oracle 的 HttpClient API 文档明确写了“一个 HttpClient 实例通常管理自己的连接池”;连接对同一 origin 复用、不同 origin 分开这一点,可以再配合 OpenJDK 源码 一起看。对 marketplace-service:80 和 order-service:80 的连接不会混在一个全局 bucket 里,所以“共享一个 HttpClient 实例”并不等于“所有下游抢同一组 socket”:
独立连接 bucket"] HC --> P2["origin: order-service
独立连接 bucket"] HC --> P3["origin: loyalty-service
独立连接 bucket"]
虚拟线程会明显降低“线程预算被慢下游吃光”的压力#
传统 platform-thread + Apache HttpClient 时代,慢下游很容易把请求线程拖住,最后把线程预算吃光。到了 Java 21 的 virtual threads(见 JEP 444),这个压力会明显下降:
| 时代 | 一个慢下游的影响 |
|---|---|
| Servlet thread-per-request | 占用线程预算 → 其他请求排队 → 雪崩 |
| Java 21+ VT | 只是一些 VT park 在 I/O 上,不占用平台线程预算 |
HTTP/2 多路复用进一步降低隔离诉求#
同 origin 的多个请求可以复用同一条 HTTP/2 连接并通过多个 stream 并发传输;不同 origin 本来就不会复用同一条连接。因此,大多数“为了防止 A 服务挤占 B 服务连接池”而做的按服务拆池,在 JDK HttpClient 这里未必是默认需要的。
2.2 真正需要隔离的场景#
| 场景 | 隔离粒度 | 原因 |
|---|---|---|
| 外部 API(支付、地图、第三方 webhook) | 独立 HttpClient 实例 | SLA 完全不同(外部 10s connect,内部 2s) |
| 不同 TLS / 认证 | 独立 HttpClient 实例 | SSLContext 不同;外部要 client cert,内部不要 |
| 流式 / 大响应体下游 | 独立 HttpClient 实例 | 回调消耗 internal executor,避免污染其他链路 callback |
| 协议显著差异 | 独立 HttpClient 实例 | 比如某下游强制 prior-knowledge HTTP/2,需特殊 builder |
⚠ 注意"想限制对某下游的并发数"通常不属于隔离场景——那更适合用 Resilience4j Bulkhead 这类工具来处理,而不是在连接池层面解决。
2.3 怎么隔离#
注入第二个独立配置的 ShopHttpExchangeSupport(或类似工厂)bean,用 @Qualifier 区分:
@Bean("internalSupport")
ShopHttpExchangeSupport internalSupport(
ObjectProvider<RestClient.Builder> builderProvider,
SharedDownstreamErrorHandler errorHandler,
TracingHeaderInterceptor tracingInterceptor) {
return new ShopHttpExchangeSupport(
builderProvider::getObject, errorHandler, tracingInterceptor,
Duration.ofSeconds(2), // 内部 connect 短
Duration.ofSeconds(5)); // 内部 read 短
}
@Bean("externalSupport")
ShopHttpExchangeSupport externalSupport(
ObjectProvider<RestClient.Builder> builderProvider,
SharedDownstreamErrorHandler errorHandler,
TracingHeaderInterceptor tracingInterceptor) {
return new ShopHttpExchangeSupport(
builderProvider::getObject, errorHandler, tracingInterceptor,
Duration.ofSeconds(10), // 外部 connect 长
Duration.ofSeconds(30)); // 外部 read 长
}
@Bean
PaymentGatewayClient paymentClient(
@Qualifier("externalSupport") ShopHttpExchangeSupport support,
@Value("${shop.payment.gateway-url}") String url) {
return support.createClient(url, PaymentGatewayClient.class);
}
经验法则:一个 ShopHttpExchangeSupport bean = 一组隔离边界。绝大多数 BFF 一个就够;典型多一个的场景是"内部 + 外部 API"两组。
3. 超时:connectTimeout 和 readTimeout 的隔离边界#
3.1 两者的隔离粒度不同#
这是 JDK HttpClient API 的硬约束:
| 配置 | 属于谁 | 隔离粒度 |
|---|---|---|
connectTimeout |
HttpClient 实例(构造阶段固定) |
HttpClient 实例级——所有共享此实例的代理共用 |
readTimeout |
JdkClientHttpRequestFactory 实例 |
代理级——每个 createClient() 都 new 一个 factory,可以 per-service |
// connectTimeout 在 HttpClient 构造阶段就固定,之后不可改
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.build();
// readTimeout 在 RequestFactory 上设,每次 createClient() new 一个
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(client);
factory.setReadTimeout(Duration.ofSeconds(10));
3.2 实战决策表#
| 情况 | connectTimeout 策略 | readTimeout 策略 |
|---|---|---|
| 都是 K8s 内部服务 | 先共用一个值(比如 2s) | 按服务定制(marketplace 5s,search 10s) |
| 包含外部 API | 单独建 ShopHttpExchangeSupport bean(10s) |
在外部 support 里再 per-service 定制 |
| 出集群但还在 cloud 内 | 单独建(5s) | per-service |
| 公网 / 跨地域 | 单独建(10s+) | per-service,配合 retry 预算 |
为什么 K8s 内部服务的 connectTimeout 往往可以一刀切:同集群内调用的 DNS 解析和 TCP 建连通常都很快,真正差异更大的往往是服务端处理时间——那是 readTimeout 的范畴。所以内部服务通常可以共用一个保守的 connect timeout,再按服务细分 read timeout。
3.3 为什么 starter API 故意不让 per-service 改 connectTimeout#
ShopHttpExchangeSupport.createClient(url, class, readTimeout) 重载只接受 readTimeout——这是有意的。
如果让 createClient() 也能传 connectTimeout,那意味着每次调用都要 new 一个 HttpClient 实例(因为 connectTimeout 是实例级属性)——连接池就碎片化了,违背 §2 的共享设计。
所以 API 故意只暴露代理级 readTimeout:把"实例级配置"留给"注入第二个 ShopHttpExchangeSupport bean"处理。
// ✅ 推荐:实例级配置(connectTimeout)在 bean 定义阶段
@Bean ShopHttpExchangeSupport externalSupport(...) {
return new ShopHttpExchangeSupport(..., Duration.ofSeconds(10), ...);
}
// ✅ 推荐:代理级配置(readTimeout)在 createClient 阶段
support.createClient(searchUrl, SearchClient.class, Duration.ofSeconds(15));
// ❌ 不要:每个 client new 一个 HttpClient → 连接池碎片化
new HttpClient.newBuilder().connectTimeout(...).build(); // 每个服务一个?错
4. 内部服务调用的 HTTP/2#
4.1 默认 HTTP/1.1 还是 h2c?#
我的压测实验显示:
| Workload | h2c vs HTTP/1.1 |
|---|---|
| 单下游 / 小 payload / 低并发 | 边际收益(甚至 P95 更差) |
| Fan-out(同 origin 多并发) + 连接预算紧 | +97% 吞吐 / -61% P95 |
我的做法:先保留 HTTP/1.1,对验证过的 fan-out 热点再开 h2c。
4.2 客户端:大多数情况下不用额外做事#
Oracle 的 HttpClient.Builder#version 文档写得很明确:JDK HttpClient 默认偏好 HTTP/2;对 cleartext 端点,如果服务端支持 h2c,就会先走 Upgrade 协商:
Upgrade: h2c
HTTP2-Settings: ... S-->>C: HTTP/1.1 101 Switching Protocols Note over C,S: 同一 TCP 连接,从此切到 HTTP/2 帧 par 并发多 stream C->>S: HTTP/2 stream 1: GET /api/x and C->>S: HTTP/2 stream 3: GET /api/y end S-->>C: stream 1 response S-->>C: stream 3 response
两个细节:
- 协商需要服务端配合:服务端需要
server.http2.enabled=true;不开就保持 HTTP/1.1 - 第一次请求是 HTTP/1.1:Upgrade 协议要先发 HTTP/1.1,101 之后才切;同一连接的后续请求才走多路复用。短连接 / 一次性调用拿不到 h2c 收益
4.3 服务端:选 Tomcat 还是 Undertow?#
参考一个现状:
- Spring Boot 4 migration guide 明确写了:因为 Spring Boot 4 要求 Servlet 6.1 baseline,而 Undertow 还不兼容,所以 Undertow support is dropped,包括 starter 和嵌入式容器支持。
- 官方文档:
Spring Boot 4.0 Migration Guide
所以新服务的容器选型 Tomcat 可能更稳妥,避免后面升到 SB4 时再补一次容器迁移。
但 Tomcat 在本文配套的 SB 3.5 + h2c + virtual-thread fan-out 压测场景里踩过一个坑:BFF 用 VT 同时打多个 stream 时,我在 Tomcat 10.1 / 11.0.x 默认 HTTP/2 参数下复现过第二个 stream(id=3)被 RST,报:
org.apache.coyote.http2.StreamException:
Client sent more data than stream window allowed
或 FLOW_CONTROL_ERROR。所以这里给出的调参建议,应该理解成针对该压测场景的经验性修正,不是说“所有 Tomcat+h2c 都必然出问题”。
修法:
Tomcat 这些参数的语义可以直接对照官方 HTTP/2 配置文档:
| 参数 | Tomcat 默认 | 改为 | 原因 |
|---|---|---|---|
overheadDataThreshold |
1024 | 0 | list/query 接口的小 JSON payload 会被 overhead 计数误伤 |
initialWindowSize |
65535 (64KB) | 1MB | 并发 stream 初始化时 64KB 窗口很快显得耗尽 |
@Configuration
@ConditionalOnProperty(name = "server.http2.enabled", havingValue = "true")
public class TomcatHttp2AutoConfiguration {
@Bean
WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatHttp2() {
return factory -> {
// 阻止 Spring Boot 后续添加默认参数的 Http2Protocol
var http2 = factory.getHttp2();
if (http2 != null) http2.setEnabled(false);
factory.addConnectorCustomizers(connector -> {
var protocol = new Http2Protocol();
protocol.setOverheadDataThreshold(0);
protocol.setInitialWindowSize(1 << 20);
connector.addUpgradeProtocol(protocol);
});
};
}
}
把它装到 shop-common-core 的 auto-configuration 里,所有 server.http2.enabled=true 的 Tomcat 服务自动生效。
4.4 验证客户端真的用了 HTTP/2#
HttpResponse<String> response = client.send(request, BodyHandlers.ofString());
System.out.println(response.version()); // HTTP_2 / HTTP_1_1
或者看服务端 access log 的协议字段。生产环境可以加一个 metric tag(自定义 ClientRequestObservationConvention)。
4.5 没有 mTLS 时的实践建议#
如果内部网络没有 service mesh + mTLS,加密在 api-gateway 边缘终止,服务间是明文:
- 先默认 HTTP/1.1——所有服务出厂不开 h2c
- 按需开 h2c——只对验证过的 fan-out 热点(如 BFF → 同一服务多并发)开启,客户端不用改
- 服务端用 Tomcat +
TomcatHttp2AutoConfiguration——较稳定且面向 SB4 兼容 - NetworkPolicy 收紧——h2c 没有传输层加密,更要 L4 白名单严格,明确"哪些 BFF 可以访问哪些 domain service"
- h2c 先不要出集群——公网入口应该用 h2 (HTTPS),h2c 仅限 ClusterIP 内部通信
- 逐步演进到 mTLS——团队规模允许时可以考虑引入 Linkerd(比 Istio 轻量),sidecar 接管 mTLS,应用代码不变
5. 决策流程#
外部 API?} Q1 -- 是 --> E[新建 external
ShopHttpExchangeSupport bean
connectTimeout 10s+
HTTP/1.1 + TLS] Q1 -- 否 --> Q2{已经验证是
fan-out 热点?} Q2 -- 否 --> N[共享 internal bean
HTTP/1.1
readTimeout 按服务定制] Q2 -- 是 --> Q3{被调端容器
是 Tomcat?} Q3 -- 是 --> Y1[开 server.http2.enabled=true
+ TomcatHttp2AutoConfiguration] Q3 -- 否 --> Y2[开 server.http2.enabled=true
客户端零改动] Y1 --> Y3[验证 HttpResponse.version
= HTTP_2] Y2 --> Y3
6. 一句话速查#
| 问题 | 答案 |
|---|---|
| 共享一个 HttpClient 还是按服务隔离? | 通常先共享;外部 API / 不同 SLA / 不同 TLS 才隔离 |
| connectTimeout 怎么 per-service? | 不能直接 per-service;要不同 connectTimeout,通常需要新建 ShopHttpExchangeSupport bean |
| readTimeout 怎么 per-service? | createClient(url, class, readTimeout) 重载 |
| 内部用 h2c 吗? | 先保留 HTTP/1.1,验证过的 fan-out 热点再开 |
| h2c 服务端用什么? | Tomcat + TomcatHttp2AutoConfiguration(我会先不选 Undertow,SB4 已移除) |
| 客户端要做什么? | 大多数情况下不用额外做事,JDK HttpClient 会自动尝试 Upgrade 协商 |