Spring Boot 3.5 开启 h2c 后,真的比 HTTP/1.1 更快吗?一次完整压测实验复盘
目录
如果你还没理清 h2 和 h2c 的区别,可以先看我的基础概念篇。
这一篇不再讲概念,而是回答一个更实际的问题:
Spring Boot 3.5 把 h2c 打开以后,真的会比 HTTP/1.1 更快吗?
我在自己的 Shop Platform 概念验证(POC)项目里,围绕 buyer-bff -> marketplace-service 做了一整轮压测。这次实验全程基于 Java 25 (LTS) 并在 Spring Boot 中开启了 虚拟线程 (Virtual Threads)。
2026-04 实践更新 当前主线仓库里,BFF 的下游调用已经进一步演进成
@HttpExchangetyped clients;因此本文关于 h2c / HTTP/1.1 的性能讨论依然成立,但调用栈样例请理解为“传输层对比实验”,不再代表今天 main 分支的全部客户端装配细节。
先说这次实验里的几个观察:
- 不会天然更快
- 但在特定 workload 下,的确可能更快
- 关键不在“有没有开 h2c”,而在“你的请求模型有没有真的吃到多路复用”,以及虚拟线程在高并发 I/O 场景下会不会放大这件事
这篇文章就是这次实验的完整复盘。
实验背景#
这个项目本身是一套基于 Spring Boot 3.5 + Java 25 的微服务电商平台 POC。BFF 层会聚合多个下游领域服务,非常适合拿来验证协议层优化。
在 Java 25 这一最新的 LTS 版本中,虚拟线程的调度已经非常成熟。我在所有服务中都开启了:
spring.threads.virtual.enabled=true
这意味着传统的 I/O 阻塞不再像以前那样突出,压力点更多转移到了网络协议栈和连接模型上。
这次实验的目标分成两步:
- 验证真实业务接口:把内部调用从 HTTP/1.1 切到 h2c,看看它是不是“自动变快”
- 设计一个正例:如果第一步没有明显优势,就专门构造一个更容易体现 h2c 多路复用价值的场景
实验链路如下:
k6 -> buyer-bff -> marketplace-service
其中:
buyer-bff使用 Spring 的JdkClientHttpRequestFactory,底层就是 JDK 自带HttpClientmarketplace-service是 Spring Boot 3.5 应用- 压测时直连
buyer-bff,绕过 gateway,避免把边缘层噪音混进协议对比里
先说一个坑:Tomcat h2c 在这条链路里并不稳定#
这次实验里,最先撞到的并不是“性能差”,而是直接报错。
marketplace-service 一开始跑在 Tomcat 上,JDK HttpClient 走 h2c Upgrade 后,在并发流场景下会稳定触发:
org.apache.coyote.http2.StreamException:
Client sent more data than stream window allowed
而且这个问题不是 Tomcat 10.1 独有,升级到 Tomcat 11.0.20 依然能复现。这通常是由于 HTTP/2 的 流量控制 (Flow Control) 机制在 Client 端的滑动窗口与 Server 端窗口不一致导致的,尤其是在大数据量、高并发场景下,Tomcat 的默认窗口管理策略在我这组实验里和 JDK HttpClient 配合得不太顺。
最终我把 marketplace-service 改成了 Undertow,在这个实验环境里,Undertow 的 h2c 表现更稳,这才把 buyer-bff -> marketplace-service 的 h2c 链路稳定到 0% 错误率。
如果这一步不先稳定下来,后面的压测结论就没有太大意义——因为你测到的只是 transport error,不是协议收益。
实验一:真实业务接口,h2c 并不会自动赢#
修掉 Tomcat 问题之后,我先测了一个很“普通”的真实业务 workload:
buyer-bff每次请求只触发 1 次 下游调用- 请求体很小
- 同源并发也不高
压测结果如下:
| 指标 | HTTP/1.1 | h2c | 变化 |
|---|---|---|---|
| p50 latency | 6.19 ms | 6.17 ms | -0.25% |
| avg latency | 8.55 ms | 7.64 ms | -10.59% |
| p95 latency | 16.42 ms | 18.69 ms | +13.84% |
| 吞吐量 | 142.8 req/s | 145.2 req/s | +1.65% |
| 错误率 | 0.00% | 0.00% | 0 |
这个结果对我来说更像一个提醒:
- 平均延迟和吞吐量确实略有提升
- 但提升幅度并不大
- p95 反而更差
至少在这组 workload 里,这说明了一件事:
h2c 不是开了就自动起飞。
如果你的请求本来就只有一次小下游调用,那么 HTTP/2 最核心的两个优势——多路复用和头部压缩——根本没吃满。
这也是为什么我会觉得 Stack Overflow 上“h2c 可能比 HTTP/1.1 慢”的讨论是有道理的:
不是 h2c 不行,而是它对 workload 非常敏感。
一个容易被忽视的事实:JDK HttpClient 默认走的是 Upgrade,不是 Prior Knowledge#
做这轮实验之前,我最初还误以为 JDK HttpClient 走 h2c 时默认是 Prior Knowledge。
后来查 JDK 行为和实际链路,结论是:
- JDK
HttpClient在这次实验链路里默认走 HTTP/1.1 Upgrade - 也就是先发 HTTP/1.1 请求,带
Upgrade: h2c - 服务端接受后再切到 HTTP/2
这意味着:
- 你不能简单把“h2c”想象成“始终是纯粹的 HTTP/2 长连接”
- 握手、升级、连接复用策略,都会影响最终表现
所以真正做实验时,客户端实现细节和服务端容器实现细节一样重要。
实验二:我刻意设计了一个 h2c 的正例#
既然真实业务 workload 只能说明“h2c 不一定更快”,那如果想验证 h2c 在某些场景下可能更快,我就得重新设计实验。
我最后选择了一个很适合放大多路复用优势的模型:
设计目标#
- 同一外部请求里,要并发打多个下游请求
- 这些下游请求需要落到同一个 origin
- 还要给 HTTP/1.1 一个明确的连接预算约束
于是我加了一个仅在 load-test profile 下启用的端点:
POST /buyer/v1/experiments/h2c/marketplace-burst
它的行为很简单:
- 每次外部请求,buyer-bff 并发发起 4 个 对
marketplace-service的CATEGORY_LIST调用
同时,我给 buyer-bff 注入:
-Djdk.httpclient.connectionPoolSize=1
这一步非常关键。
它构造了一个“连接预算受限”的客户端环境:
- 对 HTTP/1.1 而言,连接池预算非常小,多请求会更容易排队
- 对 HTTP/2 而言,同一连接上可以复用多个 stream,这个限制不会以同样的方式卡住它
换句话说,我不是靠“增大 payload”或者“硬加延迟”去凑结果,而是直接把 HTTP/2 最擅长的事情——单连接并发复用——拉出来单独验证。
正例结果:这次 h2c 确实明显赢了#
最终我选定了一组稳定、可复现、而且两边都 0% 错误 的参数:
fanout=42 VUs5s ramp + 20s steadybuyer-bff使用-Djdk.httpclient.connectionPoolSize=1
结果如下:
| 指标 | HTTP/1.1 (connectionPoolSize=1) |
h2c (connectionPoolSize=1) |
变化 |
|---|---|---|---|
| p50 latency | 6.09 ms | 3.48 ms | -42.92% |
| avg latency | 8.56 ms | 4.30 ms | -49.70% |
| p90 latency | 12.95 ms | 7.17 ms | -44.63% |
| p95 latency | 22.93 ms | 8.92 ms | -61.09% |
| 吞吐量 | 201.88 req/s | 397.90 req/s | +97.10% |
| 错误率 | 0.00% | 0.00% | 0 |
至少在这组参数下,这个表已经能看出差别:
- h2c 吞吐量接近翻倍
- 平均延迟几乎减半
- p95 下降超过 60%
这次 h2c 的优势不太像“玄学调优”,而是比较符合协议预期:
HTTP/1.1 在连接预算受限时,更容易把并发请求变成排队。
HTTP/2 则能在同一条连接上并行处理多个 stream。
为什么最终正例只保留了 30 秒窗口?#
这里我专门解释一下,免得看起来像“挑了一组最漂亮的数据”。
我当然尝试过更长时间、更高压力的版本,比如更长 steady state、更高 fan-out、更强连接限制。
结果是:
- h2c 一侧通常还能继续稳定
- 但 HTTP/1.1 一侧在过强约束下会逐渐掉进 JDK
HttpClient的 request cancelled 区间
那种数据不适合拿来做最终“0 错误对比”,因为它已经从“协议收益对比”变成了“极限约束下的客户端行为对比”。
所以最后我保留的是:
- 已经验证 0% 错误
- 仍然足够体现 h2c 优势
- 而且可重复执行
的 30 秒窗口。
我认为这比拿一组更“夸张”但带错误的结果更诚实。
这次实验给我的几个结论#
1. Spring Boot 3.5 把“开启 h2c”变简单了,但没有把“验证 h2c 值不值得开”变简单#
配置层面,Spring Boot 3.5 让 h2c 真的轻松很多。
但真实问题在于:
- 你的客户端是不是支持并复用 HTTP/2
- 服务端容器实现稳定不稳定
- 你的 workload 有没有真的命中多路复用收益
换句话说,配置简单,不代表结论简单。
2. “h2c 会更快”不够准确,“h2c 在某些场景下会更快”更贴近实验结果#
如果你的链路是:
- 单下游、小 payload、低并发、没有连接预算压力
那 h2c 很可能只会带来边际收益,甚至在 tail latency 上更差。但如果你的链路涉及到 Fan-out 模型 且需要处理大量并发微请求,h2c 的单连接复用特性和 Java 25 的虚拟线程配合起来会比较顺。
3. K8s 集群的工程收益:减少 TCP 连接数#
这可能是比延迟下降更有意义的一点:在 K8s 等高密度容器环境下,Pod 之间的 TCP Connection Churn(连接抖动)和 Established Connections 数量本身就是需要考虑的资源。
使用 h2c 后,我们有机会通过更少的 TCP 连接支撑更高的请求并发,这在理论上会降低集群内的网络资源消耗,也可能减轻负载均衡器的压力。
4. 如果没有明确理由,我目前仍会先保留 HTTP/1.1#
做完这轮实验后,我的倾向是:先保留 HTTP/1.1,把 h2c 作为可切换能力。只有当你明确分析出自己的 workload 存在多路复用空间,或者你确实受到 TCP 连接数过高的困扰时,再按需开启它。
相关阅读#
如果你想自己复现#
我这次实验最终保留了两组可复现脚本:
# 真实业务 workload 基线
bash experiments/h2c/run-baseline.sh
# 真实业务 workload h2c
bash experiments/h2c/run-experiment.sh
以及正例:
# HTTP/1.1 正例
BUYER_JDK_JAVA_OPTIONS='-Djdk.httpclient.connectionPoolSize=1' \
LOAD_TEST_SCRIPT=experiments/h2c/marketplace-burst-test.js \
HEALTHCHECK_PATH=/buyer/v1/experiments/h2c/marketplace-burst \
HEALTHCHECK_BODY='{"fanout":4,"headerBytes":0}' \
FANOUT=4 TARGET_VUS=2 RAMP_SECONDS=5 STEADY_SECONDS=20 SLEEP_SECONDS=0 \
bash experiments/h2c/run-baseline.sh
# h2c 正例
BUYER_JDK_JAVA_OPTIONS='-Djdk.httpclient.connectionPoolSize=1' \
LOAD_TEST_SCRIPT=experiments/h2c/marketplace-burst-test.js \
HEALTHCHECK_PATH=/buyer/v1/experiments/h2c/marketplace-burst \
HEALTHCHECK_BODY='{"fanout":4,"headerBytes":0}' \
FANOUT=4 TARGET_VUS=2 RAMP_SECONDS=5 STEADY_SECONDS=20 SLEEP_SECONDS=0 \
bash experiments/h2c/run-experiment.sh
最后一句话#
这次实验让我对 h2c 的态度变得更“工程化”了。
它不是银弹,也不是噱头。
如果你的链路没有并发复用需求,它未必值回票价;如果你的链路刚好卡在连接与并发模型上,它又可能是最便宜的一次提速。
真正值得做的,不是争论“HTTP/2 有没有用”,而是先把自己的 workload 讲明白。