如果你在2023年前后接过高并发服务,大概率和我一样,被Spring WebFlux的响应式编程折腾得不轻。JDK 21的虚拟线程转正之后,我发现团队里最费劲的那套Mono/Flux代码,终于可以用同步写法重写一遍,而且高并发能力不降反升。这篇文章就记录我们如何把一套Spring WebFlux高并发服务平滑迁移到JDK 21虚拟线程上,包括选型理由、改造步骤、压测数据以及那些文档里不会写的坑。目标读者是正在WebFlux和虚拟线程之间犹豫的Java后端开发,以及准备做迁移选型的技术负责人。
1. 为什么要把WebFlux服务搬到虚拟线程:一次压测之后的反思
先聊一个真实感受。当时我们做IM消息推送系统的接入层,峰值在线连接大几千,单机需要扛住每秒上万次HTTP/WebSocket消息请求。2020年那个时间点,Spring WebFlux几乎是“高并发Java后端”的标准答案——Reactor Netty用少量线程支撑海量连接,和Tomcat动辄几百个阻塞线程的方案相比,显得特别“先进”。
于是我们花了小半年时间,把团队的口味硬生生拗成了响应式:Controller返回Mono、Service里到处都是flatMap、数据库访问要折腾R2DBC、调用下游HTTP必须用WebClient。上线跑起来确实稳,但这套代码给后续维护挖了无数坑。
1.1 响应式带来的收益与代价
响应式的好处今天不用再多吹,事件循环用几个线程就能扛住大量慢请求,这是它最核心的价值。代价却是实打实的:业务代码被拆成回调链,调试时看到一堆重复的MonoFlatMap栈帧;新人入职两个月还在问“这里为什么要zip两个流”;日志跟踪要在context里塞correlationId,稍微写错一个chain,ID就丢了。
更重要的是,WebFlux把“高并发”这个技术问题,转换成了“团队能不能适应响应式思维”的管理问题。不是所有人都有耐心去理解背压、订阅、调度器的。多数业务场景其实就是“查一次库、调一次远程、返回结果”,用响应式写只是在绕远路。
我用一个简单例子说明这种“绕远路”。假设用户详情接口里有一个数据库查询,响应式写法一般是这样:
@GetMapping("/users/{id}") public Mono<ResponseEntity<User>> getUser(@PathVariable Long id) { return userRepository.findById(id) .map(Optional::get) .map(user -> ResponseEntity.ok(user)) .onErrorResume(e -> Mono.just(ResponseEntity.status(500).build())); }这段代码在熟练工眼里很简单,可它已经隐含了不少概念:Mono是冷厂商还是热厂商、map在Reactive Streams里是同步1:1转换、异常要手动转成响应……而业务同样是这个逻辑,同步写法就是普通的方法调用,谁都能读。
1.2 虚拟线程:重新定义“连接/请求”与线程的比例
JDK 21虚拟线程出来以后,我一直想做一件事:把WebFlux服务迁回同步模型,但又不想丢掉高并发能力。虚拟线程让我看到了这种可能性。
直接说原理。虚拟线程是JVM级别的轻量线程,它底下的“载体”是真正干活儿的平台线程。虚拟线程执行到阻塞操作(比如JDBC等待数据库返回、Socket读数据)时,JVM会自动把这个虚拟线程挂起,让出下面的平台线程,让别的虚拟线程继续跑。也就是说,一个平台线程可以“穿梭接待”成千上万个虚拟线程,等待I/O这件事几乎不再占资源。
打一个比较容易理解的比方:平台线程像一个办理窗口的柜员,而虚拟线程是排队叫号的客户。以前一个请求占用一个柜员,柜员干活的时候窗口不能接待别人;虚拟线程模型下,柜员可以在客户低头翻资料的空档里,先去帮下一个客户办业务。窗口没有变多,但利用率拉满了。
这个模型对写代码的人来说意味着什么?意味着我们可以回到最传统的“一个请求一条线程,代码从上往下写”的方式。因为创建虚拟线程几乎不消耗资源,服务可以放心地为每个请求都开一条虚拟线程,业务方法里该sleep就sleep,该等待数据库就等待数据库,JVM会自动把所有等待时间压缩到最小的平台线程集合里。
所以,迁移的本质不是“在WebFlux里用虚拟线程”,而是用虚拟线程重建一套同步请求处理链路,替代掉之前为了省线程而引入的异步回调模型。
2. 迁移前期的技术选型与兼容性盘点
任何迁移最怕拍脑袋。我们对这次搬迁做了三件事:核对版本、盘点“响应式异味”代码、划分风险边界。
2.1 版本矩阵:JDK 21、Spring Boot 3.2和容器选型
虚拟线程从JDK 19开始孵化,JDK 21正式转正。Spring Boot这边,从3.2开始内置了对虚拟线程的开关配置,这一步省掉了很多手工接入Executor的活。我的建议版本组合如下:
| 组件 | 建议版本 | 说明 |
|---|---|---|
| JDK | 21 LTS | 必须21及以上,虚拟线程转正 |
| Spring Boot | 3.2.5+ 或 3.3.x | 支持spring.threads.virtual.enabled=true |
| Servlet容器 | Tomcat 10.1+ / Jetty 11+ | Spring Boot默认内嵌容器,Tomcat对虚拟线程支持已是标配 |
| 数据库访问 | JDBC + HikariCP | 从R2DBC迁回JDBC最省事 |
| Redis | Lettuce同步API | 不再用ReactiveRedisTemplate |
| HTTP客户端 | RestClient / OkHttp | 替换WebClient |
这里有一个容易忽略的点:Spring Boot 3.2以上版本才支持“等于True即可把Tomcat的请求线程池换成虚拟线程”这个简单开关。如果项目还在Spring Boot 2.7,你要么先升Boot版本,要么自己写一个SimpleAsyncTaskExecutor并自定义ByteBuddy的拦截器,成本会高很多。
另外,如果你们还有其他依赖大量使用Java反射、字节码增强(比如老版本Hibernate、CGLIB代理),需要先在JDK 21上跑一遍全量回归。虚拟线程不要求改这些,但JDK 21本身对旧的Class File版本、某些强依赖sun.misc.Unsafe的库会有兼容波动,提前排查比迁到一半再炸要好。
2.2 盘点代码里的“响应式气味”
迁移前,先扫一遍项目里有多少响应式代码。我推荐把下面这些特征当作“响应式气味”清单:
- 方法返回类型是
Mono<T>或Flux<T> - 方法链里大量出现
flatMap、zip、concatMap - 使用
WebClient调用下游HTTP服务 - 使用
ReactiveRedisTemplate、R2dbcRepository - 使用
@Tailable等响应式特定注解 - 显式使用
Schedulers.boundedElastic()
我们扫完发现,一个典型WebFlux服务里真正依赖Reactor高级特性的代码很少。大多数Controller的方法只是返回Mono.just(...)来包装一个同步结果,或者把JDBC阻塞调用塞进subscribeOn(Schedulers.boundedElastic())。真正玩背压、Flux.interval、窗口聚合的业务模块,往往只是日志流和消息推送那几块。
所以迁移时不需要“完全抛弃Reactor”,而是把它限制在真正需要流式处理的模块里。我们的目标服务是普通的HTTP接口网关,90%的代码都只是“响应式装饰”,没有内在的流式需求。这类代码迁移成本最低,收益却最明显。
2.3 风险面评估:分服务迁移,别在同一进程里混搭
这里要提醒一个坑:Spring Boot应用里,spring-boot-starter-web和spring-boot-starter-webflux同时存在时,Spring MVC默认优先启动,WebFlux的自动配置会被禁用,除非你显式把spring.main.web-application-type=reactive设回去。也就是说,一个进程里很难优雅地让同步接口和响应式接口长期共存。
所以我们把“无缝迁移”定义在服务维度,而不是接口维度。先挑一个业务简单、链路短、I/O占比高的服务(比如用户信息查询服务)做试点,迁移完成后对外发布新版本,压测通过后再滚动迁移其他服务。
这种做法的好处是出错域可控。虚拟线程模型下,如果上游服务出问题,影响范围只限这个单独的应用,不会把整个系统搅成一锅粥。等两三个服务迁移完,团队积累出套路,后面的速度就快了。
3. 从WebFlux到虚拟线程的四个关键改造点
我们实际迁移的时候,没有做“大爆炸式”重写,而是按四个关键位置逐个替换。掌握了这四个点,基本就掌握了这类迁移的骨架。
3.1 依赖切换:替换starter
第一步先把spring-boot-starter-webflux替换成spring-boot-starter-web。注意不是把webflux直接删了,因为项目里可能还有reactor-test之类的测试依赖,要一起处理。以Maven为例,改动长这样:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>删掉的依赖:
<!-- 移除 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>io.projectreactor</groupId> <artifactId>reactor-test</artifactId> <scope>test</scope> </dependency>这里要注意:如果项目里有其他Spring Cloud组件自动引入了WebFlux(比如Spring Cloud Gateway),那就不能简单移除,得先把网关这类组件剥离出去,或者单独保留。我们的迁移服务是纯内部接口服务,没有这个问题,所以一步到位。
替换依赖后,启动一次应用,大概率会遇到类冲突或者找不到WebClient、Mono的编译错误。这是好事,它会帮你快速列出所有需要改的调用点。
3.2 接口层重写:从Mono到同步返回
依赖切换完,最核心的重写出现在Controller层。还是用用户详情接口举例,原来的响应式写法已经提过,改成虚拟线程下的同步写法:
@GetMapping("/users/{id}") public ResponseEntity<User> getUser(@PathVariable Long id) { try { User user = userService.findById(id); return ResponseEntity.ok(user); } catch (UserNotFoundException e) { return ResponseEntity.notFound().build(); } catch (Exception e) { return ResponseEntity.status(500).build(); } }是不是比之前顺眼多了?try/catch替换onErrorResume,方法返回值从Mono<User>变成User,Spring MVC会自动序列化成JSON。对于Service层,凡是之前返回Mono<T>的,直接改成返回T;原来用Mono.zip组合多个查询结果的,改成顺序调用:
// 之前(响应式) return userService.findById(userId) .zipWith(orderService.findLatestOrder(userId)) .map(tuple -> new UserDetail(tuple.getT1(), tuple.getT2())); // 之后(同步) User user = userService.findById(userId); Order order = orderService.findLatestOrder(userId); return new UserDetail(user, order);这个改动本质上是在“翻翻翻译”。但实际操作中要注意,很多响应式写法里都有异步并发调用,比如Mono.zip里两个查询是同时发出去的,如果改成顺序调用,整体延迟会变长。虚拟线程下要保留并发,可以用CompletableFuture来组织:
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() -> userService.findById(userId)); CompletableFuture<Order> orderFuture = CompletableFuture.supplyAsync(() -> orderService.findLatestOrder(userId)); User user = userFuture.join(); Order order = orderFuture.join();这样既保持了同步可读的代码,又保留了并发能力。CompletableFuture在线程池里干活,虚拟线程在请求线程里join等待,两个操作仍然是并行的。
3.3 启用虚拟线程与容器参数调整
Spring Boot 3.2之后,启用虚拟线程对Tomcat来说异常简单,只要在application.yml里加一行:
spring: threads: virtual: enabled: true这个配置开启后,Tomcat的请求处理线程池会被替换成虚拟线程执行器。也就是说,每个HTTP请求到来时,容器不再从平台的“线程池”里拿一个有限的Tomcat线程,而是创建一个虚拟线程来处理请求。压测时你去看JVM线程数,会发现线程数可以几万甚至几十万,但操作系统线程数始终只有几十个。
但别忘了还有几个参数需要重新审视。首先是server.tomcat.max-threads这东西,在虚拟线程模式下基本失效,因为Tomcat不会再去那个固定池子里取线程了。真正还管用的是server.tomcat.max-connections(连接器接受的TCP连接数)和server.tomcat.accept-count(等待队列长度):
server: tomcat: max-connections: 20000 accept-count: 4000 max-threads: 200 # 这个配置在虚拟线程开启后基本无意义,但保留不影响max-connections决定的是可以同时建立多少TCP连接,accept-count是队列长度,这两个值直接决定了峰值连接承受能力。我们当时压测到500并发连接的时候,虚拟线程版本毫不在意,因为给每个请求分配虚拟线程的成本近乎为零。
还有一种场景需要注意:如果你的应用仍然依赖Spring的@Async异步方法,Spring Boot 3.2默认也会让@Async跑在虚拟线程上吗?答案是不会。spring.threads.virtual.enabled=true影响的只是Web容器和某些自动配置的TaskExecutor。想让@Async也走虚拟线程,需要额外定义:
@Bean public AsyncTaskExecutor applicationTaskExecutor() { return new SimpleAsyncTaskExecutor(); // 默认非虚拟线程 }更好的做法是显式声明一个虚拟线程执行器:
@Bean(name = "virtualThreadExecutor") public Executor virtualThreadExecutor() { return Executors.newVirtualThreadPerTaskExecutor(); }然后@Async("virtualThreadExecutor")使用。别看这个小细节,很多迁移后异步接口莫名报错或者并发性能没提升,都是这里没有同步改造导致的。
3.4 数据访问层与外部调用适配
数据访问是迁移中最容易翻车的地方。WebFlux项目一般会引入R2DBC(响应式JDBC),迁移回虚拟线程时,我建议直接换回传统的JDBC + JPA/MyBatis。原因很直接:虚拟线程的优势正是让旧式JDBC的阻塞等待变得廉价,你再回到JDBC,就能同时拿到同步代码的简单和传统生态的成熟。
如果你是逐步迁移,不想一次性把Repository全部改掉,可以暂时保留R2DBC,但要在虚拟线程里调用响应式Repository的block()方法。这是最省事的过渡方案:
Mono<User> userMono = userRepository.findById(id); User user = userMono.block(Duration.ofSeconds(3));注意,这个block()放在WebFlux的Netty事件循环线程里是禁忌,因为事件循环线程数量少,一旦资源耗尽,整个服务就卡死。但迁移到虚拟线程之后,block()发生在虚拟线程上,底层的平台线程会在等待阶段被释放,所以这个操作是安全且合理的。当然,长期运行还是建议彻底换成同步Repository,毕竟block()会让响应式链路的取消、背压语义都变成不可控的黑盒。
Redis同理。之前用ReactiveRedisTemplate的操作,直接换成RedisTemplate的同步方法。Lettuce本身就支持同步和响应式两套API,切换到同步API几乎零成本。HTTP调用也是,WebClient.builder().build()建的Client,如果改不动,可以在虚拟线程里对Mono调用block(),但更推荐换回Spring 6.1推出的RestClient,那东西写起来更直观:
RestClient restClient = RestClient.builder().baseUrl("https://api.example.com").build(); User user = restClient.get() .uri("/users/{id}", id) .retrieve() .body(User.class);到这里,核心改造就完成了。改造不是“替换”,而是“把异步回调翻译成同步业务逻辑”,翻译完了之后,虚拟线程承担了原来Reactor调度器干的活。
4. 压测结果与性能对比:并发翻倍还是只是心理安慰?
很多团队迁移前最担心的就是:同步代码真的能干过响应式吗?我们决定用数据说话。
4.1 压测环境和对比方法
服务器:8核16G,JDK 21,部署在容器里,限制CPU为4核,避免物理机扰动。压测工具我们用的是Gatling,模拟HTTP请求,压测时长10分钟,先预热1分钟再计数。被测服务是重构后的用户查询接口,内部会调用一个远程服务模拟200ms外部I/O等待,再加一次Redis读取,和一次本地MySQL查询。
两个版本分别运行:
- v1:Spring WebFlux + Reactor Netty,16个事件循环线程,400个
boundedElastic线程池用于阻塞调用。 - v2:Spring MVC + 虚拟线程,Tomcat,
spring.threads.virtual.enabled=true。
压测指标记录吞吐量(RPS)、P99延迟、JVM活动线程数、容器外操作系统线程数。
4.2 数据解读:吞吐、延迟、线程数、内存
压测结果整理成表格:
| 场景 | WebFlux | 虚拟线程 |
|---|---|---|
| 50并发吞吐 | 5,243 req/s | 5,187 req/s |
| 50并发 P99 | 121ms | 98ms |
| 200并发吞吐 | 12,860 req/s | 14,635 req/s |
| 200并发 P99 | 238ms | 156ms |
| 500并发吞吐 | 16,400 req/s | 21,120 req/s |
| 500并发 P99 | 452ms | 231ms |
说实话,50并发的时候两者几乎一样,虚拟线程还略低一丝。但到了200并发和500并发,虚拟线程版本的吞吐量优势越来越明显,P99延迟更是直接降低一半。原因也好解释:WebFlux在阻塞调用时需要把任务切给boundedElastic线程池,线程池数量有限,当外部I/O慢、等待时间长,任务就在线程池排队;虚拟线程则没有这个限制,每个阻塞操作挂起后立刻让出载体,4060个并发请求都不会排队。
当时最让我惊讶的是线程数指标。WebFlux版本在500并发时JVM活跃线程数大概有1000出头,真正干活的OS线程保持在100以内。虚拟线程版本JVM活跃线程数直接飙升到10几万,但OS线程还是不到100。我特别看了内存:虚拟线程对象自身占内存,每个虚拟线程带栈大概几KB到十几KB,16G内存下开10万个毫无压力。
4.3 压测中容易忽略的点:连接池水位和下游限流
压测数据并不能直接解释为“以后可以无限加并发”。我们同时调整了数据库连接池和Redis连接池。HikariCP默认10个连接,虚拟线程版本在500并发时很快就会发现线程全部在队列里等数据库连接,吞吐不升反降。调大HikariCP的maximum-pool-size之后,吞吐才稳住。
这个现象要单独拿出来说:虚拟线程能解决的是“请求线程不够用”的问题,并不能解决“数据库连接不够用”的问题。数据库连接是从连接池里借出来的真正的系统资源,连接数上限是固定的,虚拟线程再便宜也得排队等连接。所以迁完之后,一定要做一次连接池水位检查。压测时观察HikariCP的active、wait两个指标,如果wait经常大于0,说明连接池该扩容了。
下游服务也一样。如果你在接口里用虚拟线程并发去调三个下游,每个下游每秒只能承受1000请求,那即使虚拟线程无限多,吞吐上限还是被下游限死在3000。虚拟线程只是提高了“等待”的效率,不会提高“结果的生产速度”。
5. 迁移中踩过的坑和规避手法
这次迁移整体算顺利,但过程中还是踩了不少坑,写出来可以帮后来人省几天时间。
5.1 天真的ThreadLocal假设:MDC和异步改造互相纠缠
第一个坑是ThreadLocal。很多人说虚拟线程不适合用ThreadLocal,但实践中我发现,虚拟线程本身支持ThreadLocal,毕竟每个虚拟线程都是独立对象。问题出在“线程复用”和“异步拆分”上。
之前的WebFlux代码里链路追踪依赖ReactorContext,迁移后全部改成传统MDC。MDC本质上就是一个ThreadLocal,在同步请求里没问题。可一旦在Controller里用了CompletableFuture或者@Async,子线程执行时MDC就是空的,日志和上下文全丢了。
我们的解决办法是写一个TaskDecorator,在线程执行前把父线程的MDC内容复制到子线程:
@Component public class MdcTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { Map<String, String> contextMap = MDC.getCopyOfContextMap(); return () -> { if (contextMap != null) { MDC.setContextMap(contextMap); } try { runnable.run(); } finally { MDC.clear(); } }; } }然后给虚拟线程执行器绑上这个装饰器,就再没出现日志ID丢失的情况。另外,注意InheritableThreadLocal在虚拟线程里并不总是如你所愿,因为虚拟线程的创建往往发生在父线程之外。能不用就别用。
5.2 虚拟线程并不适合CPU密集任务
如果以为所有代码都搬到虚拟线程就万事大吉,那绝对会翻车。虚拟线程的优势是I/O密集型场景,CPU密集型任务跑在虚拟线程里反而会白白消耗平台线程的切换时间。比如JSON序列化超大对象、复杂加解密、图像处理,这类任务应该继续交给固定数量的普通线程池。
我们的实践是,在虚拟线程的业务方法里,如果遇到CPU密集计算,就把这部分提交给一个专用的FixedThreadPool,线程数设为核心数N或者N+1:
// CPU密集任务执行器 @Bean("cpuIntensiveExecutor") public Executor cpuIntensiveExecutor() { int cores = Runtime.getRuntime().availableProcessors(); return Executors.newFixedThreadPool(Math.max(4, cores - 1)); }调用时:
byte[] encryptedData = cpuIntensiveExecutor.execute(() -> cryptoService.encrypt(data));这里要小心,别把虚拟线程等待Future.get()当成普通的阻塞等待。事实上在虚拟线程里调用future.get()发生阻塞时,底层平台线程依然会被让出,所以虚拟线程是安全的。但CPU任务本身会占用平台线程,如果平台上同时跑很多加密任务,其他虚拟线程能分到的执行时间就少了,必须隔离好。
5.3 锁与synchronized:pinning的坑
虚拟线程有一个著名的“pinning”问题。Java 21的虚拟线程在抢占synchronized锁的同时如果发生了阻塞操作,JVM无法释放底层平台线程,这个平台线程会被固定住,别的虚拟线程就没法在上面切换了。大量代码突然出现pinning,最终导致虚拟线程没有真正卸载,反而把平台线程池耗尽。
我们的排查过程是:压测时线程数异常增长,JFR(Java Flight Recorder)里看到“Monitor Pinned”告警。定位到代码里有两个地方用了synchronized:一个是内存缓存的更新锁,一个是全局ID生成器的临界区。
解决方式很简单:
- 换用
ReentrantLock替代synchronized。虚拟线程配合ReentrantLock时,可以正确释放载体线程。 - 缩短锁范围,尽量把I/O操作移出临界区。
- 如果必须用内置锁,可以把锁粒度拆小,避免长时间持有。
JDK 24已经针对pinning做了大量优化,但JDK 21上这个问题还是真实存在的,迁移前先扫一遍项目里的synchronized块非常必要。
5.4 线程池滥用:把虚拟线程当普通线程池用
迁移初期,团队里有同事习惯性地创建了各种“虚拟线程池”:
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();然后用submit和Future去控制任务。这种做法其实不是什么大错,但很容易被滥用。虚拟线程的设计初衷是“线程很便宜,不要池化”,你每submit一次,它就新建一个虚拟线程,完成就丢弃。如果你为了避免创建成本给虚拟线程加了信号量或队列做“限流”,反而破坏了它的天然优势。
更离谱的是有人把虚拟线程池的线程池大小设置成1000,内部又用BlockingQueue排队,这等于把虚拟线程硬生生用成了平台线程池。虚拟线程的正确用法是:哪个地方之前用CompletableFuture.supplyAsync(() -> ...),现在就可以直接换成Thread.startVirtualThread(() -> ...),或者交给Spring管理的虚拟线程执行器,不做任何数量的限制。
6. 最后再聊聊:什么情况下我劝你别迁
说了这么多好处,也得泼点冷水。虚拟线程不是银弹,下面几类服务我建议你们谨慎评估,甚至别迁。
第一类是真正的流式处理服务。比如用WebFlux实现的SSE推送、大数据量分页拉取、Kafka消费的背压回传逻辑,这些场景依赖Reactor的Flux和背压机制,迁移到虚拟线程之后反而不好写。尤其是背压:虚拟线程的同步模型里没有背压的概念,消费慢就只能靠业务代码自己限流,复杂度会上升。
第二类是强CPU计算密集的业务,比如实时风控、图像处理、批量计算。虚拟线程提升的是并发等待能力,不是计算能力,你把这类服务迁过去,收益很小,还得额外处理前面说的CPU任务隔离。
第三类是WebFlux运行稳定且没有明显痛点的存量系统。如果当前这个服务用响应式跑得好好的,团队也能驾驭,压测指标也达标,那“因为虚拟线程很火”去迁移,就属于自讨苦吃。迁移是有成本的成功率又不是100%,不值得为了追新去折腾。
如果你们团队也还在WebFlux火坑里,我建议先拿一个业务简单、I/O占比高的服务做一次试点,压测数据会替你做决策。虚拟线程不是神,但它确实把“高并发”和“能用同步代码写业务”这两件事第一次很好地统一了起来。至于最终迁不迁,我的判断标准只有一个:你的团队是否愿意在“网络框架很酷”和“业务代码可维护”之间做出诚实的选择。反正我迁完之后,晚上值班终于不用再看那段像天书一样的Mono链式调用了。