☰
Spring WebFlux响应式编程入门:核心概念、线程模型与实战搭建
2026/10/12 8:33:01 网站建设 项目流程

1. 从Servlet到Reactive:为什么要在Spring Boot里引入WebFlux

很多同学第一次接触WebFlux,是因为项目里有人提了一句“咱们这个接口并发上不去,要不要试试响应式”。然后打开Spring Initializr,勾上Spring Reactive Web,生成项目,写了个Controller返回Mono ,跑起来发现——好像跟以前写Spring MVC也没什么区别。于是心里犯嘀咕:这东西到底解决什么问题?

我当初也是这么想的。真正让我理解WebFlux价值的,是一个很具体的场景:一个聚合接口需要同时调用三个下游服务,每个平均耗时200ms。用传统的Spring MVC同步写法,三个调用串行执行,总耗时600ms,Tomcat线程池里就有一个线程被占住600ms。当QPS上来之后,线程池很快被打满,后续请求全部排队。而WebFlux的做法是三个调用同时发起,总耗时取决于最慢的那个,大约200ms出头,而且整个过程不占用额外的线程去“等待”。

这就是WebFlux的核心价值:用更少的资源处理更多的并发连接,尤其是I/O密集型场景。它不是让单次请求变快,而是让系统在等待I/O的过程中不浪费线程资源。

但这里有个前提需要说清楚:WebFlux不是银弹。如果你的接口是CPU密集型的,比如大量计算、图像处理、复杂算法,那WebFlux帮不了你,甚至可能因为响应式编程的额外开销而略微变慢。它真正擅长的,是高并发、I/O等待时间占比高的场景——网关、聚合层、实时推送、流式数据处理,这些才是WebFlux的主场。

还有一个常见的误解:有人觉得用了WebFlux就一定要把整个项目全部改成响应式。实际上Spring Boot允许Servlet和Reactive共存,你完全可以在一个项目里让大部分接口继续用Spring MVC,只把少数几个高并发的聚合接口用WebFlux来实现。这种渐进式的策略在实际项目中往往更务实。

这篇文章是WebFlux系列的上篇,重点放在核心概念、线程模型、与Spring MVC的本质差异,以及第一个可运行的WebFlux项目搭建。下篇会深入讲背压、WebClient、SSE、WebSocket以及和数据库的响应式集成。如果你正在考虑要不要在项目里引入WebFlux,或者已经用了但总觉得没吃透,那这篇内容应该能帮你把底层逻辑理清楚。

2. 响应式流规范与Reactor的核心角色

2.1 响应式流到底规定了什么

在聊Reactor之前,得先搞清楚它背后的规范——Reactive Streams。这个规范其实非常简短,核心就是四个接口:Publisher、Subscriber、Subscription、Processor。它解决的问题只有一个:在异步环境下,如何让生产者根据消费者的能力来发送数据,而不是无限制地推送导致消费者被压垮。

这个机制叫背压(Backpressure)。举个生活中的例子:你家的水龙头一直开着,但下水道排水能力有限,如果没有一个机制告诉水龙头“你慢点放水”,那水槽迟早会溢出来。响应式流规范就是定义了一套“下水道告诉水龙头该放多少水”的标准协议。

规范里定义了Publisher的subscribe方法,Subscriber收到数据前会先收到一个Subscription对象,通过它来请求指定数量的数据或者取消订阅。这套机制看起来简单,但它是整个响应式编程生态的基石。Reactor、RxJava、Akka Streams都实现了这套规范,所以它们之间可以互操作。

2.2 Reactor的两种核心类型:Mono与Flux

Reactor是Spring WebFlux默认的响应式库,它提供了两个核心类型:

  • Mono:表示0个或1个元素的异步序列。可以理解为“未来某个时刻会有一个值,或者没有值”。
  • Flux:表示0个到N个元素的异步序列。可以理解为“未来某个时刻会有一串值陆续到达”。

