Skip to content

Spring WebFlux 响应式编程

提出问题

传统 Spring MVC 基于 Servlet 规范,每个请求占用一个线程,直到响应返回。在高并发场景下(尤其是 IO 密集的长连接、实时数据流),线程池很快被打满,系统吞吐量受限于线程数。Tomcat 默认 200 线程,4 核 16G 的机器,压测下 500 并发就开始有线程等待和上下文切换开销。

Spring WebFlux 基于 Reactor 的 Mono/Flux 和 Netty 的非阻塞 IO,用少量线程处理大量并发请求。但 WebFlux 并非银弹——同步阻塞的数据库驱动、第三方 HTTP 调用都会拖垮它的优势。面试官问你 WebFlux,往往是在考察:你是否真正理解响应式编程的适用边界,还是仅仅把它当作「异步 MVC」来用?

分析问题

Netty 事件循环:WebFlux 的底层引擎

WebFlux 默认跑在 Netty 上,Netty 的核心是 EventLoop 模型。EventLoop 本质上是一个单线程循环,不断重复:从 Channel 读事件 → 执行 Handler → 写回响应。默认启动的 EventLoop 线程数等于 CPU 核数 × 2(比如 4 核机器起 8 个 EventLoop)。

ascii
┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│  EventLoop 1 │     │  EventLoop 2 │     │  EventLoop 3 │
│  (CPU 核0)   │     │  (CPU 核1)   │     │  (CPU 核2)   │
│  select()    │     │  select()    │     │  select()    │
│  process()   │     │  process()   │     │  process()   │
│  runTasks()  │     │  runTasks()  │     │  runTasks()  │
└──────┬───────┘     └──────┬───────┘     └──────┬───────┘
       │                    │                    │
       └────────────────────┼────────────────────┘

              ┌─────────────▼─────────────┐
              │  Channel 注册到 EventLoop  │
              │  每个 Channel 绑定一个     │
              │  EventLoop,生命周期不变  │
              └───────────────────────────┘

关键约束:一个 EventLoop 负责多个 Channel,但同一个 Channel 的所有操作都在同一个 EventLoop 线程上执行,不存在锁竞争。代价是:如果你在这个 EventLoop 线程里做了一次阻塞 IO(比如 JDBC 查询),整个 EventLoop 上的所有 Channel 都得等它完成。这也是为什么 WebFlux 不能用 JDBC 的原因。

Reactor 核心:Mono 与 Flux

Reactor 是 WebFlux 的底层响应式库,提供两种核心发布者:

  • Mono<T> — 表示 0 或 1 个元素的异步序列
  • Flux<T> — 表示 0 到 N 个元素的异步序列
java
// Mono 示例:返回单个结果
Mono<String> greeting = Mono.just("Hello").map(String::toUpperCase);

// Flux 示例:返回多个结果
Flux<Integer> numbers = Flux.just(1, 2, 3, 4, 5)
    .filter(n -> n > 2)
    .map(n -> n * 10);

关键区别:Mono/Flux 是声明式的,调用 map/filter 只是构建操作链,不执行;只有 subscribe 或 WebFlux 框架内部的订阅才会触发真正执行。这种「惰性求值」机制是构建非阻塞管道的基石。

ascii
时间线:
        ┌─ just("Hello") ──→ map(toUpper) ──→ subscribe ──→ 输出 "HELLO"
        │    声明阶段:只构建操作链,不执行          │
        │                                           │
        └────────── 订阅触发异步执行 ────────────────┘

背压(Backpressure)

背压是响应式流规范的核心:下游可以告诉上游「我消化不了,慢点发」。Reactor 内置了多种背压策略:

