为什么 Spring Cloud Gateway 必须用 WebFlux?

724 阅读17分钟

引子:为什么Spring Cloud Gateway选择WebFlux?

Spring Cloud Gateway是Spring官方的新一代网关,它彻底抛弃了之前基于Servlet的Zuul 1.x,转而采用WebFlux。

这不是赶时髦,而是网关场景的必然选择。

网关的特殊性

网关的核心工作是什么?转发请求。

客户端请求
    ↓
网关接收
    ↓
路由到后端服务AB、C(可能需要调用多个)
    ↓
聚合结果
    ↓
返回给客户端

这个过程中,网关自己几乎不做计算,95%的时间都在:

  • 等待后端服务响应
  • 处理网络I/O

传统Servlet的困境

如果用Servlet容器(Tomcat):

1个请求进来
    ↓
分配1个线程
    ↓
线程发起HTTP调用后端服务
    ↓
线程阻塞等待响应(可能100ms-500ms)
    ↓
收到响应,返回客户端
    ↓
线程释放

问题在哪?

假设网关要承载1万QPS:

  • 每个请求平均耗时200ms
  • 同时在处理的请求 = 10000 * 0.2 = 2000个
  • 需要2000个线程

但Tomcat默认最大线程数是200,即使调到2000:

  • 2000个线程 × 1MB栈空间 = 2GB内存
  • 线程上下文切换开销巨大
  • 大部分线程都在阻塞等待,浪费资源

WebFlux的优势

同样的场景,WebFlux只需要:

  • 8-16个EventLoop线程
  • 内存占用不到200MB
  • 线程永不阻塞,利用率100%

这就是为什么Spring Cloud Gateway必须用WebFlux。

网关不是简单的应用,而是流量枢纽,必须用非阻塞I/O来榨干硬件性能。

第一层:WebFlux的技术栈

先看WebFlux到底由哪些部分组成:

graph TB
    A[你的Controller代码] --> B[Spring WebFlux框架层]
    B --> C[Project Reactor响应式库]
    C --> D[Netty网络I/O框架]
    D --> E[Java NIO]
    E --> F[操作系统 epoll/kqueue]

每一层都有明确的职责:

层次组件职责举例
应用层Spring WebFlux路由、注解、依赖注入@GetMapping
编程模型层Project Reactor响应式APIMono、Flux、flatMap
网络层Netty事件驱动I/OEventLoop、Channel
系统抽象层Java NIO非阻塞I/OSelector、ByteBuffer
操作系统层epoll/kqueueI/O多路复用系统调用

这些层次环环相扣,缺一不可。

第二层:Reactor到底是什么

很多人第一次接触WebFlux,会被两个"Reactor"搞晕:

  • Netty的Reactor模式
  • Project Reactor库

它们是不同的东西。

Netty的Reactor模式

这是一种设计模式,用于处理并发I/O:

// Netty的Reactor实现
EventLoopGroup bossGroup = new NioEventLoopGroup(1);     // 主Reactor
EventLoopGroup workerGroup = new NioEventLoopGroup(4);   // 从Reactor

ServerBootstrap bootstrap = new ServerBootstrap()
    .group(bossGroup, workerGroup);

Boss负责接收连接,Worker负责处理I/O,这就是Reactor模式的主从多线程版本。

Project Reactor库

这是Spring生态的响应式编程库,提供Mono和Flux这些API:

// 纯内存操作,不需要Netty
Mono.just(1)
    .map(i -> i * 2)
    .filter(i -> i > 1)
    .subscribe(System.out::println);

Project Reactor是独立的库,不依赖Netty。它只是提供了响应式编程的抽象,类似Java 8的Stream API。

那为什么总和Netty一起出现?

因为在WebFlux做网络I/O时,底层用Netty实现,上层用Reactor API编程。两者配合工作:

Reactor定义"做什么"(业务逻辑)
Netty负责"怎么做"(网络I/O)

第三层:一个半Netty的架构

这是理解WebFlux的核心。

WebFlux使用了两套Netty线程组,但第二套是"阉割版"。

Server端:完整的Netty

// 标准的Netty服务端配置
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup(4);

ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
    .channel(NioServerSocketChannel.class)
    .childHandler(new HttpServerInitializer());

