配套阅读:shop-starter-http-client 设计 介绍完整的 starter 设计;本文专注于其中"连接配置"这一块的细节决定。

问题场景#

典型的 BFF 模式:

graph LR BFF[buyer-bff] --> M[marketplace-service] BFF --> O[order-service] BFF --> L[loyalty-service] BFF --> P[promotion-service] BFF --> S[search-service] BFF -. 偶尔 .-> EXT[第三方支付 API]

每个下游的特征可能完全不同:marketplace 响应 50ms,搜索可能要 5s;payment 是外部 HTTPS API,order 是内部 cleartext;某个服务上了 HTTP/2,其他还是 HTTP/1.1。

三个绕不开的问题:

  1. 不同下游应该共用一个连接池,还是每个独立?
  2. connectTimeout / readTimeout 怎么按服务定制?
  3. 内部服务用 HTTP/2(h2c)值不值得开?怎么避坑?

下面逐个讲清楚。

连接池:共享还是隔离?#

我通常会先共享一个 java.net.http.HttpClient 实例

为什么很多时候先不需要隔离#

HttpClient 的连接复用天然按 origin 收敛#

Oracle 的 HttpClient API 文档明确写了“一个 HttpClient 实例通常管理自己的连接池”;连接对同一 origin 复用、不同 origin 分开这一点,可以再配合 OpenJDK 源码 一起看。对 marketplace-service:80order-service:80 的连接不会混在一个全局 bucket 里,所以“共享一个 HttpClient 实例”并不等于“所有下游抢同一组 socket”:

graph TB HC[一个 HttpClient 实例] HC --> P1["origin: marketplace-service
独立连接 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 这里未必是默认需要的。

真正需要隔离的场景#

场景 隔离粒度 原因
外部 API(支付、地图、第三方 webhook) 独立 HttpClient 实例 SLA 完全不同(外部 10s connect,内部 2s)
不同 TLS / 认证 独立 HttpClient 实例 SSLContext 不同;外部要 client cert,内部不要
流式 / 大响应体下游 独立 HttpClient 实例 回调消耗 internal executor,避免污染其他链路 callback
协议显著差异 独立 HttpClient 实例 比如某下游强制 prior-knowledge HTTP/2,需特殊 builder

⚠ 注意"想限制对某下游的并发数"通常不属于隔离场景,那更适合用 Resilience4j Bulkhead 这类工具来处理,而不是在连接池层面解决。

怎么隔离#

注入第二个独立配置的 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"两组。

超时:connectTimeout 和 readTimeout 的隔离边界#

两者的隔离粒度不同#

这是 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));

实战决策表#

情况 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。

为什么 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();  // 每个服务一个?错

内部服务调用的 HTTP/2#

默认 HTTP/1.1 还是 h2c?#

我的压测实验显示:

Workload h2c vs HTTP/1.1
单下游 / 小 payload / 低并发 边际收益(甚至 P95 更差)
Fan-out(同 origin 多并发) + 连接预算紧 +97% 吞吐 / -61% P95

我的做法:先保留 HTTP/1.1,对验证过的 fan-out 热点再开 h2c

客户端:大多数情况下不用额外做事#

Oracle 的 HttpClient.Builder#version 文档写得很明确:JDK HttpClient 默认偏好 HTTP/2;对 cleartext 端点,如果服务端支持 h2c,就会先走 Upgrade 协商:

sequenceDiagram participant C as JDK HttpClient participant S as Server (h2c enabled) C->>S: GET / HTTP/1.1
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 收益

服务端:选 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 服务自动生效。

验证客户端真的用了 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)。

没有 mTLS 时的实践建议#

如果内部网络没有 service mesh + mTLS,加密在 api-gateway 边缘终止,服务间是明文:

  1. 先默认 HTTP/1.1,所有服务出厂不开 h2c
  2. 按需开 h2c,只对验证过的 fan-out 热点(如 BFF → 同一服务多并发)开启,客户端不用改
  3. 服务端用 Tomcat + TomcatHttp2AutoConfiguration,较稳定且面向 SB4 兼容
  4. NetworkPolicy 收紧,h2c 没有传输层加密,更要 L4 白名单严格,明确"哪些 BFF 可以访问哪些 domain service"
  5. h2c 先不要出集群,公网入口应该用 h2 (HTTPS),h2c 仅限 ClusterIP 内部通信
  6. 逐步演进到 mTLS,团队规模允许时可以考虑引入 Linkerd(比 Istio 轻量),sidecar 接管 mTLS,应用代码不变

决策流程#

flowchart TD Q1{下游是
外部 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

一句话速查#

问题 答案
共享一个 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 协商

相关文章#