1. 底层模型反差:Tomcat 的“一请求一线程”与 Netty 的“少线程扛住高并发”
先别急着看代码,这两套东西最大的差异不在注解怎么写,而在它们背后完全不同的线程模型。
1.1 传统 MVC 为什么每个请求都要占一个线程
传统 Spring MVC 的服务端基于 Servlet 规范,部署在 Tomcat、Jetty 这类 Servlet 容器里。Tomcat 默认以“每个请求分配一条线程”的方式工作:客户端连接进来后,Tomcat 的连接器从线程池里取一条线程,在这条线程上执行 DispatcherServlet 的分发逻辑,调用我们写的 Controller 方法,直到方法执行完、视图或响应体写回客户端,这条线程才被释放。
这里的核心问题很直白:业务线程一旦被占用,就只能等。比如 Controller 里调用了一次远程接口,这个接口响应要 300ms,这期间这条 Tomcat 线程就是挂起状态,CPU 实际上没干活,但线程资源被占死了。高并发场景下一次请求可能带来 5 到 8 个下游依赖调用,线程池哪怕配置成 200 个线程,也只能同时扛住 20 到 40 个请求,吞吐量上不去,机器资源却消耗得飞快。
所以传统 MVC 本身不是不行,而是它的瓶颈在于“线程 = 并发数”的算力模型。如果你的业务大多数操作都是 CPU 计算、本地缓存、内存运算,这套模型其实很舒服,因为线程不会长时间闲等,100 个线程能稳定跑出 100 倍的 CPU 利用率。
1.2 WebFlux 的事件循环为什么能“以少胜多”
Spring Boot 的响应式 Web 模块(Reactive Web,即 WebFlux)底层默认跑在 Netty 上。Netty 是一种事件驱动型模型,线程数量通常只配置为 CPU 核数的两倍。比如一台 8 核机器,往往只有 16 条 Netty 事件循环线程,这在 Tomcat 看来简直是“可怜的配置”,但实际承载能力可能超过 Tomcat 400 线程的并发。
原理在于:Netty 的事件循环不会因为一次网络请求的等待而阻塞。响应式应用的所有操作都抽象为流(Publisher),Controller 返回一个Mono<T>或者Flux<T>,框架在事件循环上注册回调,数据真正到达后再触发后续处理。从发起请求到拿到响应,线程始终没有被“卡住”,一条线程同时挂在成百上千个 I/O 请求上,哪个先返回就先处理哪个,形成所谓“异步非阻塞”的运转方式。
打个不那么严谨但好记的比方:传统 MVC 就像人工服务柜台,每个用户都要占一个接待员,接待员等用户考虑问题时只能干等着。WebFlux 像一个接线总机,一名接线员可以同时记录和调度几十通电话的关键状态,哪条线路通了就去接通哪条。
1.3 背压是响应式模型的核心安全阀
说响应式必然会提到背压(Backpressure)。这是传统 MVC 完全没有的概念。传统 MVC 里,上游请求来了,框架被动地创建线程处理;数据库返回慢,你就只能等。所有环节都没有“我处理不过来时,请上游先缓一缓”的表达机制。
响应式流规范要求每个Flux链路上都有背压传导。消费者可以告诉生产者:“我一次只能接受 100 个元素,你发完这 100 个先等我消化。”这样即使在削峰填谷、突发流量场景下,系统不会因为一次性拉取过多数据而内存溢出。Netty 的响应式通道、R2DBC 的响应式数据库连接,都天然会传递这些需求信号。
背压意义上的“解决高并发”,不是让系统单次处理得更快,而是让系统能够用极少线程处理大量慢 I/O,同时面对坡度很陡的流量也不至于被打爆。理解了这一层,才能继续聊代码差异。
2. Controller 代码对照:从签名、返回类型到下游 I/O 调用的每一步变化
概念说完了,直接看代码。很多团队在改造时最容易犯的错是:把返回类型从Order改成Mono<Order>,就觉得“响应式改造完成”。根本不是这么回事。
2.1 最直观的 Controller 写法差异
先看传统 MVC 的一个普通接口:
@RestController @RequestMapping("/api/orders") public class OrderController { private final OrderService orderService; public OrderController(OrderService orderService) { this.orderService = orderService; } @GetMapping("/{id}") public Order getById(@PathVariable Long id) { Order order = orderService.findById(id); return orderService.decorate(order); } }在 MVC 模型下,orderService.findById()是一个阻塞方法。执行到findById时,当前业务线程走到数据库驱动,发出 SQL,然后挂起等待返回。这里的挂起对调用方是不可见的,但对容器来说,这条线程一直被占用着。Spring Boot 默认 Tomcat 线程池大小 200,意味着同时最多只有 200 个这样的调用在跑。
再看同一个接口的响应式写法:
@RestController @RequestMapping("/api/orders") public class OrderReactiveController { private final ReactiveOrderService orderService; public OrderReactiveController(ReactiveOrderService orderService) { this.orderService = orderService; } @GetMapping("/{id}") public Mono<Order> getById(@PathVariable Long id) { return orderService.findById(id) .flatMap(order -> orderService.decorate(order)); } }Mono表示 0 或 1 个元素的异步流。关键区别是:findById(id)这行代码执行后立刻返回,真正查询发生在订阅时,由响应式数据源决定何时把结果推送过来。整个链路中事件循环没有阻塞,处于等待的时间可以用来处理其他请求。
这里的flatMap是响应式世界里最常用的算子,它把两个异步操作串起来:前一个流的结果产出后,交给后一个方法返回新的流。这替代了传统代码里的“方法调用后拿到返回值再继续往下写”的顺序逻辑。
提示:如果
Controller里直接调用一个用Thread.sleep()模拟阻塞的同步方法,你会发现 WebFlux 不但没有变快,反而有可能引入新的延迟问题。纯响应式要求从数据源到最外层都是非阻塞链路,任何一环是阻塞的,整条链路的收益都会大打折扣。
2.2 下游依赖调用:RestTemplate 已不适合,WebClient 才是标配
一个常见误区是:项目里用了 WebFlux,但调用其他微服务时还在用RestTemplate。
RestTemplate是阻塞式 HTTP 客户端。它会把当前线程挂在 HTTP 连接上等待响应,这导致 WebFlux 好不容易省下来的线程资源又被下游调用白白占回去。
Spring WebFlux 体系下的标配是WebClient:
@Service public class StockClient { private final WebClient webClient; public StockClient(WebClient.Builder builder) { this.webClient = builder.baseUrl("http://inventory-service").build(); } public Mono<Stock> fetchStock(String sku) { return webClient.get() .uri("/stock/{sku}", sku) .retrieve() .bodyToMono(Stock.class) .timeout(Duration.ofSeconds(3)); } }WebClient底层基于 Reactor Netty,天然支持异步非阻塞。调用fetchStock时,请求的创建和传输都被注册为事件流,不占用专有的业务线程。这个变化意味着原来 Tomcat 线程池里大部分“傻等网络响应”的情况被彻底消除,你不再需要通过加大线程池上限来提高并发。
另外注意.timeout(Duration.ofSeconds(3))——在响应式场景,超时控制不是在底层 Socket 层面,而是流层面的。超时触发后会发射TimeoutException,链路上可以继续通过.onErrorResume()处理降级逻辑。
2.3 流式响应和 SSE:响应式真正“秀肌肉”的场景
传统 MVC 虽然也支持SseEmitter,但从代码简洁度和背压支持上都不如 WebFlux 原生。WebFlux 可以很方便地把数据库查询结果、消息队列数据、外部事件源做实时推送:
@RestController @RequestMapping("/api/order-events") public class OrderEventController { @GetMapping(path = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<OrderEvent> streamOrderEvents() { return orderEventService.subscribeToEvents() .map(event -> { event.setReceivedAt(LocalDateTime.now()); return event; }); } }这个接口返回的是Flux<OrderEvent>,Spring 会以text/event-stream格式持续向客户端输出数据。每条新订单事件只要产生,就会被序列化并通过 Netty 通道推送出去。对前端、大屏、实时报表这类场景,这是 MVC 里很难写得这么干净的代码。
3. 从零搭一个响应式 REST 项目:完整复现与执行结果验证
纸上谈兵没有意义,下面把项目从创建到接口验证完整走一遍,这些都是可复现的步骤。
3.1 初始化项目:依赖选型与最容易踩的配置雷区
使用 Spring Initializr(start.spring.io)时,依赖选择Spring Reactive Web,也就是spring-boot-starter-webflux,同时勾选需要的其他模块。
如果使用 Maven,核心依赖如下:
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>io.r2dbc</groupId> <artifactId>r2dbc-h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-r2dbc</artifactId> </dependency> </dependencies>这里有两个高频雷区。
第一,如果你的项目同时引入了spring-boot-starter-web(传统 MVC 依赖),Spring Boot 默认会以 Servlet 应用方式启动,WebFlux 的@RestController会被它伪装成 MVC 的注解来解析,结果就是 Reactive 的返回类型(比如Flux)在某些版本里会报异常。解决办法是二选一,不要混在一个应用里强行启动。真出现了这个问题,控制台先看是否打印了 Tomcat started 而不是 Netty started。
第二,Spring Boot 3 中使用 WebFlux 时,如果沿用 WebMVC 习惯去实现WebMvcConfigurer接口,配置会直接失效。比如跨域、拦截器,都需要改用WebFluxConfigurer。代码迁移时这一点很容易被忽略,日志里还不容易发现异常,属于“看起来没报错,但功能就是不生效”的典型问题。
application.yml可以做最小配置:
server: port: 8080 netty: connection-timeout: 2000WebFlux 默认容器就是 Netty,端口配置风格与 Tomcat 大同小异。
3.2 数据访问层选择:从 JPA 切换到 R2DBC 的思维转变
做响应式项目,最核心的决策之一是数据库访问层。传统 JPA / MyBatis 都是阻塞式数据库驱动,哪怕你的 Controller 写得再响应式,数据库操作仍然会把线程阻塞住。
响应式世界对应的数据库驱动是 R2DBC。它基于Publisher协议实现了非阻塞数据库访问。
实体类定义几乎和 JPA 风格相似:
@Table("orders") public class Order { @Id private Long id; private String customerId; private String sku; private Integer quantity; private String status; // 无参构造、getter、setter }Repository 接口直接继承ReactiveCrudRepository:
public interface OrderReactiveRepository extends ReactiveCrudRepository<Order, Long> { Flux<Order> findByCustomerId(String customerId); Flux<Order> findByStatusAndQuantityGreaterThan(String status, Integer quantity); }这里findByCustomerId返回的是Flux<Order>,意味着查询结果不是一次性全量返回,而是以流的方式一条一条推送。R2DBC 驱动会结合数据库游标和响应式背压,控制每次拉取的数据量。如果你的数据库是 Postgres 或 MySQL,需要对应引入r2dbc-postgresql或r2dbc-mysql(社区驱动)。
注意:R2DBC 的查询方法命名规范和 JPA 很像,但它不管理实体间的
@OneToMany、@ManyToOne这类关系映射。也就是说你没法像 JPA 那样一条语句把主子表全部带出来,更多时候需要自己写两个查询,再用flatMap、zip去组装聚合结果。这是很多团队在响应式迁移时感觉“很别扭”的核心原因。
3.3 Controller + Service + Repository 的完整链路代码
这里给出一个稍微完整些的示例,包含 Service 层的组装和错误处理。
@Service public class ReactiveOrderService { private final OrderReactiveRepository orderRepository; private final StockClient stockClient; public ReactiveOrderService(OrderReactiveRepository orderRepository, StockClient stockClient) { this.orderRepository = orderRepository; this.stockClient = stockClient; } public Mono<OrderDetail> getOrderDetail(Long orderId) { return orderRepository.findById(orderId) .switchIfEmpty(Mono.error(new OrderNotFoundException(orderId))) .flatMap(order -> stockClient.fetchStock(order.getSku()) .map(stock -> buildDetail(order, stock)) .defaultIfEmpty(buildDetail(order, null))); } public Flux<Order> listOrdersByCustomer(String customerId) { return orderRepository.findByCustomerId(customerId) .onBackpressureBuffer(128) .limitRate(50); } private OrderDetail buildDetail(Order order, Stock stock) { // 组装返回 DTO return new OrderDetail(order, stock); } }注意这行switchIfEmpty(Mono.error(...))。它在找不到记录时切换成一个错误流。这是响应式开发里必须养成的思维方式:数据不存在时,不是返回 null 让它继续走后面的代码,而是显式地让链路进入错误分支,由上游onErrorReturn、onErrorResume统一处理。
Controller 层继续:
@RestController @RequestMapping("/api/reactive/orders") public class ReactiveOrderController { private final ReactiveOrderService orderService; public ReactiveOrderController(ReactiveOrderService orderService) { this.orderService = orderService; } @GetMapping("/{id}") public Mono<OrderDetail> getOrderDetail(@PathVariable Long id) { return orderService.getOrderDetail(id) .onErrorResume(OrderNotFoundException.class, ex -> Mono.just(new OrderDetail(null, null))); } @GetMapping public Flux<Order> listByCustomer(@RequestParam String customerId) { return orderService.listOrdersByCustomer(customerId); } }onErrorReturn、onErrorResume这两兄弟记得分清楚。前者是无条件返回一个固定值,后者可以根据异常类型执行一段逻辑。实际项目里建议多用后者,这样可以保留完整的降级路径。
3.4 启动验证与背压观察
写好之后直接启动,控制台应该出现下述信息:
Netty started on port 8080这就是 WebFlux 已经在跑的最佳标识。如果控制台显示 Tomcat started,检查依赖是否混入了spring-boot-starter-web。
用 curl 验证:
curl http://localhost:8080/api/reactive/orders/1 curl -N http://localhost:8080/api/reactive/orders?customerId=C001单条接口返回的是 JSON,列表接口如果数据量较大,能明显观察到响应是分批到达而非一次性刷新。这种“边查询边返回”的行为,就是响应式流和背压在实际传输层的体现。
4. 依照业务特点做选型:什么时候该用 WebFlux,什么时候继续 MVC
这是架构评审里最容易被一带而过、却最需要认真思考的部分。很多团队看到 WebFlux 能扛高并发就盲目改造,结果不仅没变快,反而增加了排查难度。
4.1 适合 WebFlux 的典型场景
第一类是网关和聚合层。网关的主要工作是转发、鉴权、限流,逻辑不重但并发极高,非常适合非阻塞模型。比如统一 API Gateway 转发上百个下游服务的请求,用 WebFlux 能显著降低网关所在节点的线程占用率,Netty 的少线程模型在这里可以发挥最大价值。
第二类是少量 I/O 密集但计算量轻的业务。比如查询订单后需要串行调用库存服务、会员服务、营销服务。业务逻辑很简单,但 80% 时间都花在网络请求上。WebFlux 的异步编排能力让这次次调用不阻塞线程,你只用一条业务链路就把多个下游服务的返回结果聚合在一起。
第三类是信息流、行情推送、日志流等实时数据场景。Flux天然适合作为数据管道,可以通过 Kafka、MQ 的响应式客户端接入消息流,实时转换后推送至前端或存储。之前看到一个做交易行情推送的团队,切换 WebFlux 后,同样一台 4 核 8G 的机器,从支撑 500 并发提升到 3000 以上(测试环境压测数据),原因就在于推送过程几乎没有线程阻塞。
4.2 不适合 WebFlux 的场景
简单 CRUD 后台管理,是最不适合使用 WebFlux 的场景。比如典型的“基于 Spring Boot 的办公用品管理系统”“商城后台订单管理”这类项目。用户的每一次操作背后就是一次数据库查询,数据量不大、查询也不慢,使用 MVC 不但代码更直观,Debug 也更容易,团队成员不用学函数式编程就能快速维护,选 WebFlux 属于自我折磨。
使用传统单体关系数据库、且业务查询以 JPA 实体关联为核心的项目,也不要迁移。R2DBC 对复杂关联查询的支持还比较薄弱,强行走响应式路线会让数据访问层代码变得别扭,性能提升却有限。WebFlux 的优势在于“慢 I/O 大并发”,而单体后台的瓶颈往往在数据库本身,不在线程模型。
另外,团队对响应式编程不熟悉时,不建议直接上。Reactor 中subscribe()的位置、线程调度的切换、背压符号的传导规则,都需要一定时间适应。如果是接商业软件项目、工期明确有限,把学习成本算进去之后,响应式改造的性价比通常都很低。
4.3 一张选型决策表与判断流程
我把这些经验整理成了一张决策表,评审时可以直接套用。
| 判断维度 | 倾向选 WebFlux | 倾向选 MVC |
|---|---|---|
| 业务功能 | 网关、聚合、推送、实时数据流 | CRUD、后台管理、报表 |
| 主要耗时 | 下游 API 等待、网络 I/O | 本地计算、数据库查询 |
| 并发量级 | 单机预期数千以上并发连接 | 单机几百并发以内即可 |
| 数据访问 | 可用 R2DBC / 响应式 NoSQL | 需使用 JPA / MyBatis 做复杂关联 |
| 团队基础 | 熟悉 Reactor、函数式编程 | 团队以传统 Java 习惯为主 |
| 运维资源投入 | 愿意接受异步排查成本 | 希望问题易定位、日志易追踪 |
判断流程可以归纳为三步:先问业务是不是“天然异步事件驱动”,再问技术栈是否允许全链路非阻塞,最后问团队是否有能力在两周内交付可维护的响应式代码。三个问题里有两个是否定答案,就坚持使用 MVC。
5. 迁移与混真实战:R2DBC、事务、限流与常见坑的排查链路
如果已经确定要在某个服务里引入响应式,或者需要把传统 MVC 项目里的部分模块改造成 WebFlux,下面这些是实操时最容易踩到且最耗时间的点。
5.1 同一个应用里 MVC 与 WebFlux 能否共存?
严格说,Spring Boot 的 web 环境有两种:Servlet 和 Reactive,应用启动时只能选定一种为WebApplicationType。如果你同时引入spring-boot-starter-web和spring-boot-starter-webflux,默认以 Servlet 为准启动,WebFlux 的注解和功能形同虚设。
但这不是说一个系统里完全不能两套并存。现实项目里最常见的做法是拆服务:路由器层用 WebFlux 做网关转发,往下接的是普通 MVC 微服务。网关只负责请求分发,不包含 JPA、MyBatis 这类阻塞数据源,两侧各干各的最舒服。
还有一种变通做法是设置spring.main.web-application-type=reactive,强制以 Reactive 模式启动应用。这时@Controller、@RestController会被 WebFlux 的适配层识别,但你没法再依赖 Servlet API,HttpServletRequest这类对象完全不能注入。想要读请求头、参数要改用ServerHttpRequest。很多团队试图“兼容”旧代码时,就卡在这里动弹不得。
5.2 第一个坑:JPA 阻塞式查询“污染”了整条响应式链路
这是迁移时最有迷惑性的坑。业务代码看起来没问题:
@GetMapping("/{id}") public Mono<Order> getOrder(@PathVariable Long id) { return Mono.just(orderMapper.findById(id)); // 错误示范 }findById是 MyBatis 阻塞查询,它被包进Mono.just后,虽然返回类型是Mono<Order>,但这个Mono的创建过程里已经发生了完整数据库查询,线程被阻塞到查询完成。你以为做了响应式改造,实际上线程模型的坑分毫未动,甚至多了不必要的 Mono 包装。
正确做法是使用Mono.defer延迟执行,并扔到弹性线程池:
@GetMapping("/{id}") public Mono<Order> getOrder(@PathVariable Long id) { return Mono.defer(() -> Mono.just(orderMapper.findById(id))) .subscribeOn(Schedulers.boundedElastic()); }defer让整个查询动作延迟到订阅发生时才执行,subscribeOn指定这段阻塞逻辑跑到boundedElastic线程池,不让它占用 Netty 的事件循环线程。这种做法其实是在“有阻塞组件的边界”上做隔离,能让项目渐进式改造,不会一上来就把数据访问层全换掉。但它只是过渡方案,要真正享受响应式红利,最终还得把数据访问换成 R2DBC 或响应式 NoSQL 驱动。
5.3 超时、限流、出错重试的标准写法
响应式链路的错误处理和资源保护手段,比传统 try/catch 要灵活得多,但也容易把代码写散。我习惯维护一套标准模板:
public Mono<OrderDetail> getOrderDetailWithGuard(Long orderId) { return orderService.getOrderDetail(orderId) .timeout(Duration.ofMillis(1500)) .retryWhen(Retry.backoff(3, Duration.ofMillis(300)) .filter(exception -> exception instanceof TimeoutException)) .onErrorResume(ex -> Mono.just(buildFallbackOrderDetail(ex))); }.timeout()控制单次请求总时长,避免下游慢接口拖死整个链路。.retryWhen搭配Retry.backoff实现指数退避重试,filter只针对超时异常重试,对其他异常直接放行。
限流方面,Reactor 自身的limitRate是一个简单好用的背压工具:
orderRepository.findByStatus("NEW") .limitRate(100) // 每次最多向下游发 100 个元素 .map(processor::doLightWork) .subscribe();limitRate配合下游消费速度和系统资源决定这个常数,并不是越大越好。实测中,100 到 200 是比较常见的取值区间,超过 500 时批处理优势不明显,反而可能造成内存积压。
5.4 事务与缓存:响应式世界的特殊处理
MySQL 事务在 R2DBC 里的写法与传统@Transactional差异很大。普通 JPA 里标一个@Transactional就完事,但 R2DBC 要求你使用TransactionalOperator并显式控制事务边界,声明式事务的那套依赖线程绑定机制在响应式线程切换场景下并不适用。
@Service public class PaymentService { private final TransactionalOperator txOperator; public PaymentService(TransactionalOperator txOperator) { this.txOperator = txOperator; } public Mono<PaymentResult> createPayment(Payment payment) { return Mono.from(txOperator.transactional( paymentRepository.save(payment) .flatMap(savedPayment -> ledgerRepository.record(savedPayment)) )); } }缓存同样有讲究。本地缓存通常用 Caffeine,官方也提供了针对 WebFlux 的版本,比如CaffeineAsyncCache。但很多团队依然习惯在业务代码里cache.put(key, value)同步调用,这在响应式链路里会造成不必要的线程切换。更符合响应式思维的是把缓存访问也封装成Mono或CompletableFuture的结合体,避免阻塞链路上的事件循环。
5.5 从一次真实压测中看到的运维差异
最后说一个我实际压测 WebFlux 服务时观察到的现象。传统 MVC 服务在并发升高时,日志里最先出现的是线程池拒绝异常(RejectedExecutionException),排障思路很直接:加线程数、接队列。WebFlux 服务在过载时表现更“安静”,因为事件循环线程被占满后,Netty 会通过 TCP 背压把压力传导给客户端——表现为客户端侧响应超时增多、连接建立变慢,服务端日志反而不打印多少异常。这种反直觉现象让习惯了传统排障的运维同学很容易误判,第一次遇到时务必先看 Netty 除了事件循环线程之外是否有排队积压请求的迹象。
从迁移落地的角度,我的做法是:网关这种重 I/O 轻业务的服务先上 WebFlux,因为它就是为“高并发网络转发”设计的;核心交易链路的业务模块继续用 MVC,保持事务、缓存、日志链路完整可控。等团队把 Reactor 的思维方式真正内化成习惯,再逐步把一些慢异步场景拆出来用响应式实现。这样既拿到了性能提升,又不会让项目进入一种“全响应式”的教条状态。