Administrator
发布于 2024-12-31 / 10611 阅读
141

Spring 生态的响应式之路:现状与反思

用 WebFlux 四年,年底我把它重写了

2021 年我们做实时风控服务,选了 Spring WebFlux。当时的理由很充分:QPS 目标 2 万、需要背压、团队想试试响应式。四年过去了,这个服务一直在线上跑,但我对它的评价从"技术先进"变成了"维护负担"。

今年 10 月我用 JDK 21 虚拟线程 + Spring MVC 重写了一遍。这篇不吹不黑,把四年的账算一算。

先说它做得好的地方

先肯定成绩,不然显得不客观。WebFlux 在我们这个服务上确实达成了目标:

指标目标2021 年上线时2024 年实际
QPS2 万2.4 万3.1 万
P99 延迟< 100 ms68 ms71 ms
单机内存< 2 GB1.4 GB1.9 GB
线程数3232

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 月我重写了那个风控服务,对比数据:

WebFluxMVC + 虚拟线程
QPS(同机器 8C16G)3140029800
P99 延迟71 ms78 ms
P99.9 延迟184 ms152 ms
峰值内存1.9 GB2.7 GB
存活线程数32约 2.9 万
业务代码行数84206110
团队能独立改的人数27

QPS 低了 5%,内存多了 0.8 GB,但代码少了 27%,能维护的人从 2 个变成 7 个。这笔账在我们团队里是压倒性的。

P99.9 反而更好,我猜是因为虚拟线程版本的线程调度更公平,而 WebFlux 在 event loop 上有排队效应。这个结论我不敢说有普适性,但至少在我们这个负载模型下是这样。

迁移不是没有成本

重写花了 6 周(一个人),其中三周在改这三件事:

  1. 数据库访问:从 HTTP 调用那个独立的 MVC 服务,改回直接 JDBC。省了一跳网络,P99 降了 8 毫秒。
  2. 阻塞 SDK:直接调用,不用再包 subscribeOn
  3. 上下文传递: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 年底)要新起一个服务,我的建议顺序是:

  1. 默认用 Spring MVC + 虚拟线程(JDK 21 及以上)。同步代码、可调试、生态完整,性能对绝大多数业务足够。
  2. 只有满足"大量长连接"或"真流式处理"时才考虑 WebFlux,并且把这类逻辑限制在服务的一小块,不要扩散到整个代码库。
  3. 已有 WebFlux 服务不要急着重写。我们重写是因为维护成本已经高到影响迭代速度了,如果它跑得好、有人能维护,就别动。重写本身是有风险的,我们花了 6 周,期间发现并修复了 3 个原版没暴露的边界 bug。

留个问题

关于《Spring 生态的响应式之路:现状与反思》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。

参考