ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

WebFlux迁移JDK21虚拟线程:高并发服务改造实践与踩坑指南

WebFlux迁移JDK21虚拟线程:高并发服务改造实践与踩坑指南 如果你在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稍微写错一个chainID就丢了。更重要的是WebFlux把“高并发”这个技术问题转换成了“团队能不能适应响应式思维”的管理问题。不是所有人都有耐心去理解背压、订阅、调度器的。多数业务场景其实就是“查一次库、调一次远程、返回结果”用响应式写只是在绕远路。我用一个简单例子说明这种“绕远路”。假设用户详情接口里有一个数据库查询响应式写法一般是这样GetMapping(/users/{id}) public MonoResponseEntityUser 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的活。我的建议版本组合如下组件建议版本说明JDK21 LTS必须21及以上虚拟线程转正Spring Boot3.2.5 或 3.3.x支持spring.threads.virtual.enabledtrueServlet容器Tomcat 10.1 / Jetty 11Spring Boot默认内嵌容器Tomcat对虚拟线程支持已是标配数据库访问JDBC HikariCP从R2DBC迁回JDBC最省事RedisLettuce同步API不再用ReactiveRedisTemplateHTTP客户端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 盘点代码里的“响应式气味”迁移前先扫一遍项目里有多少响应式代码。我推荐把下面这些特征当作“响应式气味”清单方法返回类型是MonoT或FluxT方法链里大量出现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-typereactive设回去。也就是说一个进程里很难优雅地让同步接口和响应式接口长期共存。所以我们把“无缝迁移”定义在服务维度而不是接口维度。先挑一个业务简单、链路短、I/O占比高的服务比如用户信息查询服务做试点迁移完成后对外发布新版本压测通过后再滚动迁移其他服务。这种做法的好处是出错域可控。虚拟线程模型下如果上游服务出问题影响范围只限这个单独的应用不会把整个系统搅成一锅粥。等两三个服务迁移完团队积累出套路后面的速度就快了。3. 从WebFlux到虚拟线程的四个关键改造点我们实际迁移的时候没有做“大爆炸式”重写而是按四个关键位置逐个替换。掌握了这四个点基本就掌握了这类迁移的骨架。3.1 依赖切换替换starter第一步先把spring-boot-starter-webflux替换成spring-boot-starter-web。注意不是把webflux直接删了因为项目里可能还有reactor-test之类的测试依赖要一起处理。以Maven为例改动长这样dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency删掉的依赖!-- 移除 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency dependency groupIdio.projectreactor/groupId artifactIdreactor-test/artifactId scopetest/scope /dependency这里要注意如果项目里有其他Spring Cloud组件自动引入了WebFlux比如Spring Cloud Gateway那就不能简单移除得先把网关这类组件剥离出去或者单独保留。我们的迁移服务是纯内部接口服务没有这个问题所以一步到位。替换依赖后启动一次应用大概率会遇到类冲突或者找不到WebClient、Mono的编译错误。这是好事它会帮你快速列出所有需要改的调用点。3.2 接口层重写从Mono到同步返回依赖切换完最核心的重写出现在Controller层。还是用用户详情接口举例原来的响应式写法已经提过改成虚拟线程下的同步写法GetMapping(/users/{id}) public ResponseEntityUser 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方法返回值从MonoUser变成UserSpring MVC会自动序列化成JSON。对于Service层凡是之前返回MonoT的直接改成返回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来组织CompletableFutureUser userFuture CompletableFuture.supplyAsync(() - userService.findById(userId)); CompletableFutureOrder 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.enabledtrue影响的只是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()方法。这是最省事的过渡方案MonoUser 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核16GJDK 21部署在容器里限制CPU为4核避免物理机扰动。压测工具我们用的是Gatling模拟HTTP请求压测时长10分钟先预热1分钟再计数。被测服务是重构后的用户查询接口内部会调用一个远程服务模拟200ms外部I/O等待再加一次Redis读取和一次本地MySQL查询。两个版本分别运行v1Spring WebFlux Reactor Netty16个事件循环线程400个boundedElastic线程池用于阻塞调用。v2Spring MVC 虚拟线程Tomcatspring.threads.virtual.enabledtrue。压测指标记录吞吐量RPS、P99延迟、JVM活动线程数、容器外操作系统线程数。4.2 数据解读吞吐、延迟、线程数、内存压测结果整理成表格场景WebFlux虚拟线程50并发吞吐5,243 req/s5,187 req/s50并发 P99121ms98ms200并发吞吐12,860 req/s14,635 req/s200并发 P99238ms156ms500并发吞吐16,400 req/s21,120 req/s500并发 P99452ms231ms说实话50并发的时候两者几乎一样虚拟线程还略低一丝。但到了200并发和500并发虚拟线程版本的吞吐量优势越来越明显P99延迟更是直接降低一半。原因也好解释WebFlux在阻塞调用时需要把任务切给boundedElastic线程池线程池数量有限当外部I/O慢、等待时间长任务就在线程池排队虚拟线程则没有这个限制每个阻塞操作挂起后立刻让出载体4060个并发请求都不会排队。当时最让我惊讶的是线程数指标。WebFlux版本在500并发时JVM活跃线程数大概有1000出头真正干活的OS线程保持在100以内。虚拟线程版本JVM活跃线程数直接飙升到10几万但OS线程还是不到100。我特别看了内存虚拟线程对象自身占内存每个虚拟线程带栈大概几KB到十几KB16G内存下开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) { MapString, 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或者N1// 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 锁与synchronizedpinning的坑虚拟线程有一个著名的“pinning”问题。Java 21的虚拟线程在抢占synchronized锁的同时如果发生了阻塞操作JVM无法释放底层平台线程这个平台线程会被固定住别的虚拟线程就没法在上面切换了。大量代码突然出现pinning最终导致虚拟线程没有真正卸载反而把平台线程池耗尽。我们的排查过程是压测时线程数异常增长JFRJava 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链式调用了。
返回列表