响应式编程 vs 虚拟线程——我在两个真实项目中的选型经历
响应式编程 vs 虚拟线程——我在两个真实项目中的选型经历
适读人群:正在做技术选型、纠结于 WebFlux 和虚拟线程的工程师 | 阅读时长:约16分钟 | 核心价值:从两个真实项目的完整选型经历中提炼判断框架
这是这个系列的最后一篇,我想用两个真实项目的经历,把前面20篇的内容做个收尾。
这两个项目,一个选了 WebFlux + Reactor Kafka,一个选了 Spring MVC + 虚拟线程。两个选择我现在都觉得是对的,但原因是完全不同的,选型过程也都有一些值得记录的波折。
项目一:数据管道平台
背景
这是一个内部数据平台,负责把各个业务系统的数据实时同步到大数据平台。数据来源包括:MySQL binlog、Kafka 消息流、API 定时拉取、文件 FTP 扫描。同步目标包括:Kafka、HDFS、Elasticsearch、HBase。
峰值时每秒处理约 18000 条数据,需要支持过滤、转换、路由规则,每种数据源和目标的组合都有不同的逻辑。
初始方案和遇到的问题
最初我主导的方案是:Spring Batch + Spring MVC,用 Job 的方式跑数据同步,熟悉的技术栈,开发快。
上线之后,这套方案在低负载时运行没问题,但到了业务高峰期,MySQL binlog 的消费速度开始跟不上,lag 持续增加。同时,Elasticsearch 写入的批次越来越大,内存压力升高,GC 频繁,有几次触发了 Full GC,导致数据同步中断了十几秒,下游报了警告。
当时我们的小王说了一句话让我重新想了想方向,他说:"老张,你看咱们这个场景,其实整个链路就是个数据流,从源头到目标,中间的处理逻辑都是无状态的。这不就是 Flux 的典型使用场景吗?"
我当时的第一反应是……有道理,但我对 Reactor Kafka 没什么把握。
重构决策
花了两周做调研和原型验证,最终决定重构成 WebFlux + Reactor Kafka。
理由:
- 整个链路确实是数据流,没有复杂的业务逻辑,主要是过滤和转换,这正是 Reactor 的强项
- 背压是刚需:Kafka 的消费速度不能超过 ES 的写入速度,响应式背压能自然地解决速率不匹配
- 数据量大,内存效率很重要:响应式流处理不需要把一批数据全加载到内存再处理,可以流式处理
重构过程的坑
坑1:团队熟悉度
当时团队里只有我一个人对 WebFlux 有实际经验,其他3个人都是第一次接触。这带来了很高的培训和协作成本。有一段时间,有个同事写的 Reactor 链,在处理异常时把整个 Flux 搞坏了,但测试里没发现(因为测试数据量太少,没触发那个 case),上线后才暴露,排查花了半天。
这让我意识到:响应式的调试难度是真实存在的成本,不能只看性能收益。
坑2:错误处理的纪律性
数据管道里,一条数据处理失败不应该让整个流挂掉,应该记录、跳过、继续。但响应式链里如果不加 onErrorResume,任何未处理的异常都会终止整个 Flux。
我们花了额外的时间建立错误处理规范,并且做了一个通用的"跳过+记录"包装:
// 通用的"失败跳过"包装
public static <T, R> Function<T, Mono<R>> skipOnError(Function<T, Mono<R>> fn,
Consumer<Throwable> errorLogger) {
return item -> fn.apply(item)
.onErrorResume(e -> {
errorLogger.accept(e);
return Mono.empty(); // 跳过这条,继续下一条
});
}
// 使用
Flux<DataRecord> processedRecords = rawRecords
.flatMap(skipOnError(
record -> transformer.transform(record),
e -> log.error("数据转换失败,跳过: {}", e.getMessage())
));结果
重构完成、稳定运行3个月后的数据:
| 指标 | 重构前(Spring Batch) | 重构后(WebFlux + Reactor Kafka) |
|---|---|---|
| 峰值处理速率 | 约6200条/s | 约21000条/s |
| GC Full GC 频率 | 高峰期约每40分钟一次 | 运行3个月零 Full GC |
| Consumer 实例数 | 12个 | 4个 |
| 内存(单实例) | ~1.6GB | ~580MB |
| 最大 Kafka lag | 约120万条 | 约8000条 |
效果很好,但我要诚实地说:这个场景是响应式最适合的那类——纯数据流,无状态转换,高吞吐,背压刚需。如果把这个结果拿去说"响应式就是好",会误导人。
项目二:保险业务中台
背景
某保险公司的业务中台系统改造,包括产品管理、保单管理、理赔处理三个核心服务。业务逻辑非常复杂,大量的业务规则,复杂的状态机(保单有七八种状态),很多服务之间的调用依赖。原来的技术栈是 Spring MVC + JPA + MySQL,稳定运行了多年。
他们找到我的时候,已经有一个改造方案:全部迁移到 Spring WebFlux + R2DBC,理由是"听说响应式性能好"。
并发量要求:保单创建峰值 8000 QPS,理赔受理峰值 3000 QPS。
我的评估和建议
看完现有系统之后,我给出了一个他们没想到的建议:不要迁移到 WebFlux,改成虚拟线程。
理由:
1. 业务逻辑极其复杂,响应式调试成本极高
保险理赔的流程有几十个步骤,涉及多个服务调用、多层业务规则验证、复杂的条件分支。如果用 WebFlux 实现,响应式链会非常长,而且业务逻辑嵌套在 flatMap 里,可读性很差。
生产环境出了 bug,你要在 Reactor 的非线性执行模型里定位问题,这个成本在金融场景里是不可接受的。
2. R2DBC 对复杂 JPA 查询的支持不足
他们的查询大量使用 JPA 的关联映射(@OneToMany、@ManyToOne,还有一些 @NamedEntityGraph 优化)。换到 R2DBC 意味着要重写几乎所有的数据访问层,工作量极大,风险也极大。
3. 8000 QPS 用虚拟线程完全够用
做了一个快速 PoC:Java 21 + 虚拟线程 + 现有 Spring MVC + JPA 代码,只改了 Tomcat 配置,在压测中轻松跑到了 10000+ QPS,P99 在 230ms 以内。
完全不需要响应式的复杂度来达到这个并发目标。
结果
最终方案:
- 升级到 Java 21,启用虚拟线程
- 服务间调用从 Feign 换成 WebClient(并发调多个下游服务)
- 保留 JPA,连接池从150减到30(虚拟线程等待连接时不占 OS 线程)
- 关键热点接口增加本地缓存
改造工期:1.5个月(原计划全 WebFlux 方案估计需要6个月)。
上线后的数据:
| 指标 | 改造前 | 改造后(虚拟线程) |
|---|---|---|
| 保单创建 P99 | 847ms | 183ms |
| 理赔受理 P99 | 1230ms | 247ms |
| 最大支撑 QPS | 约3200 | 超过12000 |
| 内存(单服务) | ~1.4GB | ~780MB |
| 代码改动量 | - | 约200行配置 |
200行配置改动,P99 从 800ms+ 降到了 180ms,QPS 翻了近4倍。
项目二的一个意外插曲
在那个保险项目里有一个小插曲,值得记一下。
迁移完成大约两周之后,有一天运营反馈说:保单查询偶发性很慢,有时候要十几秒才返回,但大部分时候是正常的。
我当时以为是数据库慢查询,花了一天多翻 Slow Query Log,没发现明显异常。第二天又怀疑是 JPA 的 N+1,加了日志把 SQL 语句全打出来看,也没问题。
第三天,我们的测试同学无意中说了一句:"好像只有并发很高的时候才偶发?"
这一句话提醒了我。我去看 HikariCP 的连接池监控,发现高峰期偶尔有连接等待超过10秒!
原因找到了:我们把连接池从150减到了20(参考了虚拟线程"不需要大连接池"的建议),但没考虑到这个服务有几个报表接口,每次查询要持有连接很长时间(复杂的聚合查询,有时候要5-8秒)。报表查询时,20个连接被占满,普通查询就得等。
解决方案:把报表查询的逻辑拆到单独的数据源(专门的连接池),和普通业务查询隔离。这个方案半天就上线了,问题彻底解决。
这个教训是:虚拟线程减少了对连接池数量的需求,但不意味着可以随意减小,需要根据业务的查询特征来评估。特别是有长事务或者长耗时查询的场景,连接占用时间长,池子太小还是会排队。
我的选型框架
经历这两个项目之后,我整理了一个判断框架:
选 WebFlux + 响应式的条件(需要同时满足多条):
- 系统是数据流处理、消息管道、推送服务等"流式"场景
- 团队至少有2-3个人对响应式有实际经验
- 可以接受较高的调试和维护成本
- 无法简单通过增加机器解决的性能问题
选虚拟线程 + 传统 MVC 的条件(满足一条就可以考虑):
- 已有 Spring MVC 系统,代码逻辑复杂,不想大改
- 团队响应式经验薄弱,迁移风险高
- 业务逻辑复杂,调试可靠性优先于性能
- 并发量在万级以下(虚拟线程通常够用)
- 快速上线是优先级
两者结合的场景:
- MVC 做业务逻辑,WebClient 做服务间调用(享受并发调用的好处,不承担响应式的全部成本)
- Kafka Consumer 用 Reactor Kafka(流处理场景),其他部分用 MVC + 虚拟线程
说到底,这两项技术都在解决"如何让 Java 的并发更高效"这个问题,只是解题路径不同:
响应式编程是在代码层面做异步——把复杂性前移到开发时,换取运行时的高效;
虚拟线程是在 JVM 层面做异步——开发时写同步代码,JVM 在运行时自动把阻塞等待变成非阻塞;
两条路都能到达高并发的目标地,选哪条,主要看你的地图(场景)和你带的装备(团队能力)。
这20篇到这里就结束了。如果你从 article-1103 一路看到这里,我非常感谢你的陪伴。这些内容是我这两年实际踩坑和思考的积累,希望能对你的技术判断有一些实际的帮助。
后续我还会继续写,下一个系列还没想好,可能是 AI 工程化方向,也可能继续深挖 Java 的某个方向。如果你有想让我写的话题,评论区告诉我。