这两个类型都实现了Publisher接口。你可能会问:为什么不用Java自带的CompletableFuture?因为CompletableFuture只能表示单个结果,而且它没有背压机制。Flux可以表示流式数据,比如从数据库里逐条读取一百万条记录,消费者可以按自己的节奏来请求数据。

在实际使用中,Mono和Flux提供了大量的操作符,比如map、flatMap、filter、zip、merge、concat等等。这些操作符让你可以用声明式的方式组合异步逻辑,而不需要写回调地狱。

2.3 冷序列与热序列的区别

这是很多初学者容易忽略的一个概念。冷序列是指每次订阅都会重新开始产生数据,比如一个从数据库查询的Flux,每个订阅者都会触发一次新的查询。热序列是指数据在订阅之前就已经开始产生,后来的订阅者只能收到订阅之后的数据,比如一个WebSocket连接或者一个事件总线。

大部分Reactor的操作符返回的都是冷序列。这个特性在实际开发中很重要,因为如果你不小心对一个冷序列订阅了两次,那底层的查询或请求就会执行两次。我见过有同学在代码里写了这样的逻辑:先调一次Mono获取结果做判断,然后又订阅一次做返回,结果下游服务被调了两次。这种问题在同步代码里不会出现,但在响应式代码里如果不注意就会踩坑。

解决方式是用cache()操作符把结果缓存起来,或者用share()把冷序列转成热序列。但要注意cache的缓存策略,如果数据会变化,缓存时间需要仔细设计。

3. WebFlux的线程模型:事件循环到底怎么跑

3.1 与Spring MVC线程模型的对比

Spring MVC基于Servlet API,默认使用Tomcat的线程池。每个请求进来,Tomcat从线程池里分配一个线程,这个线程从头到尾处理这个请求——包括参数解析、业务逻辑、数据库查询、远程调用、结果序列化。如果中间有阻塞操作,比如等待数据库返回,这个线程就干等着,什么也做不了。

默认情况下,Tomcat的最大线程数是200。这意味着同时只能有200个请求被处理,第201个请求就要排队。如果你的接口平均响应时间是500ms,那理论上每秒最多处理400个请求(200线程 / 0.5秒)。想提高吞吐量,要么加线程数(但线程多了上下文切换开销大),要么缩短响应时间。

WebFlux的模型完全不同。它默认使用Netty作为服务器(也可以换成Undertow或Servlet 3.1+容器),底层是事件循环(Event Loop)模型。Netty的EventLoop数量默认是CPU核数乘以2。每个EventLoop是一个单线程的循环,不断检查是否有I/O事件就绪,就绪就处理,没就绪就继续轮询。

关键在于:当WebFlux需要执行阻塞操作时,它不会让EventLoop线程去等,而是把任务提交到另一个线程池,然后立即释放EventLoop线程去处理其他请求。等阻塞操作完成后再回到EventLoop线程继续处理响应。这样,少量的EventLoop线程就能支撑大量的并发连接。

3.2 默认线程池的配置与调度器

Reactor提供了几种调度器(Scheduler):

  • Schedulers.immediate():在当前线程执行,不切换。
  • Schedulers.single():使用一个可复用的单线程。
  • Schedulers.parallel():使用固定大小的线程池,大小默认等于CPU核数。
  • Schedulers.boundedElastic():使用一个有界弹性线程池,适合执行阻塞任务。

在WebFlux中,Controller的方法默认在EventLoop线程上执行。如果你在方法里做了阻塞操作,比如用JDBC查数据库,那EventLoop线程就被卡住了,整个事件循环都会受影响。这时候需要用subscribeOn(Schedulers.boundedElastic())把阻塞操作切换到弹性线程池。

但更好的做法是:根本不要在WebFlux的链路上使用阻塞API。用R2DBC代替JDBC,用WebClient代替RestTemplate,用响应式的Redis客户端代替传统的Jedis。如果实在避不开阻塞调用(比如某个老系统的SDK只有同步版本),那就必须显式地切换到boundedElastic调度器。

3.3 一个容易踩的坑:在EventLoop里做阻塞操作