策略方法行为
限流limitRate(n)上游每次最多发 n 个,不等下游请求
缓冲onBackpressureBuffer()上游元素全部缓冲到队列,可能 OOM
丢弃onBackpressureDrop()上游多发的元素直接丢弃
报错onBackpressureError()背压发生时抛出异常
最新onBackpressureLatest()只保留最新元素,丢弃旧的
java
Flux.range(1, 1_000_000)
    .log()
    .subscribe(new BaseSubscriber<Integer>() {
        @Override
        protected void hookOnSubscribe(Subscription subscription) {
            // 每次只请求 100 个,控制消费节奏
            request(100);
        }

        @Override
        protected void hookOnNext(Integer value) {
            // 处理一个元素
            if (value % 100 == 0) {
                request(100); // 处理完一批,再请求下一批
            }
        }
    });

生产踩坑:我们有个实时数据推送服务,用 Flux.interval(Duration.ofMillis(10)) 每秒产生 100 个事件,下游消费端处理一个事件平均耗时 50ms。10ms 产生 vs 50ms 消费,差 5 倍。如果不加背压,未处理的事件会在 Sinks.many() 内部队列无限堆积,最终 OOM。解决办法是 limitRate(20) 让上游主动限频,配合 onBackpressureLatest() 丢弃来不及处理的旧数据,只保留最新状态。

与 Spring MVC 的核心对比

维度Spring MVCSpring WebFlux
底层Servlet API(阻塞 IO)Reactor Netty / Undertow(非阻塞 IO)
线程模型请求 → 线程 → 响应(同线程)事件循环 + Work 线程(少量线程处理海量连接)
默认线程数Tomcat 200(max 可配)EventLoop = CPU × 2(4 核 ≈ 8 线程)
编程模型注解 @Controller注解 + 函数式 RouterFunction
数据库JPA / JDBC(阻塞)R2DBC / MongoDB Reactive(非阻塞)
最大并发(4 核 16G)约 2000 TPS(Tomcat 调优后)约 15000+ TPS(Netty 非阻塞)
内存占用(1000 连接)约 500MB(线程栈 × 1000)约 50MB(事件循环 + 少量线程)
适用场景CPU 密集、短请求、阻塞库IO 密集、长连接、流式响应、网关

数值来源:基于 4 核 16G 机器、短请求(<50ms 处理)、纯内存操作的压测对比。实际场景中数据库 IO 会成为瓶颈,但 WebFlux 在连接数上的优势不变。

java
// WebFlux 函数式路由示例
@Configuration
public class RouterConfig {
    @Bean
    public RouterFunction<ServerResponse> route(UserHandler handler) {
        return RouterFunctions.route()
            .GET("/api/users/{id}", handler::getUser)
            .GET("/api/users", handler::listUsers)
            .POST("/api/users", handler::createUser)
            .build();
    }
}

@Component
class UserHandler {
    private final ReactiveUserRepository repo;

    public Mono<ServerResponse> getUser(ServerRequest req) {
        return repo.findById(req.pathVariable("id"))
            .flatMap(user -> ServerResponse.ok().bodyValue(user))
            .switchIfEmpty(ServerResponse.notFound().build());
    }
}

生产实战:R2DBC 的坑

R2DBC 是响应式数据库驱动,但远没有 JDBC 成熟,以下是我们实际踩过的坑:

坑 1:连接池耗尽

java
// 错误写法:flatMap 无限并发,R2DBC 连接池瞬间打满
return userRepo.findAll()
    .flatMap(user -> orderRepo.findByUserId(user.getId())) // 并发 N 个查询
    .collectList();
// 改为:控制并发度
return userRepo.findAll()
    .flatMap(user -> orderRepo.findByUserId(user.getId()), 16) // 最多 16 并发
    .collectList();

坑 2:事务管理 R2DBC 的 @Transactional 需要 Reactive 事务管理器,不能和 JDBC 事务管理器混用。Spring 会报 No transaction manager found。配置:

java
@Bean
public ReactiveTransactionManager transactionManager(DatabaseClient client) {
    return new R2dbcTransactionManager(client.getConnectionFactory());
}

坑 3:阻塞操作混入