这是完整的Reactor模式:

  • Boss线程组:专门负责accept()新的TCP连接
  • Worker线程组:负责处理已建立连接的I/O读写
graph LR
    A[客户端连接1] --> B[Boss线程]
    C[客户端连接2] --> B
    D[客户端连接3] --> B
    B --> E[Worker线程1]
    B --> F[Worker线程2]
    B --> G[Worker线程3]
    B --> H[Worker线程4]

Boss就像公司老板,只负责接项目(接收连接),然后分配给项目经理(Worker)去执行。

Client端:半个Netty

当WebFlux需要调用外部服务时,用的是WebClient:

WebClient client = WebClient.builder()
    .baseUrl("http://api.example.com")
    .build();

Mono<User> user = client.get()
    .uri("/user/123")
    .retrieve()
    .bodyToMono(User.class);

WebClient底层用的是Netty的HttpClient:

// Netty Client的配置
HttpClient httpClient = HttpClient.create()
    .runOn(new NioEventLoopGroup(4));  // 只有EventLoop

注意:这里只有EventLoop,没有Boss线程组。

为什么Client不需要Boss?

这是关键问题。

Boss的职责是什么?

// Boss线程做的事
while (true) {
    SocketChannel clientSocket = serverSocket.accept();  // 接收新连接
    workerGroup.register(clientSocket);  // 分配给Worker
}

Boss负责监听端口,接收客户端主动发起的连接请求。

Client的工作方式完全不同:

// Client主动连接服务端
Socket socket = new Socket("api.example.com", 80);
socket.connect();  // 主动发起连接

Client是主动连接别人,不需要监听端口,自然不需要Boss。

类比说明

Server端(完整公司架构)

老板(Boss)
    ↓
专门负责签约新客户(接收TCP连接)
    ↓
把项目分配给项目经理(Worker)
    ↓
项目经理负责执行(处理I/O)

Client端(外包开发团队)

没有老板
    ↓
开发人员直接接到任务(主动发起连接)
    ↓
自己去对接客户(发送HTTP请求)
    ↓
完成后直接汇报(接收HTTP响应)

外包团队不需要老板来接活,因为活是别人派给他们的。

这就是为什么说"半个Netty":Client端的Netty只有EventLoop,缺少Boss组件。

完整架构图

120.png

  • 绿色:Server Boss(接收连接)
  • 蓝色:Server Worker(处理业务)
  • 粉色:Client EventLoop(调用外部)

Server和Client是两套独立的线程组,通过Reactor的回调机制协作。

第四层:一个请求的完整生命周期

假设有个Controller需要查询用户和订单:

@RestController
public class OrderController {
    
    @Autowired
    private WebClient webClient;
    
    @GetMapping("/order/{userId}")
    public Mono<OrderDTO> getOrder(@PathVariable int userId) {
        return webClient.get()
            .uri("http://user-service/user/" + userId)
            .retrieve()
            .bodyToMono(User.class)
            .flatMap(user -> webClient.get()
                .uri("http://order-service/order/" + user.getOrderId())
                .retrieve()
                .bodyToMono(Order.class))
            .map(order -> new OrderDTO(order));
    }
}

完整时序图

sequenceDiagram
    participant Client as 客户端
    participant SB as Server Boss<br/>(1个线程)
    participant SW as Server Worker<br/>(4个线程)
    participant Code as 业务代码<br/>(Reactor)
    participant CE as Client EventLoop<br/>(4个线程)
    participant US as 用户服务
    participant OS as 订单服务

    Client->>SB: HTTP请求
    Note over SB: Boss接收TCP连接
    SB->>SW: 分配给Worker线程2
    Note over SW: 线程2解析HTTP
    SW->>Code: 路由到getOrder()
    
    Note over Code: 执行webClient.get(user)
    Code->>CE: 派发给Client EventLoop
    Note over SW: Worker线程2留下钩子<br/>立即返回,不等待
    
    Note over CE: Client线程5发起HTTP
    CE->>US: GET /user/123
    
    Note over SW: Worker线程2继续<br/>处理其他请求
    
    US->>CE: 返回User数据
    Note over CE: 触发Reactor回调
    CE->>Code: 执行flatMap逻辑
    
    Note over Code: 执行webClient.get(order)
    Code->>CE: 再次派发任务
    Note over CE: Client线程6发起HTTP
    CE->>OS: GET /order/456
    
    OS->>CE: 返回Order数据
    Note over CE: 触发最后的map回调
    CE->>SW: 返回最终结果
    
    Note over SW: Worker线程2收到结果
    SW->>Client: 发送HTTP响应<br/>不经过Boss

