引子:为什么Spring Cloud Gateway选择WebFlux?
Spring Cloud Gateway是Spring官方的新一代网关,它彻底抛弃了之前基于Servlet的Zuul 1.x,转而采用WebFlux。
这不是赶时髦,而是网关场景的必然选择。
网关的特殊性
网关的核心工作是什么?转发请求。
css
客户端请求
↓
网关接收
↓
路由到后端服务A、B、C(可能需要调用多个)
↓
聚合结果
↓
返回给客户端
这个过程中,网关自己几乎不做计算,95%的时间都在:
- 等待后端服务响应
- 处理网络I/O
传统Servlet的困境
如果用Servlet容器(Tomcat):
markdown
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到底由哪些部分组成:
每一层都有明确的职责:
| 层次 | 组件 | 职责 | 举例 |
|---|---|---|---|
| 应用层 | Spring WebFlux | 路由、注解、依赖注入 | @GetMapping |
| 编程模型层 | Project Reactor | 响应式API | Mono、Flux、flatMap |
| 网络层 | Netty | 事件驱动I/O | EventLoop、Channel |
| 系统抽象层 | Java NIO | 非阻塞I/O | Selector、ByteBuffer |
| 操作系统层 | epoll/kqueue | I/O多路复用 | 系统调用 |
这些层次环环相扣,缺一不可。
第二层:Reactor到底是什么
很多人第一次接触WebFlux,会被两个"Reactor"搞晕:
- Netty的Reactor模式
- Project Reactor库
它们是不同的东西。
Netty的Reactor模式
这是一种设计模式,用于处理并发I/O:
java
// 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:
java
// 纯内存操作,不需要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编程。两者配合工作:
arduino
Reactor定义"做什么"(业务逻辑)
Netty负责"怎么做"(网络I/O)
第三层:一个半Netty的架构
这是理解WebFlux的核心。
WebFlux使用了两套Netty线程组,但第二套是"阉割版"。
Server端:完整的Netty
java
// 标准的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读写
Boss就像公司老板,只负责接项目(接收连接),然后分配给项目经理(Worker)去执行。
Client端:半个Netty
当WebFlux需要调用外部服务时,用的是WebClient:
java
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:
java
// Netty Client的配置
HttpClient httpClient = HttpClient.create()
.runOn(new NioEventLoopGroup(4)); // 只有EventLoop
注意:这里只有EventLoop,没有Boss线程组。
为什么Client不需要Boss?
这是关键问题。
Boss的职责是什么?
java
// Boss线程做的事
while (true) {
SocketChannel clientSocket = serverSocket.accept(); // 接收新连接
workerGroup.register(clientSocket); // 分配给Worker
}
Boss负责监听端口,接收客户端主动发起的连接请求。
Client的工作方式完全不同:
java
// Client主动连接服务端
Socket socket = new Socket("api.example.com", 80);
socket.connect(); // 主动发起连接
Client是主动连接别人,不需要监听端口,自然不需要Boss。
类比说明
Server端(完整公司架构):
css
老板(Boss)
↓
专门负责签约新客户(接收TCP连接)
↓
把项目分配给项目经理(Worker)
↓
项目经理负责执行(处理I/O)
Client端(外包开发团队):
markdown
没有老板
↓
开发人员直接接到任务(主动发起连接)
↓
自己去对接客户(发送HTTP请求)
↓
完成后直接汇报(接收HTTP响应)
外包团队不需要老板来接活,因为活是别人派给他们的。
这就是为什么说"半个Netty":Client端的Netty只有EventLoop,缺少Boss组件。
完整架构图
1个线程
接收TCP连接] SW[Worker线程组
4个线程
处理HTTP请求] SB --> SW end subgraph "Client端 - 半个Netty" CE[EventLoop线程组
4个线程
发起HTTP请求] end SW -.派发外部调用.-> CE CE -.返回响应数据.-> SW style SB fill:#90EE90 style SW fill:#87CEEB style CE fill:#FFB6C1
- 绿色:Server Boss(接收连接)
- 蓝色:Server Worker(处理业务)
- 粉色:Client EventLoop(调用外部)
Server和Client是两套独立的线程组,通过Reactor的回调机制协作。
第四层:一个请求的完整生命周期
假设有个Controller需要查询用户和订单:
java
@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));
}
}
完整时序图
(1个线程) participant SW as Server Worker
(4个线程) participant Code as 业务代码
(Reactor) participant CE as Client EventLoop
(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留下钩子
立即返回,不等待 Note over CE: Client线程5发起HTTP CE->>US: GET /user/123 Note over SW: Worker线程2继续
处理其他请求 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响应
不经过Boss
详细步骤拆解
第1步:接收连接(Boss的工作)
arduino
客户端发起TCP连接
↓
Server Boss线程(线程1)执行accept()
↓
创建SocketChannel
↓
注册到Server Worker线程组
↓
Boss线程回到循环,继续accept其他连接
Boss只负责接收连接,立即就交出去了。
第2步:处理HTTP请求(Worker的工作)
scss
假设分配给Worker线程2
↓
线程2从SocketChannel读取HTTP请求
↓
解析HTTP头、路径、参数
↓
WebFlux路由:/order/123 -> OrderController.getOrder(123)
↓
执行Controller方法
这一步都在线程2上同步执行。
第3步:第一次外部调用(关键转折点)
java
// 执行到这一行
return webClient.get()
.uri("http://user-service/user/123")
.retrieve()
.bodyToMono(User.class)
这里发生了什么?
arduino
Worker线程2执行webClient.get()
↓
创建HTTP请求对象
↓
派发给Client EventLoop线程组
↓
假设分配给Client线程5
↓
Worker线程2注册回调(钩子)
↓
立即返回Mono<User>对象(此时还没有数据)
↓
Worker线程2的工作完成,可以处理其他请求了
关键:Worker线程2不等待!
它留下一个"钩子"(回调函数),然后立即释放,去处理下一个HTTP请求了。
第4步:Client线程发起实际调用
arduino
Client EventLoop线程5拿到任务
↓
使用Netty的Channel发起HTTP请求
↓
通过NIO的Selector注册OP_CONNECT事件
↓
发送HTTP请求数据到用户服务
↓
注册OP_READ事件,等待响应
↓
线程5不阻塞,继续处理其他任务
Client线程5也不会傻等,它发出请求后,通过Selector注册了"读事件",然后去干别的了。
第5步:接收用户服务响应
arduino
用户服务返回数据
↓
Selector检测到OP_READ事件
↓
Client线程5被唤醒
↓
从SocketChannel读取响应数据
↓
解析HTTP响应体,得到User对象
↓
触发Reactor的回调链
这时,之前注册的"钩子"被触发了。
第6步:执行flatMap(还在Client线程5上)
java
.flatMap(user -> webClient.get()
.uri("http://order-service/order/" + user.getOrderId())
.retrieve()
.bodyToMono(Order.class))
arduino
Client线程5拿到User对象
↓
执行flatMap中的lambda
↓
再次调用webClient.get()
↓
这次可能分配给Client线程6
↓
发起第二个HTTP请求到订单服务
↓
注册新的回调
第7步:订单服务响应
arduino
订单服务返回Order数据
↓
Client线程6接收响应
↓
触发map回调
↓
构造OrderDTO对象
↓
调用之前Worker线程2留下的钩子
第8步:返回给客户端(Worker的收尾工作)
javascript
Worker线程2(或者其他空闲的Worker)被唤醒
↓
拿到最终的OrderDTO对象
↓
序列化成JSON
↓
通过原来的SocketChannel发送HTTP响应
↓
注意:直接发送,不经过Boss
Boss只管接收新连接,响应由Worker直接发送。
时间线对比
传统Servlet模式:
makefile
T0: 请求到达,分配线程A
T100ms: 线程A调用用户服务,阻塞等待
T200ms: 收到用户服务响应
T200ms: 线程A调用订单服务,阻塞等待
T300ms: 收到订单服务响应
T300ms: 线程A返回结果
总耗时:300ms
线程A利用率:33%(100ms实际工作,200ms等待)
WebFlux模式:
makefile
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步:老板接项目
markdown
客户上门
↓
老板接待(Boss线程accept连接)
↓
签订合同
↓
分配给项目经理张三(Worker线程2)
↓
老板继续接待其他客户
老板只负责拉业务,不管具体执行。
第2步:项目经理启动项目
arduino
张三接手项目
↓
查看需求文档(解析HTTP请求)
↓
发现需要用户数据,这部分要外包
↓
联系外包团队李四(Client线程5)
↓
在项目管理系统设置里程碑:用户数据完成后通知我
↓
张三继续去管理其他项目,不干等
关键:张三不等外包完成,他去忙别的了。
第3步:外包团队干活
markdown
李四接到任务
↓
去用户服务API拉数据
↓
不阻塞等待,同时可以接其他任务
↓
用户服务返回数据
↓
李四拿到数据,触发里程碑
↓
通知张三:用户数据好了
第4步:项目经理继续推进
arduino
张三收到通知
↓
拿到用户数据,查看订单ID
↓
又需要订单数据,再次外包
↓
联系外包团队王五(Client线程6)
↓
设置新的里程碑:订单数据完成后通知我
↓
张三又去干别的了
第5步:再次外包
markdown
王五接到任务
↓
去订单服务API拉数据
↓
订单服务返回数据
↓
触发里程碑,通知张三
第6步:项目收尾
markdown
张三收到订单数据
↓
汇总用户数据和订单数据
↓
生成最终报告
↓
直接交付给客户(发送HTTP响应)
↓
不需要再找老板审批
老板只管接项目,交付由项目经理完成。
关键点总结
-
老板(Boss)只接活,不干活
- Boss线程只负责accept连接
- 立即分配给Worker,自己继续接新连接
-
项目经理(Worker)不傻等
- 遇到需要外部资源的地方,立即外包
- 留下"钩子"(回调),去管理其他项目
- 一个项目经理可以同时管理几百个项目
-
外包团队(Client EventLoop)并发干活
- 同时可以处理多个外包任务
- 不阻塞,用事件驱动
- 干完了触发钩子通知项目经理
-
交付不经过老板
- 项目完成后,项目经理直接交付
- Boss不参与项目执行和交付
为什么这么高效?
传统Servlet模式(每个项目配一个专职经理):
markdown
100个项目同时进行
↓
需要100个项目经理
↓
每个经理只盯自己的项目
↓
大部分时间在等外包完成(阻塞)
↓
人力浪费严重
WebFlux模式(少数经理管理大量项目):
markdown
100个项目同时进行
↓
只需要4个项目经理
↓
每个经理同时管理25个项目
↓
利用等待时间处理其他项目
↓
人力利用率接近100%
第六层:为什么必须全链路响应式
有些开发会这么写:
java
@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,实际上还是阻塞的。
会发生什么
scss
Worker线程2执行这个方法
↓
执行jdbcTemplate.queryForObject()
↓
这是JDBC,会阻塞等待数据库返回(可能50ms)
↓
Worker线程2被阻塞,啥也干不了
↓
50ms后数据库返回
↓
包装成Mono.just(user)返回
线程2被阻塞了50ms!
假设只有4个Worker线程,如果同时来4个这样的请求:
ini
4个Worker线程全部阻塞
↓
第5个请求进来,没有空闲线程
↓
请求排队等待
↓
QPS = 4个线程 / 0.05秒 = 80
还不如Tomcat的200个线程!
正确的做法
java
@GetMapping("/user")
public Mono<User> getUser() {
// 使用R2DBC,真正的响应式数据库驱动
return r2dbcTemplate
.select(User.class)
.matching(query(where("id").is(1)))
.one();
}
这样Worker线程不会阻塞:
sql
Worker线程2执行这个方法
↓
调用r2dbcTemplate.select()
↓
通过R2DBC发起异步查询(类似WebClient)
↓
立即返回Mono<User>(还没有数据)
↓
Worker线程2去处理其他请求
↓
数据库返回数据时,触发回调
↓
Mono发出User对象
响应式技术栈对照
| 场景 | 阻塞方式 | 响应式方式 |
|---|---|---|
| HTTP客户端 | RestTemplate | WebClient |
| 数据库 | JDBC (JdbcTemplate) | R2DBC |
| Redis | Jedis (同步) | Lettuce Reactive |
| MongoDB | MongoTemplate | ReactiveMongoTemplate |
| Kafka | KafkaTemplate | ReactiveKafkaTemplate |
任何一个环节用阻塞API,整个链路的响应式优势都会丧失。
第七层:性能数据对比
测试场景
模拟网关场景:每个请求需要调用3个后端服务,每个服务耗时100ms。
环境:
- 机器:4核CPU、8GB内存
- 并发请求:1000
Spring MVC + Tomcat
diff
配置:
- Tomcat线程池:200
- 每个请求耗时:100ms + 100ms + 100ms = 300ms(串行)
结果:
- QPS:666(200线程 / 0.3秒)
- 平均响应时间:1500ms
- P99响应时间:3000ms
- CPU使用率:85%
- 内存占用:1.2GB(200个线程栈)
Spring WebFlux + Netty
diff
配置:
- Server Worker线程:4
- Client EventLoop线程:4
- 每个请求耗时:max(100ms, 100ms, 100ms) = 100ms(并发)
结果:
- QPS:10000+
- 平均响应时间:120ms
- P99响应时间:200ms
- CPU使用率:60%
- 内存占用:512MB
性能差距
| 指标 | Spring MVC | WebFlux | 提升 |
|---|---|---|---|
| QPS | 666 | 10000 | 15倍 |
| 响应时间 | 1500ms | 120ms | 12倍 |
| 内存 | 1.2GB | 512MB | 减少60% |
| 线程数 | 200 | 8 | 减少96% |
为什么差距这么大?
- 外部调用并发执行:WebFlux可以同时发起3个请求,MVC必须串行
- 线程不阻塞:WebFlux的8个线程永远在工作,MVC的200个线程大部分在等待
- 内存占用小:少量线程意味着更少的栈空间
第八层:底层技术原理
WebFlux的性能来自底层技术的层层支撑。
Linux的epoll
这是一切的基础:
c
// 创建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:
java
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:
java
// EventLoop = 一个线程 + 一个Selector + 一个任务队列
EventLoopGroup group = new NioEventLoopGroup(4);
// 4个EventLoop,每个都是:
while (true) {
selector.select(timeout); // 等待I/O事件
processSelectedKeys(); // 处理I/O
runAllTasks(); // 执行任务队列中的任务
}
EventLoop做三件事:
- 等待I/O事件(通过Selector)
- 处理I/O事件(读写数据)
- 执行异步任务(业务逻辑)
Reactor的异步编排
Reactor把回调地狱变成了链式调用:
java
// 回调地狱
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
网关系统:
diff
Gateway的核心工作:
- 接收请求(I/O)
- 路由(CPU极少)
- 调用后端(I/O)
- 聚合响应(CPU极少)
- 返回(I/O)
95%都是I/O等待,WebFlux完美匹配
微服务聚合层:
scss
一个请求调用5-10个微服务
↓
WebFlux可以并发调用
↓
响应时间 = max(服务耗时),不是sum(服务耗时)
实时通信:
markdown
WebSocket长连接
↓
1万个连接 = 1万个用户在线
↓
WebFlux只需8个线程
↓
Tomcat需要1万个线程(根本不现实)
不适合用WebFlux
简单CRUD应用:
rust
读数据库 -> 返回
写数据库 -> 返回
并发不高(QPS < 1000)
响应式优势体现不出来
反而增加代码复杂度
CPU密集型任务:
css
图像处理、算法计算、加密解密
这些任务的瓶颈是CPU,不是I/O
响应式无法提升性能
团队不熟悉:
响应式编程学习曲线陡
调试困难
如果团队没经验,反而降低开发效率
总结
核心要点
-
架构:一个半Netty
- Server端:完整的Boss-Worker
- Client端:只有EventLoop,没有Boss
- Boss只管接连接,Worker处理业务,Client调外部
-
工作原理:事件驱动
- 操作系统的epoll:一个线程监听多个连接
- Java NIO的Selector:封装epoll
- Netty的EventLoop:事件循环 + 任务队列
- Reactor的API:优雅的异步编排
-
性能关键:线程不阻塞
- Worker派发任务后立即返回
- Client并发调用外部服务
- 少量线程处理大量并发
- 资源利用率接近100%
-
适用场景:高并发I/O
- 网关系统
- 微服务聚合
- 实时通信
- 高并发API
一句话总结
WebFlux用一个半Netty(Server完整 + Client阉割)的架构,通过事件驱动和异步回调,让少量EventLoop线程处理大量并发I/O,从而实现在网关等I/O密集场景下的高性能。
学习建议
循序渐进:
markdown
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架构设计