场景:线程不是无限的

设想一个网关服务,每个请求要串行调用三个下游接口,每个下游平均耗时 100ms。用传统 Spring MVC 的"一请求一线程"模型,这个线程在 300ms 里几乎全程在阻塞等待 IO,CPU 啥也没干。Tomcat 默认 200 个工作线程,意味着你最多同时扛 200 个这样的请求,第 201 个开始排队。瓶颈不在 CPU,而在"线程"这种昂贵资源被白白挂起。

WebFlux 要解决的就是这个问题:用少量线程承载大量并发连接。它的核心命题是——既然线程大部分时间在等 IO,那就别让它等,IO 没好就去干别的,好了再回来。这就是响应式 + 非阻塞的底层动机。

机制一:Reactive Streams 与背压

WebFlux 建立在 Reactor 之上,而 Reactor 实现的是 Reactive Streams 规范。这套规范只有四个接口,但精髓在于背压(backpressure)

普通的观察者模式是 push 的:生产者有数据就推给消费者。问题是如果生产者比消费者快,数据会堆积,要么撑爆内存,要么丢数据。Reactive Streams 改成了 pull-push 混合:消费者通过 Subscription.request(n) 告诉生产者"我现在只能处理 n 个",生产者最多推 n 个。消费速度由消费者掌控。

1
2
3
4
5
6
7
8
9
10
public interface Subscriber<T> {
void onSubscribe(Subscription s); // 拿到 subscription,可以 request
void onNext(T t); // 收到一个元素
void onError(Throwable t);
void onComplete();
}
public interface Subscription {
void request(long n); // 背压的核心:我要 n 个
void cancel();
}

类比:背压就像点餐时跟厨房说"先上两个菜",而不是厨房一口气把二十个菜全端上来摆不下。这是响应式相比"裸异步回调"最本质的工程价值。

机制二:事件循环与非阻塞

WebFlux 默认跑在 Netty 上。Netty 是 Reactor 模式的事件循环:少量(通常等于 CPU 核数)EventLoop 线程,每个线程绑定一个 selector,轮询多个 channel 上的 IO 事件。一个连接没数据可读时,它的线程不会阻塞在那个连接上,而是转去处理其它就绪的连接。

1
2
3
4
5
传统阻塞模型:           事件循环模型:
线程1 → 连接A(等待中) EventLoop线程 → selector → [连接A就绪? 连接B就绪? ...]
线程2 → 连接B(等待中) ↓ 只处理就绪的
线程3 → 连接C(等待中) 处理A的数据 → 注册回调 → 继续轮询
...每连接一线程 ...几个线程轮询上万连接

这套模型的威力在于:线程数和连接数解耦了。4 个 EventLoop 线程理论上能处理上万个空闲连接,因为空闲连接不占线程。

但代价是一条铁律:绝对不能在 EventLoop 线程上做阻塞操作。一旦你在事件循环里调了 Thread.sleep、JDBC 同步查询、或 block(),这个 EventLoop 线程就被卡死,它负责的成百上千个连接全部停摆。这是 WebFlux 最致命也最常见的坑。

源码视角:一条响应式链路

WebFlux 的 Controller 返回的不是数据,而是 Mono(0-1 个元素)或 Flux(0-N 个元素)——它们是惰性的发布者,只有被订阅时才执行:

1
2
3
4
5
6
7
8
9
@GetMapping("/user/{id}")
public Mono<UserView> getUser(@PathVariable String id) {
return userRepository.findById(id) // Mono<User>,非阻塞 DB
.flatMap(user ->
orderClient.fetchOrders(user.getId()) // Mono<List<Order>>,非阻塞 HTTP
.map(orders -> new UserView(user, orders)))
.switchIfEmpty(Mono.error(new NotFoundException()))
.timeout(Duration.ofSeconds(2));
}

关键点:这段代码里没有任何线程在等待findById 发出 DB 请求后立即返回,线程去干别的;DB 响应回来时,Reactor 触发 flatMap 里的逻辑继续往下走。整条链路被编排成一系列回调,由事件循环驱动。

谁来订阅?是 WebFlux 框架本身。框架拿到你返回的 Mono,订阅它,把最终结果写回 HTTP 响应。你永远不该自己调 .block() 把它变回阻塞——那等于把响应式的好处全扔了。

工程权衡:WebFlux 不是银弹

第一个误区:以为 WebFlux 一定更快。对于 CPU 密集型任务,响应式毫无优势,反而因为调度开销更慢。它的收益只体现在 IO 密集 + 高并发 场景。低并发下,MVC 的简单同步模型吞吐和延迟往往更好,还更好调试。

第二个权衡:整条链路必须全程非阻塞,否则全盘皆输。如果你的持久层还在用阻塞 JDBC,那就必须把它丢到独立的有界弹性线程池(Schedulers.boundedElastic())上,避免污染 EventLoop:

1
2
Mono.fromCallable(() -> jdbcTemplate.queryForObject(sql, ...))
.subscribeOn(Schedulers.boundedElastic()); // 把阻塞调用隔离到专用线程池

但这么一搞,本质上又退化成了"线程池等 IO",响应式的并发优势被打了折扣。真正纯响应式需要 R2DBC 这样的非阻塞驱动配套,生态成熟度要评估。

第三个,也是最痛的:调试和心智成本。响应式的异常栈是断裂的——错误发生在某个回调里,堆栈往往指向 Reactor 内部而非你的业务代码,需要靠 checkpoint()onOperatorDebug() 来还原。ThreadLocal 在响应式里也基本失效(因为执行会跨线程跳转),依赖 MDC 日志、事务上下文的代码都要改用 Reactor Context 重写。团队的学习曲线是实打实的成本。

边界:什么时候该上 WebFlux

适合:网关、BFF、大量扇出调用下游、SSE/WebSocket 长连接推送、需要扛海量空闲连接的场景。

不适合:CPU 密集计算、强依赖阻塞型中间件且无非阻塞替代、团队不熟悉响应式且业务以 CRUD 为主。后者强上 WebFlux,大概率是用十倍的复杂度换来微不足道的性能,还埋下一堆 block() 隐患。

小结

WebFlux 的本质是用事件循环把线程和连接解耦,再用 Reactive Streams 的背压让快慢两端协调速度。它真正的价值在 IO 密集高并发场景下省线程,而不是无脑提速。用好它的前提是理解那条铁律——EventLoop 上不能阻塞——以及随之而来的全链路非阻塞改造、ThreadLocal 失效、断裂栈调试这些实打实的工程代价。选型时先问一句:我的瓶颈真的是线程被 IO 挂起吗?如果不是,简单的同步模型往往是更诚实的选择。