详细步骤拆解

第1步:接收连接(Boss的工作)

客户端发起TCP连接
    ↓
Server Boss线程(线程1)执行accept()
    ↓
创建SocketChannel
    ↓
注册到Server Worker线程组
    ↓
Boss线程回到循环,继续accept其他连接

Boss只负责接收连接,立即就交出去了。

第2步:处理HTTP请求(Worker的工作)

假设分配给Worker线程2
    ↓
线程2从SocketChannel读取HTTP请求
    ↓
解析HTTP头、路径、参数
    ↓
WebFlux路由:/order/123 -> OrderController.getOrder(123)
    ↓
执行Controller方法

这一步都在线程2上同步执行。

第3步:第一次外部调用(关键转折点)

// 执行到这一行
return webClient.get()
    .uri("http://user-service/user/123")
    .retrieve()
    .bodyToMono(User.class)

这里发生了什么?

Worker线程2执行webClient.get()
    ↓
创建HTTP请求对象
    ↓
派发给Client EventLoop线程组
    ↓
假设分配给Client线程5
    ↓
Worker线程2注册回调(钩子)
    ↓
立即返回Mono<User>对象(此时还没有数据)
    ↓
Worker线程2的工作完成,可以处理其他请求了

关键:Worker线程2不等待!

它留下一个"钩子"(回调函数),然后立即释放,去处理下一个HTTP请求了。

第4步:Client线程发起实际调用

Client EventLoop线程5拿到任务
    ↓
使用Netty的Channel发起HTTP请求
    ↓
通过NIO的Selector注册OP_CONNECT事件
    ↓
发送HTTP请求数据到用户服务
    ↓
注册OP_READ事件,等待响应
    ↓
线程5不阻塞,继续处理其他任务

Client线程5也不会傻等,它发出请求后,通过Selector注册了"读事件",然后去干别的了。

第5步:接收用户服务响应

用户服务返回数据
    ↓
Selector检测到OP_READ事件
    ↓
Client线程5被唤醒
    ↓
从SocketChannel读取响应数据
    ↓
解析HTTP响应体,得到User对象
    ↓
触发Reactor的回调链

这时,之前注册的"钩子"被触发了。

第6步:执行flatMap(还在Client线程5上)

.flatMap(user -> webClient.get()
    .uri("http://order-service/order/" + user.getOrderId())
    .retrieve()
    .bodyToMono(Order.class))
Client线程5拿到User对象
    ↓
执行flatMap中的lambda
    ↓
再次调用webClient.get()
    ↓
这次可能分配给Client线程6
    ↓
发起第二个HTTP请求到订单服务
    ↓
注册新的回调

第7步:订单服务响应

订单服务返回Order数据
    ↓
Client线程6接收响应
    ↓
触发map回调
    ↓
构造OrderDTO对象
    ↓
调用之前Worker线程2留下的钩子

第8步:返回给客户端(Worker的收尾工作)

Worker线程2(或者其他空闲的Worker)被唤醒
    ↓
拿到最终的OrderDTO对象
    ↓
序列化成JSON
    ↓
通过原来的SocketChannel发送HTTP响应
    ↓
注意:直接发送,不经过Boss

Boss只管接收新连接,响应由Worker直接发送。

时间线对比

传统Servlet模式

T0: 请求到达,分配线程A
T100ms: 线程A调用用户服务,阻塞等待
T200ms: 收到用户服务响应
T200ms: 线程A调用订单服务,阻塞等待
T300ms: 收到订单服务响应
T300ms: 线程A返回结果
总耗时:300ms
线程A利用率:33%(100ms实际工作,200ms等待)

WebFlux模式

