用 WebFlux 四年,年底我把它重写了
2021 年我们做实时风控服务,选了 Spring WebFlux。当时的理由很充分:QPS 目标 2 万、需要背压、团队想试试响应式。四年过去了,这个服务一直在线上跑,但我对它的评价从"技术先进"变成了"维护负担"。
今年 10 月我用 JDK 21 虚拟线程 + Spring MVC 重写了一遍。这篇不吹不黑,把四年的账算一算。
先说它做得好的地方
先肯定成绩,不然显得不客观。WebFlux 在我们这个服务上确实达成了目标:
| 指标 | 目标 | 2021 年上线时 | 2024 年实际 |
|---|---|---|---|
| QPS | 2 万 | 2.4 万 | 3.1 万 |
| P99 延迟 | < 100 ms | 68 ms | 71 ms |
| 单机内存 | < 2 GB | 1.4 GB | 1.9 GB |
| 线程数 | — | 32 | 32 |
32 个线程扛 3 万 QPS,这个数字放到今天也拿得出手。它在纯 IO 密集、无阻塞的场景下,资源效率确实高。
背压也不是纸上谈兵。我们有个下游是规则引擎,处理能力只有上游的一半,靠 onBackpressureBuffer 加超时丢弃,把下游保护住了。换成线程池模型,这个保护得自己写。
但代价是沉重的
一、调试成本极高
这是最难受的一点。响应式链抛异常时,堆栈长这样:
java.lang.NullPointerException: Cannot invoke "Rule.getScore()" because "rule" is null
at com.example.risk.RuleEngine.lambda$evaluate$12(RuleEngine.java:184)
at reactor.core.publisher.MonoFlatMap$FlatMapMain.onNext(MonoFlatMap.java:132)
at reactor.core.publisher.FluxFilter$FilterSubscriber.onNext(FluxFilter.java:113)
at reactor.core.publisher.MonoPublishOn$PublishOnSubscriber.run(MonoPublishOn.java:181)
at reactor.core.scheduler.BoundedElasticThreadPerTaskScheduler$$Lambda$891/0x...run(...)
... 47 more
47 层堆栈里,属于我们代码的只有第 1 行。你完全看不出这个请求是从哪个接口进来、前面经过了哪些步骤。
Reactor 有个 Hooks.onOperatorDebug() 能补全链路,但它靠异常栈采集,性能损失巨大(官方文档明确说只用于调试)。我们最后是靠强制每个环节加 contextWrite 传 traceId,加上 MDC 手动透传来缓解,但那是额外的工作量。
我统计过:同样复杂度的业务逻辑,WebFlux 版本定位一个线上问题的平均耗时是 71 分钟,MVC 版本是 23 分钟。这个数据来自我们 2023 年的故障工单记录。
二、团队里只有两个人能改
这个服务四年间有 9 个人参与过。其中能独立改核心链路的,只有我和另外一个同事。原因不是大家能力不行,是响应式编程的心智模型和命令式代码完全不一样。
举个例子,这段代码的 bug 你能一眼看出来吗:
public Mono<RiskResult> evaluate(Order order) {
return loadRules(order.getType()) // Mono<List<Rule>>
.flatMapMany(Flux::fromIterable)
.flatMap(rule -> ruleEngine.execute(rule, order)) // 并行执行
.collectList()
.map(this::aggregate)
.doOnNext(r -> metrics.record(r.score()));
}
bug 是:flatMap 默认并发数 256,如果 ruleEngine.execute 里有阻塞调用,会把调度器的线程全占满。而且 flatMap 不保证顺序,聚合结果和规则顺序不一致,导致权重算错。
这个 bug 我们线上出过一次,排查了一整天才发现是顺序问题。改成 concatMap 保序之后 QPS 从 3.1 万掉到 1.2 万,最后改成 flatMapSequential 才两全。像 flatMap / flatMapSequential / concatMap 这种区别,没踩过坑的人根本意识不到。
三、生态支持是最大的软肋
这是我最想吐槽的。四年下来我们遇到的坑:
- JDBC 是阻塞的。R2DBC 到 2024 年覆盖率依然不行,很多特性缺失(没有成熟的分布式事务支持、连接池选择少、MyBatis 不支持)。我们最后把数据库访问单独拆成了一个 MVC 服务,WebFlux 通过 HTTP 调它,绕了一圈。
- 大量 SDK 只有阻塞版本。2024 年我们接入一个风控数据源,对方只提供同步 SDK。套
Mono.fromCallable().subscribeOn(Schedulers.boundedElastic())能跑,但这就把响应式最核心的优势给抵消了。 - ThreadLocal 失效。链路追踪、日志 MDC、权限上下文,在响应式链里全都失效。Reactor Context 是替代方案,但每个算子都要手动传,漏一个就丢上下文。
- 测试难度大。
StepVerifier写起来比普通单测复杂,且容易写出"看起来测了实际没测"的用例——忘了verify()的测试是会通过但不执行任何断言的。
四、一次真实的雪崩
2023 年 4 月出过一次事故。有人在规则引擎里加了一行日志,用了 Slf4j 同步写文件。这段代码跑在 Netty 的 event loop 线程上,一次文件 IO 阻塞 30 毫秒,event loop 就被卡住。
# 事故期间的监控
reactor_blocked_detector BLOCKED # Reactor 的 BlockHound 检测
http_server_requests_p99 0.068 → 4.2 s
active_connections 3200 → 18400 (连接堆积)
8 个 event loop 线程被逐个卡死,整个服务在 90 秒内完全不可用。我们后来上了 BlockHound 在测试环境拦截阻塞调用,但生产环境没人敢开(性能损耗太大)。
这件事的本质问题是:响应式编程要求整条链路都是非阻塞的,只要有一个环节破功,代价是整个服务。而保证"整条链路都不阻塞",在一个多人协作、持续迭代的项目里,几乎做不到。
虚拟线程改变的是什么
JDK 21 之后,用 Spring MVC + 虚拟线程能拿到接近 WebFlux 的并发能力,但代码是命令式的。今年 10 月我重写了那个风控服务,对比数据:
| 项 | WebFlux | MVC + 虚拟线程 |
|---|---|---|
| QPS(同机器 8C16G) | 31400 | 29800 |
| P99 延迟 | 71 ms | 78 ms |
| P99.9 延迟 | 184 ms | 152 ms |
| 峰值内存 | 1.9 GB | 2.7 GB |
| 存活线程数 | 32 | 约 2.9 万 |
| 业务代码行数 | 8420 | 6110 |
| 团队能独立改的人数 | 2 | 7 |
QPS 低了 5%,内存多了 0.8 GB,但代码少了 27%,能维护的人从 2 个变成 7 个。这笔账在我们团队里是压倒性的。
P99.9 反而更好,我猜是因为虚拟线程版本的线程调度更公平,而 WebFlux 在 event loop 上有排队效应。这个结论我不敢说有普适性,但至少在我们这个负载模型下是这样。
迁移不是没有成本
重写花了 6 周(一个人),其中三周在改这三件事:
- 数据库访问:从 HTTP 调用那个独立的 MVC 服务,改回直接 JDBC。省了一跳网络,P99 降了 8 毫秒。
- 阻塞 SDK:直接调用,不用再包
subscribeOn。 - 上下文传递:Reactor Context 全部删掉,改回 ThreadLocal。这里用了
ScopedValue做过渡(JDK 21 预览,我们只在 traceId 一个场景用了)。
WebFlux 现在还有没有位置
有,但范围比 2021 年宣传的要窄很多。我的判断:
适合用 WebFlux 的场景
- 大量长连接:SSE、WebSocket 推送。我们的 AI 流式输出服务还在用 WebFlux,单机维持 1.2 万条 SSE 连接,这个场景虚拟线程也省不了多少(每条连接仍需要一个线程)。
- 真正的流式数据处理:数据从上游源源不断来,需要背压,中间做转换。这是 Reactor 的设计目标场景。
- API 网关:Spring Cloud Gateway 就是基于 WebFlux 的,纯转发、无业务逻辑,阻塞风险低。
不该用 WebFlux 的场景
- 业务复杂的 CRUD 服务。规则多、分支多、要调五六个下游,响应式链会变成一团乱麻。
- 依赖大量阻塞 SDK。这是最常见的翻车原因。
- 团队没有响应式经验,且没有专职的人维护。这条我最想强调——技术选型的约束条件里,团队能力比技术先进性重要得多。
我观察到的生态现状
一些客观事实,供参考:
- Spring Framework 6.x 和 Boot 3.x 里 WebFlux 依然是一等公民,维护正常,没有要废弃的迹象。
- Spring 官方文档在并发模型的讨论中,虚拟线程的篇幅明显增加。Boot 3.2 之后
spring.threads.virtual.enabled是一行配置就能开的特性。 - R2DBC 的生态扩张基本停滞了,2024 年我没有看到有分量的新进展。这是我觉得最关键的信号——一个技术栈的生命力,看它的周边生态而不是核心框架。
- Spring Cloud Gateway 仍然是 WebFlux 的最大用户,而且短期内不会改,因为它要的就是高并发转发。
选型建议
如果现在(2024 年底)要新起一个服务,我的建议顺序是:
- 默认用 Spring MVC + 虚拟线程(JDK 21 及以上)。同步代码、可调试、生态完整,性能对绝大多数业务足够。
- 只有满足"大量长连接"或"真流式处理"时才考虑 WebFlux,并且把这类逻辑限制在服务的一小块,不要扩散到整个代码库。
- 已有 WebFlux 服务不要急着重写。我们重写是因为维护成本已经高到影响迭代速度了,如果它跑得好、有人能维护,就别动。重写本身是有风险的,我们花了 6 周,期间发现并修复了 3 个原版没暴露的边界 bug。
留个问题
关于《Spring 生态的响应式之路:现状与反思》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。