我见过一个很典型的案例:某同学在WebFlux的Controller里直接调用了RestTemplate去请求下游服务。测试环境没问题,因为并发低。上线后并发一上来,整个服务响应变得极慢,甚至出现大量超时。原因就是RestTemplate是阻塞的,它把EventLoop线程占住了,导致事件循环无法处理其他请求。

排查这个问题的过程也很有意思。当时看监控发现CPU使用率并不高,但请求延迟飙升。用jstack看线程栈,发现大量EventLoop线程都卡在socketRead上。换成WebClient之后,问题立刻消失。

所以记住一条铁律:在WebFlux的请求处理链路上,任何阻塞调用都必须被隔离到boundedElastic调度器上,或者干脆换成非阻塞的客户端。

4. 搭建第一个WebFlux项目:从依赖到跑通

4.1 依赖选择与项目结构

用Spring Initializr创建项目时,选择Spring Reactive Web即可。核心依赖是spring-boot-starter-webflux,它包含了Reactor、Netty、WebFlux的核心模块。如果你同时需要Spring MVC和WebFlux,可以两个starter都加,但要注意默认情况下Spring Boot会优先使用WebFlux(因为Netty在classpath上)。

一个典型的pom依赖配置如下:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>

如果你需要响应式数据库访问,可以加:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-r2dbc</artifactId> </dependency>

项目结构跟普通的Spring Boot项目没有区别,Controller、Service、Repository分层照旧,只是返回类型从普通的对象变成了Mono或Flux。

4.2 编写第一个响应式Controller

一个最简单的例子:

@RestController @RequestMapping("/api") public class HelloController { @GetMapping("/hello") public Mono<String> hello() { return Mono.just("Hello WebFlux"); } @GetMapping("/stream") public Flux<String> stream() { return Flux.interval(Duration.ofSeconds(1)) .map(i -> "tick-" + i) .take(5); } }

第一个接口返回单个值,第二个接口每秒返回一个值,共返回5个。用浏览器访问/api/stream,你会看到内容逐条出现,而不是一次性返回。这就是流式响应的效果。

需要注意的是,Flux.interval默认运行在parallel调度器上。如果你在interval后面直接做阻塞操作,同样会卡住EventLoop。所以链路上的每个操作符都要注意是否引入了阻塞。

4.3 用WebTestClient做测试

WebFlux提供了一个专门的测试客户端WebTestClient,它可以很方便地测试响应式接口:

@SpringBootTest @AutoConfigureWebTestClient class HelloControllerTest { @Autowired private WebTestClient webTestClient; @Test void testHello() { webTestClient.get().uri("/api/hello") .exchange() .expectStatus().isOk() .expectBody(String.class).isEqualTo("Hello WebFlux"); } @Test void testStream() { webTestClient.get().uri("/api/stream") .exchange() .expectStatus().isOk() .returnResult(String.class) .getResponseBody() .take(3) .collectList() .block() .forEach(System.out::println); } }

WebTestClient的好处是它本身就是响应式的,可以测试流式接口,而且不需要启动真实的服务器(也可以选择启动)。

5. 响应式编程中那些反直觉的地方

5.1 没有订阅,什么都不会发生

这是响应式编程最核心也最容易忘的一点。你写了一个Mono,做了一堆map、flatMap,但如果最后没有subscribe,整个链路不会执行。这跟Stream API有点像,但Stream是同步的,你忘了终端操作编译器会提醒你。而Mono/Flux作为返回值交给WebFlux框架时,框架会自动订阅,所以Controller里不写subscribe没问题。但如果你在Service里手动创建了一个Mono做副作用操作,忘了订阅,那它永远不会执行。

我见过有同学在Service里写了这样的代码:

