Spring WebFlux 入门——为什么我从 Spring MVC 切到响应式,踩了哪些坑
Spring WebFlux 入门——为什么我从 Spring MVC 切到响应式,踩了哪些坑
适读人群:有 Spring MVC 经验、想了解响应式编程的 Java 工程师 | 阅读时长:约14分钟 | 核心价值:从真实迁移经历出发,帮你避开 WebFlux 上手最常见的几个坑
大概是去年9月份的一个周四下午,我正准备摸鱼,线上告警突然刷屏。我们的用户服务 P99 延迟飙到了4300多毫秒,而且还在涨。那个服务用的是标准 Spring MVC,Tomcat 线程池,正常情况下跑得挺好的。
问题出在哪?
排查了大概两个小时,最终定位到:并发量到了一定程度之后,Tomcat 线程池被打满了,后续请求全在等待队列里排队,每个请求都在傻傻地占着一根线程等数据库、等下游接口。那段时间业务侧在做活动推广,流量比平时多了3倍多,我们完全没有预料到。
这件事之后,我开始认真看 Spring WebFlux。
一、我为什么动了迁移的念头
说实话,在这个事情之前,我对 WebFlux 的态度是"听说过,但没必要学"。Spring MVC 已经够用了,够稳,出了问题 Google 一下基本都有答案。WebFlux 这种东西感觉是给"高端玩家"准备的,普通业务用不上。
但那次事故让我改变了想法,不是因为 WebFlux 是银弹,而是因为我开始真正理解了阻塞模型的边界在哪里。
Spring MVC 的模型是:一个请求进来,占一根线程,从头干到尾。线程不够了,后来的请求就等。这个模型简单,调试方便,但有个硬伤:当你的服务大量依赖 IO 操作时(数据库、HTTP 调用、消息队列),线程大部分时间都在等,没在真正工作。
WebFlux 的模型是:请求进来,注册一个"当数据来了告诉我"的回调,然后这根线程去处理别的事情。IO 完成了,事件循环通知你继续。理论上少量线程就能支撑大量并发。
这个思路在 Node.js、Nginx 里早就验证过了,Java 只是来得晚了一些。
二、真正开始迁移:比我想象的难
我第一个迁的是一个内部的数据聚合服务,业务不复杂:从三个下游服务拿数据,合并返回给前端。正好适合验证 WebFlux。
最开始的感觉是:写法变了,但我不知道为什么要这么写。
比如一个最简单的控制器:
// Spring MVC 的写法,我用了5年,非常自然
@GetMapping("/user/{id}")
public UserVO getUser(@PathVariable Long id) {
User user = userService.findById(id);
return UserVO.from(user);
}换成 WebFlux 之后:
// WebFlux 的写法,第一次看到 Mono 我是懵的
@GetMapping("/user/{id}")
public Mono<UserVO> getUser(@PathVariable Long id) {
return userService.findById(id) // 返回 Mono<User>
.map(UserVO::from); // 转换,还没执行,只是在"描述"
}表面上看差不多,但背后的含义完全不同。Mono<UserVO> 不是一个值,它是一个"未来某个时刻会有一个值"的描述。你在方法里写的所有操作,都只是在"定义管道",真正执行是在订阅的时候。
这个概念搞清楚要花点时间,我花了大约一周才真的理解,不是只是记住了。
三、踩的第一个坑:在响应式链里调了阻塞方法
这个坑我觉得80%的人第一次用 WebFlux 都会踩。
我当时有个地方需要调一个老服务,那个老服务的客户端是同步的,没有响应式版本。我就直接在 WebFlux 的链里调了:
public Mono<ResultVO> getData(Long id) {
return Mono.fromCallable(() -> {
// 这里调了一个同步阻塞方法,当时我以为没问题
LegacyResult result = legacyClient.fetchSync(id); // 阻塞!
return ResultVO.from(result);
});
// 问题:这段代码默认在 reactor 的 event loop 线程上执行
// event loop 线程被阻塞了,整个系统吞吐量直接崩
}压测的时候发现吞吐量比 Spring MVC 还低,我当时有点慌,以为是自己哪里写错了,排查了将近半天。
后来才搞明白:WebFlux 默认跑在少量的 Netty event loop 线程上(默认是 CPU 核数 × 2),你在这些线程上做任何阻塞操作,都会把整个 reactor 的 IO 处理能力阻断掉。
正确的做法是把阻塞操作切到专门的线程池:
public Mono<ResultVO> getData(Long id) {
return Mono.fromCallable(() -> {
// 同步调用,没法改,只能隔离
LegacyResult result = legacyClient.fetchSync(id);
return ResultVO.from(result);
})
.subscribeOn(Schedulers.boundedElastic());
// boundedElastic 是专门为阻塞操作准备的调度器
// 注意:不要用 Schedulers.parallel(),那个不允许阻塞
}Schedulers.boundedElastic() 会用一个有上限的弹性线程池来执行,不会撑爆你的内存,也不会阻塞 event loop。这是官方推荐的处理阻塞代码的方式。
四、踩的第二个坑:ThreadLocal 全部失效了
这个坑更隐蔽,让我连加了两天班才搞清楚。
我们系统里有个 UserContext,用 ThreadLocal 存当前登录用户的信息,鉴权中间件把用户信息放进去,后续的 Service、DAO 层直接取。在 Spring MVC 里这套东西用了好几年,从来没出过问题。
迁到 WebFlux 之后,偶发性地会出现 UserContext.get() 返回 null 的情况。
原因:WebFlux 的响应式链在执行过程中可能切换线程。你在线程 A 上设置了 ThreadLocal,等到后续的操作执行时,可能已经跑到线程 B 上了,ThreadLocal 当然没有了。
// 这种写法在 WebFlux 里是危险的
filter.then(chain.filter(exchange)).doOnSuccess(v -> {
// 这里的线程可能和你设置 UserContext 时的线程不一样
// UserContext.get() 可能是 null
UserContext.clear();
});WebFlux 的正确做法是用 Context,它是 Reactor 提供的、跟着数据流走的上下文,不依赖线程:
// 在 filter 里把用户信息塞进 Reactor Context
return chain.filter(exchange)
.contextWrite(ctx -> ctx.put("currentUser", userInfo));
// 在需要的地方从 Context 取
return Mono.deferContextual(ctx -> {
UserInfo user = ctx.get("currentUser");
return doSomethingWith(user);
});这个迁移成本挺高的,因为原来用 ThreadLocal 的地方散落在项目各个角落,需要逐一改造。
五、踩的第三个坑:异常处理的写法变了
Spring MVC 里我们习惯用 @ControllerAdvice 全局捕异常,WebFlux 也支持这个,但细节有差异。
更重要的是,在响应式链内部,异常的传播方式变了:
public Mono<UserVO> getUser(Long id) {
return userRepository.findById(id)
.map(user -> {
if (user == null) {
// 注意:这里抛出的异常会被转换成 Mono.error()
// 不会直接冒泡给调用方,而是沿着响应式链传递
throw new UserNotFoundException(id);
}
return UserVO.from(user);
})
// 如果不处理,这个错误最终会导致整个 Mono 以错误结束
.onErrorResume(UserNotFoundException.class, e -> {
// 可以在这里做降级处理
return Mono.just(UserVO.empty());
});
}不过我后来的习惯是:链内部能恢复的错误用 onErrorResume,真正的系统错误让它透传到全局异常处理器,不要在业务代码里写太多 onError,会让链子变得很难读。
六、迁移之后的实际效果
那个数据聚合服务迁完之后,我们做了一次压测对比:
| 指标 | Spring MVC | Spring WebFlux |
|---|---|---|
| 并发 200 P99 | 187ms | 143ms |
| 并发 500 P99 | 412ms | 198ms |
| 并发 1000 P99 | 1870ms(开始排队) | 267ms |
| 并发 2000 | 超时/拒绝 | 389ms |
线程数:MVC 用了约 400 线程,WebFlux 用了约 18 个 event loop 线程 + 若干 boundedElastic 线程。
效果是显著的,但我要强调一点:这个服务本身是 IO 密集型的,三个下游 HTTP 调用,大部分时间在等网络。如果是计算密集型的服务,WebFlux 的优势就不明显,甚至有可能因为响应式的额外开销而变慢。
七、我现在对 WebFlux 的看法
说实话,我觉得 WebFlux 被过度推销了。很多文章把它描述成"高并发必选",这是有误导性的。
适合用 WebFlux 的场景:
- 大量 IO 等待:数据库查询、HTTP 调用、消息消费,等待时间远大于计算时间
- 需要处理数据流:SSE、WebSocket、实时推送
- 资源受限的场景:内存紧张,不想开大量线程
不适合用 WebFlux 的场景:
- 计算密集型(机器学习推理、图像处理等):用线程反而更直接
- 团队以 Spring MVC 为主、没人熟悉响应式:踩坑成本远大于收益
- 大量依赖的第三方库没有响应式版本:你会花大量时间在
Schedulers.boundedElastic()上隔离阻塞操作,得不偿失
我们组的小陈有一句话让我印象很深,他说:"WebFlux 是把异步的复杂性从运行时(线程等待)转移到了代码时(响应式链),你得想清楚哪种复杂性更好管理。"
这句话我觉得说到点上了。
八、给准备入门的人的几点建议
第一,先把 Project Reactor 的基础搞扎实,再来看 WebFlux。很多人直接从 WebFlux 入,被各种操作符搞晕,其实那是 Reactor 的东西,不是 WebFlux 的东西。
第二,不要在已有的 Spring MVC 项目里混用 WebFlux。Spring MVC 和 WebFlux 不能在同一个应用里共存(都会被 Spring Boot 自动配置搞乱),要切就整体切。
第三,从一个新的、边缘的服务开始练手,不要上来就迁核心业务。响应式的调试体验比 Spring MVC 差很多,栈帧不直观,出了问题排查成本更高。
第四,日志和监控要提前布局。响应式链里的日志打印和 MVC 的不一样,log() 操作符是你的朋友:
return userRepository.findById(id)
.log("user-fetch") // 会打印出整个 Mono 的生命周期事件,调试非常有用
.map(UserVO::from);入门这条路确实比 Spring MVC 陡,我不会跟你说"很简单,两天上手"。但如果你的场景确实适合,迁移之后的效果是实实在在的。
下一篇我打算写 Project Reactor 的核心——Mono、Flux 和常用操作符,这块是 WebFlux 的真正地基,搞不清楚这个,后面全是坑。