T0: 请求到达,Worker线程2处理
T0: Worker线程2派发任务给Client线程5
T0: Worker线程2去处理其他请求了
T50ms: Client线程5同时发起用户和订单服务调用
T100ms: 两个服务同时返回
T100ms: Client线程触发回调,汇总结果
T100ms: 通知Worker线程(可能是线程3)发送响应
总耗时:100ms
Worker线程利用率:接近100%

性能差距:3倍。

而且WebFlux的Worker线程可以同时处理成百上千个请求,Servlet的线程在阻塞等待。

第五层:公司项目的完整类比

用一个更完整的类比来理解整个流程。

角色定义

WebFlux组件公司角色职责
Server Boss公司老板签约新客户(接收TCP连接)
Server Worker项目经理管理项目、协调资源
Client EventLoop外包开发团队干具体的活(调用外部API)
Reactor回调项目钩子/里程碑通知机制

工作流程

场景:客户要求做一个项目,需要外包部分工作。

第1步:老板接项目

客户上门
    ↓
老板接待(Boss线程accept连接)
    ↓
签订合同
    ↓
分配给项目经理张三(Worker线程2)
    ↓
老板继续接待其他客户

老板只负责拉业务,不管具体执行。

第2步:项目经理启动项目

张三接手项目
    ↓
查看需求文档(解析HTTP请求)
    ↓
发现需要用户数据,这部分要外包
    ↓
联系外包团队李四(Client线程5)
    ↓
在项目管理系统设置里程碑:用户数据完成后通知我
    ↓
张三继续去管理其他项目,不干等

关键:张三不等外包完成,他去忙别的了。

第3步:外包团队干活

李四接到任务
    ↓
去用户服务API拉数据
    ↓
不阻塞等待,同时可以接其他任务
    ↓
用户服务返回数据
    ↓
李四拿到数据,触发里程碑
    ↓
通知张三:用户数据好了

第4步:项目经理继续推进

张三收到通知
    ↓
拿到用户数据,查看订单ID
    ↓
又需要订单数据,再次外包
    ↓
联系外包团队王五(Client线程6)
    ↓
设置新的里程碑:订单数据完成后通知我
    ↓
张三又去干别的了

第5步:再次外包

王五接到任务
    ↓
去订单服务API拉数据
    ↓
订单服务返回数据
    ↓
触发里程碑,通知张三

第6步:项目收尾

张三收到订单数据
    ↓
汇总用户数据和订单数据
    ↓
生成最终报告
    ↓
直接交付给客户(发送HTTP响应)
    ↓
不需要再找老板审批

老板只管接项目,交付由项目经理完成。

关键点总结

  1. 老板(Boss)只接活,不干活

    • Boss线程只负责accept连接
    • 立即分配给Worker,自己继续接新连接
  2. 项目经理(Worker)不傻等

    • 遇到需要外部资源的地方,立即外包
    • 留下"钩子"(回调),去管理其他项目
    • 一个项目经理可以同时管理几百个项目
  3. 外包团队(Client EventLoop)并发干活

    • 同时可以处理多个外包任务
    • 不阻塞,用事件驱动
    • 干完了触发钩子通知项目经理
  4. 交付不经过老板

    • 项目完成后,项目经理直接交付
    • Boss不参与项目执行和交付

为什么这么高效?

传统Servlet模式(每个项目配一个专职经理)

100个项目同时进行
    ↓
需要100个项目经理
    ↓
每个经理只盯自己的项目
    ↓
大部分时间在等外包完成(阻塞)
    ↓
人力浪费严重

WebFlux模式(少数经理管理大量项目)

100个项目同时进行
    ↓
只需要4个项目经理
    ↓
每个经理同时管理25个项目
    ↓
利用等待时间处理其他项目
    ↓
人力利用率接近100%

第六层:为什么必须全链路响应式

有些开发会这么写:

@GetMapping("/user")
public Mono<User> getUser() {
    // 用了Mono,但还是阻塞操作
    User user = jdbcTemplate.queryForObject(
        "SELECT * FROM users WHERE id = 1",
        new BeanPropertyRowMapper<>(User.class)
    );
    return Mono.just(user);
}

表面上返回了Mono,实际上还是阻塞的。

会发生什么