java
// 错误:在 WebFlux 里调用阻塞 API
return userRepo.findById(id)
    .map(user -> {
        String encrypt = AES.encrypt(user.getEmail()); // 阻塞操作!
        return user.withEmail(encrypt);
    });

// 正确:切换到 Schedulers.boundedElastic() 执行阻塞代码
return userRepo.findById(id)
    .publishOn(Schedulers.boundedElastic())
    .map(user -> {
        String encrypt = AES.encrypt(user.getEmail());
        return user.withEmail(encrypt);
    });

不适用场景

不适用场景

  • CPU 密集计算(加解密、图像处理)—— 事件循环被阻塞,整个服务瘫痪
  • 依赖 JDBC / JPA 的数据库访问——阻塞操作会挂起事件循环线程
  • 团队缺乏响应式思维——调试困难、学习曲线陡峭

调试难点:WebFlux 的异常堆栈往往包含几十层 Reactor 内部操作符,难以定位到业务代码。生产实践一般加 checkpoint() 标记关键操作链,而不是全局开 Hooks.onOperatorDebug()(后者会记录全量 assembly 信息,对高频请求性能影响很大):

java
Flux<User> users = repo.findAll()
    .checkpoint("findAllUsers", true) // 开启详细检查点,只标记关键链路
    .filter(user -> user.isActive());

面试常问:WebFlux 与虚拟线程的取舍

Spring Boot 3.2 + JDK 21 引入了虚拟线程(Virtual Threads),它也能用少量线程处理大量阻塞请求。那 WebFlux 还有必要吗?

对比维度WebFlux + Netty虚拟线程 + Tomcat
阻塞操作必须用 publishOn 切线程随意阻塞,虚拟线程自动挂起
数据库必须 R2DBCJDBC 直接可用,阻塞时自动 yield
流式响应原生支持 Flux<SseEvent>需要 ResponseBodyEmitter,较复杂
学习成本高(Mono/Flux 操作符矩阵)低(同步编程思维)
吞吐量极高(事件循环无上下文切换)高(虚拟线程切换开销约 1μs)
成熟度7 年 +JDK 21 刚 GA,仍在迭代

结论:如果项目重度依赖 JDBC 或团队不熟悉响应式,优先选虚拟线程。如果要做流式 API、网关、高吞吐 IO 密集型服务,WebFlux 仍是更好的选择。两者在 Spring Boot 3.x 中可以共存,但不建议在同一个服务里混用——调试复杂度翻倍。

总结

  • WebFlux 适合 IO 密集型、长连接、流式响应和高并发网关场景,不适合 CPU 密集或依赖阻塞库的业务
  • Mono/Flux 是声明式 API,惰性求值,需要理解订阅才能执行
  • 背压是响应式流的核心控制机制,使用 BaseSubscriberlimitRate() 显式控制消费节奏,否则可能 OOM
  • 生产放 checkpoint() 定位操作链,不要全局开 Hooks.onOperatorDebug()(性能开销大)
  • 数据库必须用 R2DBC 或 MongoDB Reactive,不能混用 JDBC
  • R2DBC 连接池并发控制、事务管理器配置、阻塞操作切线程是三大致命坑
  • 切换 WebFlux 前务必评估:你的瓶颈是线程还是 CPU? 前者才值得入
  • Spring Boot 3.x + 虚拟线程是 WebFlux 的最强替代方案,但流式场景下 WebFlux 仍不可替代

参考

参考:Spring WebFlux 官方文档 (https://docs.spring.io/spring-framework/reference/web/webflux.html)
参考:Reactor 3 Reference Guide (https://projectreactor.io/docs/core/release/reference/)
参考:Spring Boot 3.x 响应式编程实战
参考:R2DBC 官方文档 (https://r2dbc.io/)
参考:Netty 4.x 事件循环模型 (https://netty.io/wiki/reference-counted-objects.html)

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。