public void doSomething() { Mono.just("data") .flatMap(this::process) .doOnNext(result -> log.info("done: {}", result)); // 没有subscribe,什么都不会发生 }

这种代码不会报错,但也不会执行。正确的做法是返回Mono让调用方订阅,或者在确实需要触发副作用时显式subscribe。

5.2 flatMap和concatMap的选择

flatMap和concatMap都是把内层Publisher的元素展平,但行为不同。flatMap是并发执行的,内层Publisher的顺序不确定;concatMap是串行执行的,保证顺序。

举个例子:你有一个Flux ,需要对每个元素调用一个远程接口。用flatMap的话,所有调用会同时发起,速度快但结果顺序可能乱;用concatMap的话,一个调完再调下一个,顺序有保证但速度慢。

选择哪个取决于业务需求。如果顺序不重要,用flatMap提高吞吐;如果顺序重要(比如写数据库有依赖关系),用concatMap。还有一个折中方案是flatMapSequential,它并发执行但按原始顺序收集结果。

5.3 错误处理的正确姿势

在响应式链路里,错误处理跟在同步代码里完全不同。try-catch只能捕获同步抛出的异常,异步链路里的错误需要通过onErrorResume、onErrorReturn、doOnError等操作符来处理。

public Mono<String> fetchData() { return webClient.get().uri("/data") .retrieve() .bodyToMono(String.class) .onErrorResume(e -> { log.error("fetch failed", e); return Mono.just("default"); }); }

onErrorResume可以返回一个降级值,也可以返回另一个Mono继续链路。onErrorReturn只能返回一个固定值。doOnError只做副作用(比如打日志),不会消费错误,错误还会继续往下传。

还有一个容易忽略的点:响应式链路里的错误如果没有被处理,最终会以onError信号传递给订阅者。在WebFlux中,框架会把它转换成HTTP 500响应。但如果你在链路中间用了onErrorResume吞掉了错误,那上层就感知不到了。所以错误处理的位置和策略需要仔细设计。

6. 什么时候该用WebFlux,什么时候不该用

6.1 适合的场景

  • API网关:需要转发大量请求,每个请求的I/O等待时间长,WebFlux可以用少量线程支撑高并发。
  • 聚合服务:一个接口需要调用多个下游服务,WebFlux可以并发发起调用,减少总响应时间。
  • 实时推送:SSE、WebSocket等长连接场景,WebFlux的流式模型天然适合。
  • 流式数据处理:从消息队列或数据库读取大量数据,逐条处理并返回。

6.2 不适合的场景

  • CPU密集型任务:大量计算、加密解密、图像处理,WebFlux没有优势。
  • 团队不熟悉响应式编程:响应式代码的调试和排错门槛比同步代码高,如果团队没有经验,强行上WebFlux可能适得其反。
  • 已有大量阻塞依赖:如果项目中大量使用了JDBC、RestTemplate、同步的Redis客户端,改成响应式的成本很高,收益可能不明显。

6.3 渐进式迁移的策略

如果决定引入WebFlux,不建议一次性全量迁移。可以先从一两个高并发的聚合接口开始,用WebFlux实现,其他接口继续用Spring MVC。Spring Boot支持两种栈共存,只要注意不要在同一条链路上混用阻塞和非阻塞即可。

等团队对响应式编程熟悉了,再逐步扩大范围。数据库访问可以先继续用JDBC(通过boundedElastic隔离),等R2DBC生态更成熟后再迁移。

7. 上篇小结与下篇预告

写到这里,WebFlux的核心概念和第一个可运行的项目已经讲完了。回顾一下重点:WebFlux解决的是高并发下I/O等待浪费线程的问题,它的核心是响应式流规范和Reactor的Mono/Flux类型,线程模型基于事件循环,使用时必须避免在EventLoop上做阻塞操作。

我个人在实际项目中的体会是,WebFlux的学习曲线主要不在API本身,而在于思维方式的转变——从“命令式地一步步执行”变成“声明式地组合异步流”。刚开始写的时候总觉得别扭,但写多了之后会发现,很多以前需要回调或者CompletableFuture嵌套的场景,用Reactor的操作符表达起来反而更清晰。

下篇会重点讲WebClient的实战用法、背压的具体处理策略、SSE和WebSocket的实现,以及R2DBC的集成。如果你在搭建过程中遇到了什么问题,或者对某个概念还有疑问,可以先检查一下是不是在链路上混入了阻塞调用——这是新手最容易踩的坑,没有之一。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询