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

1. 问题场景#

典型的 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)值不值得开?怎么避坑?

下面逐个讲清楚。

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

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

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

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 这里未必是默认需要的。

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 协商:

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 收益

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 边缘终止,服务间是明文:

  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,应用代码不变

5. 决策流程#

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

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 协商

相关阅读#