Spring WebFlux响应式编程入门:核心概念、线程模型与实战搭建 1. 从Servlet到Reactive为什么要在Spring Boot里引入WebFlux很多同学第一次接触WebFlux是因为项目里有人提了一句“咱们这个接口并发上不去要不要试试响应式”。然后打开Spring Initializr勾上Spring Reactive Web生成项目写了个Controller返回Mono 跑起来发现——好像跟以前写Spring MVC也没什么区别。于是心里犯嘀咕这东西到底解决什么问题我当初也是这么想的。真正让我理解WebFlux价值的是一个很具体的场景一个聚合接口需要同时调用三个下游服务每个平均耗时200ms。用传统的Spring MVC同步写法三个调用串行执行总耗时600msTomcat线程池里就有一个线程被占住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与FluxReactor是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提供了几种调度器SchedulerSchedulers.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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency如果你需要响应式数据库访问可以加dependency groupIdorg.springframework.boot/groupId artifactIdspring-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 MonoString hello() { return Mono.just(Hello WebFlux); } GetMapping(/stream) public FluxString 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 MonoString 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的集成。如果你在搭建过程中遇到了什么问题或者对某个概念还有疑问可以先检查一下是不是在链路上混入了阻塞调用——这是新手最容易踩的坑没有之一。