Worker线程2执行这个方法
    ↓
执行jdbcTemplate.queryForObject()
    ↓
这是JDBC,会阻塞等待数据库返回(可能50ms)
    ↓
Worker线程2被阻塞,啥也干不了
    ↓
50ms后数据库返回
    ↓
包装成Mono.just(user)返回

线程2被阻塞了50ms!

假设只有4个Worker线程,如果同时来4个这样的请求:

4个Worker线程全部阻塞
    ↓
第5个请求进来,没有空闲线程
    ↓
请求排队等待
    ↓
QPS = 4个线程 / 0.05秒 = 80

还不如Tomcat的200个线程!

正确的做法

@GetMapping("/user")
public Mono<User> getUser() {
    // 使用R2DBC,真正的响应式数据库驱动
    return r2dbcTemplate
        .select(User.class)
        .matching(query(where("id").is(1)))
        .one();
}

这样Worker线程不会阻塞:

Worker线程2执行这个方法
    ↓
调用r2dbcTemplate.select()
    ↓
通过R2DBC发起异步查询(类似WebClient)
    ↓
立即返回Mono<User>(还没有数据)
    ↓
Worker线程2去处理其他请求
    ↓
数据库返回数据时,触发回调
    ↓
Mono发出User对象

响应式技术栈对照

场景阻塞方式响应式方式
HTTP客户端RestTemplateWebClient
数据库JDBC (JdbcTemplate)R2DBC
RedisJedis (同步)Lettuce Reactive
MongoDBMongoTemplateReactiveMongoTemplate
KafkaKafkaTemplateReactiveKafkaTemplate

任何一个环节用阻塞API,整个链路的响应式优势都会丧失。

第七层:性能数据对比

测试场景

模拟网关场景:每个请求需要调用3个后端服务,每个服务耗时100ms。

环境

  • 机器:4核CPU、8GB内存
  • 并发请求:1000

Spring MVC + Tomcat

配置:
- Tomcat线程池:200
- 每个请求耗时:100ms + 100ms + 100ms = 300ms(串行)

结果:
- QPS:666(200线程 / 0.3秒)
- 平均响应时间:1500ms
- P99响应时间:3000ms
- CPU使用率:85%
- 内存占用:1.2GB(200个线程栈)

Spring WebFlux + Netty

配置:
- Server Worker线程:4
- Client EventLoop线程:4
- 每个请求耗时:max(100ms, 100ms, 100ms) = 100ms(并发)

结果:
- QPS:10000+
- 平均响应时间:120ms
- P99响应时间:200ms
- CPU使用率:60%
- 内存占用:512MB

性能差距

指标Spring MVCWebFlux提升
QPS6661000015倍
响应时间1500ms120ms12倍
内存1.2GB512MB减少60%
线程数2008减少96%

为什么差距这么大?

  1. 外部调用并发执行:WebFlux可以同时发起3个请求,MVC必须串行
  2. 线程不阻塞:WebFlux的8个线程永远在工作,MVC的200个线程大部分在等待
  3. 内存占用小:少量线程意味着更少的栈空间

第八层:底层技术原理

WebFlux的性能来自底层技术的层层支撑。

Linux的epoll

这是一切的基础:

// 创建epoll实例
int epoll_fd = epoll_create(1024);

// 注册多个socket
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, socket1, &event1);
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, socket2, &event2);
// ... 注册1000个socket

// 等待事件
while (1) {
    int n = epoll_wait(epoll_fd, events, 1024, -1);
    for (int i = 0; i < n; i++) {
        // 处理有数据的socket
        handle_event(events[i]);
    }
}

关键:一个线程可以监听1000个socket,哪个有数据就处理哪个。

传统阻塞I/O需要1000个线程,每个线程盯一个socket。

Java NIO的Selector

Java把epoll封装成了Selector:

Selector selector = Selector.open();

// 注册多个Channel
channel1.register(selector, SelectionKey.OP_READ);
channel2.register(selector, SelectionKey.OP_READ);
// ... 注册更多

