WebFlux 还是虚拟线程?微服务网关真实压测与选型终极博弈
在 Java 21 正式推出虚拟线程(Virtual Threads,即 Project Loom)后,后台开发圈子里兴起了一股“消灭响应式”的讨论。
许多饱受 WebFlux “全家桶”折磨的开发者高呼:“调试靠猜、日志靠蒙、代码像天书的响应式编程终于可以寿终正寝了!” 的确,能够用同步阻塞的直观代码,写出接近异步非阻塞的吞吐量,这听起来太诱人了。
但理想很丰满,现实很骨感。在微服务架构的“咽喉”—— API 网关(Gateway) 这一高并发、高 I/O 的典型场景下,虚拟线程真的能无脑替代 WebFlux 吗?
我们用一组真实的压测数据和底层机制剖析,聊聊这两者在网关场景下的真实博弈。
一、 架构模型的本质差异
在进入压测之前,必须先理清两者的底层线程模型。
1. WebFlux (基于 Netty 的事件循环模型)
- 线程数极少:默认通常只有与 CPU 核心数一致的 Work 线程(EventLoop Group)。
- 非阻塞 I/O:当请求到达网关,发起下游微服务调用时,Netty 注册一个回调后立即释放当前线程,去处理其他连接。下游响应返回后,触发回调,恢复上下文输出。
- 代价:代码必须高度响应式(
Mono/Flux),任何一步引入阻塞(比如一个不小心的Thread.sleep或传统的 JDBC 驱动),都会导致整个 EventLoop 瘫痪,整个网关直接卡死。
2. Virtual Threads (基于 JVM 调度的轻量级线程)
- 线程数极多:可以轻松创建百万个。
- 同步阻塞体感:代码写起来是标准的
RestTemplate.getForObject()或Feign这种一问一答的同步模式。 - JVM 级调度:当虚拟线程遇到 I/O 阻塞(如 Socket Read/Write)时,JVM 会自动将该虚拟线程从底层的平台线程(Carrier Thread)上“卸载”(Yield),让出物理线程。当 I/O 就绪后,JVM 重新将它“挂载”到某个平台线程上继续运行。
- 代价:内存中需要维护大量的虚拟线程栈内存,调度本身存在一定开销。
二、 真实性能压测对比
为了模拟真实的微服务网关场景,我们搭建了以下实验环境:
- 硬件环境:AWS 4C 8G 虚拟机(网关服务),独立压测机(JMeter/wrk)。
- 下游服务:一个简单的 Spring Boot 服务,人为引入 50ms 的固定的业务处理延迟(模拟微服务间调用网络和业务损耗)。
- 网关实现 A:Spring Cloud Gateway (基于 WebFlux + Netty),运行于 JDK 21。
- 网关实现 B:基于 Spring MVC 开启虚拟线程(
spring.threads.virtual.enabled=true),使用 Tomcat 作为 Servlet 容器,使用 HTTP 客户端转发请求。
压测场景:并发用户数(Concurrency)从 200 递增到 5000,持续压测。
1. 吞吐量对比 (QPS)
| 并发连接数 (Concurrency) | WebFlux (Netty) QPS | MVC + 虚拟线程 QPS | 备注说明 |
|---|---|---|---|
| 200 | ~3,950 | ~3,910 | 差异极小,均能跑满下游 |
| 1000 | ~18,500 | ~17,900 | 虚拟线程 CPU 占用略高 5% 左右 |
| 3000 | ~22,100 | ~19,500 | 虚拟线程吞吐量开始出现轻微抖动 |
| 5000 | ~24,800 | ~16,200 | 虚拟线程 QPS 出现下滑,伴随较多 Connection Timeout |
2. 平均响应时间与 P99 延迟 (ms)
在 3000 并发下,两者的延迟分布如下:
- WebFlux: Average: 54ms, P99: 68ms
- Virtual Threads: Average: 61ms, P99: 115ms
3. 内存与 GC 表现
- WebFlux: 内存曲线非常平滑,堆内存基本维持在 1.5G 左右,主要是 Netty 的 Direct Memory 稳定。GC 频率极低。
- Virtual Threads: 内存呈锯齿状,随着并发增高,堆内存迅速被拉升至 4G+。Young GC 频率大幅上升,这是因为几万个并发请求意味着同时存在几万个虚拟线程对象及其调用栈,瞬间产生了大量的临时对象。
三、 为什么虚拟线程在高并发网关下掉链子了?
压测结果显示,在极限高并发下,基于虚拟线程的 MVC 方案无论是吞吐量还是延迟,都落后于 WebFlux。原因在于虚拟线程的几个“致命隐形地雷”:
地雷一:线程钉死 (Thread Pinning)
在网关中,我们难免会用到一些鉴权组件(如 Spring Security、JWT 解析)、日志组件(Logback)或某些底层的 C++ 库。
如果这些库的底层使用了 synchronized 块或 native 方法,当虚拟线程在其内部遇到阻塞时,它无法从平台线程(Carrier Thread)上卸载。
// 导致 Thread Pinning 的典型结构
public synchronized String callDownstream() {
// 如果这里有网络 I/O,底层的 Carrier Thread 将被牢牢锁死!
return httpClient.get();
}
一旦物理线程(默认等同于 CPU 核心数,4核即 4 个线程)全部被“钉死”,整个 JVM 就会陷入饥饿状态,后续所有的虚拟线程都无法得到调度,表现出来的现象就是网关响应超时、大面积报错。
(注:虽然可以通过 -Djdk.tracePinnedThreads=full 调试并将其改造为 ReentrantLock,但在复杂的微服务生态体系下,你很难保证引入的每一个第三方 SDK 都没有使用 synchronized)。
地雷二:缺乏天然的背压(Backpressure)机制
WebFlux 底层基于 Reactive Streams,天然支持背压。当下游服务过载、响应变慢时,WebFlux 可以通过向上游发送信号减缓发号速度,或者利用 Netty 的高低水位线限制读取。
而虚拟线程本质上是“来一个请求,起一个线程”。如果下游服务发生雪崩,响应时间从 50ms 飙升至 5s,在没有限流的前提下:
- WebFlux 依然只有那几个 EventLoop 线程,只是队列里堆积了事件,不会爆内存。
- 虚拟线程 网关会在瞬间堆积数十万个虚拟线程,每个线程都带着自己的上下文和缓冲区,直接把网关的 JVM 堆内存撑爆,造成 OOM 连锁雪崩。
四、 微服务网关场景下的选型深思
如果你们正在规划下一代微服务网关,或者面临技术升级,该如何抉择?
1. 为什么“Spring Cloud Gateway 依然坚守 WebFlux”?
很多人期待 Spring Cloud Gateway 能出一个“虚拟线程版”,但实际上:
- Spring Cloud Gateway 的核心就是基于 Project Reactor 和 Netty 构建的。它的各种 Filter(限流、鉴权、路由)全是响应式链路。
- 把一个完全响应式架构的项目重构成基于虚拟线程的阻塞架构,不亚于把车头拆了重装,且性能并不会提升,反而可能因为上述的 Pinning 和内存抖动问题导致网关稳定性下降。
因此,如果你选择使用 Spring Cloud Gateway,请继续老老实实用 WebFlux,不要乱改线程配置。
2. 什么时候可以考虑使用虚拟线程做网关?
如果你们团队:
- 极度排斥响应式编程,宁可损失 15% 的极限性能,也希望代码易读、易维护、易排查问题(能直接看懂 Stacktrace 里的异常栈)。
- 你们打算自研轻量级网关,或者使用 Spring MVC + Tomcat,简单实现一个代理路由。
- 网关内没有复杂的计算逻辑,且你已经排查过所有依赖,确保没有
synchronized引起的 Thread Pinning 问题。
此时,开启虚拟线程是一个不错的折中方案。你只需要在 application.yml 中配置:
spring:
threads:
virtual:
enabled: true
并配合使用 ReentrantLock 替换 synchronized,使用 Semaphore 替代传统线程池来进行资源隔离和限流,防止瞬间高并发拖垮网关。
五、 总结:架构师的决策树
不要神话虚拟线程,也不要妖魔化 WebFlux。
你的团队能熟练编写和调试
Reactive (Mono/Flux) 代码吗?
/ \
(是) (否)
/ \
直接使用 WebFlux / 你是否正在使用
Spring Cloud Gateway Spring Cloud Gateway 作为网关?
(生产环境最稳妥方案) / \
(是) (否)
/ \
继续使用 WebFlux, 选择 Spring Boot 3 + MVC +
在 Filter 中避免阻塞 开启 Virtual Threads 自研网关
(注意排查 synchronized 与 OOM)
虚拟线程的伟大之处,在于它解放了长尾业务应用(如复杂的业务微服务、传统的 ERP 系统、CRUD 接口)的并发能力,让我们不用再写复杂的异步代码。
但在微服务网关、消息中间件代理等处于网络最前沿、充斥着极端吞吐和反向压力控制的场景下,基于事件驱动的响应式网络引擎(如 Netty),依然是不可撼动的王者。