while (true) {
    selector.select();  // 等待事件,底层调用epoll_wait
    
    Set<SelectionKey> keys = selector.selectedKeys();
    for (SelectionKey key : keys) {
        if (key.isReadable()) {
            // 有数据可读
            SocketChannel channel = (SocketChannel) key.channel();
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            channel.read(buffer);
        }
    }
}

Netty的EventLoop

Netty把Selector封装成了EventLoop:

// EventLoop = 一个线程 + 一个Selector + 一个任务队列
EventLoopGroup group = new NioEventLoopGroup(4);

// 4个EventLoop,每个都是:
while (true) {
    selector.select(timeout);  // 等待I/O事件
    processSelectedKeys();     // 处理I/O
    runAllTasks();            // 执行任务队列中的任务
}

EventLoop做三件事

  1. 等待I/O事件(通过Selector)
  2. 处理I/O事件(读写数据)
  3. 执行异步任务(业务逻辑)

Reactor的异步编排

Reactor把回调地狱变成了链式调用:

// 回调地狱
webClient.get("/user/1", user -> {
    webClient.get("/order/" + user.getOrderId(), order -> {
        webClient.get("/product/" + order.getProductId(), product -> {
            // 三层嵌套
            return result;
        });
    });
});

// Reactor链式调用
webClient.get("/user/1")
    .flatMap(user -> webClient.get("/order/" + user.getOrderId()))
    .flatMap(order -> webClient.get("/product/" + order.getProductId()));

代码更清晰,但本质都是异步回调。

第九层:适用场景分析

适合用WebFlux

网关系统

Gateway的核心工作:
- 接收请求(I/O)
- 路由(CPU极少)
- 调用后端(I/O)
- 聚合响应(CPU极少)
- 返回(I/O)

95%都是I/O等待,WebFlux完美匹配

微服务聚合层

一个请求调用5-10个微服务
    ↓
WebFlux可以并发调用
    ↓
响应时间 = max(服务耗时),不是sum(服务耗时)

实时通信

WebSocket长连接
    ↓
1万个连接 = 1万个用户在线
    ↓
WebFlux只需8个线程
    ↓
Tomcat需要1万个线程(根本不现实)

不适合用WebFlux

简单CRUD应用

读数据库 -> 返回
写数据库 -> 返回

并发不高(QPS < 1000)
响应式优势体现不出来
反而增加代码复杂度

CPU密集型任务

图像处理、算法计算、加密解密

这些任务的瓶颈是CPU,不是I/O
响应式无法提升性能

团队不熟悉

响应式编程学习曲线陡
调试困难
如果团队没经验,反而降低开发效率

总结

核心要点

  1. 架构:一个半Netty

    • Server端:完整的Boss-Worker
    • Client端:只有EventLoop,没有Boss
    • Boss只管接连接,Worker处理业务,Client调外部
  2. 工作原理:事件驱动

    • 操作系统的epoll:一个线程监听多个连接
    • Java NIO的Selector:封装epoll
    • Netty的EventLoop:事件循环 + 任务队列
    • Reactor的API:优雅的异步编排
  3. 性能关键:线程不阻塞

    • Worker派发任务后立即返回
    • Client并发调用外部服务
    • 少量线程处理大量并发
    • 资源利用率接近100%
  4. 适用场景:高并发I/O

    • 网关系统
    • 微服务聚合
    • 实时通信
    • 高并发API

一句话总结

WebFlux用一个半Netty(Server完整 + Client阉割)的架构,通过事件驱动和异步回调,让少量EventLoop线程处理大量并发I/O,从而实现在网关等I/O密集场景下的高性能。

学习建议

循序渐进

1. 理解Java NIO(Selector原理)
2. 学习Netty(EventLoop模型)
3. 掌握Reactor(Mono/Flux/操作符)
4. 实战WebFlux项目
5. 性能调优

避免误区

  • 不是所有项目都要用WebFlux
  • 不是加个Mono就是响应式
  • 必须全链路响应式才有效果
  • 调试难度确实比MVC高

合理选型

根据实际场景决定,不要为了技术而技术。简单的CRUD用Spring MVC就够了,真正的高并发场景才考虑WebFlux。


参考资料

  • Spring WebFlux官方文档
  • Project Reactor文档
  • Netty权威指南
  • Spring Cloud Gateway源码
  • Apache ShenYu架构设计