Java 基础体系 · 第 26/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Spring Web MVC 与 WebFlux:请求链、校验、异常、流式和选择边界
Spring Web MVC 与 Spring WebFlux 都能处理 HTTP 请求,也都支持注解式控制器、参数校验、统一异常响应和 Spring Security。但两者解决问题的运行时模型不同:
- Web MVC 建立在 Servlet API 和“一次请求对应一个响应处理流程”之上,阻塞式代码是自然形态。
- WebFlux 建立在 Reactive Streams 和异步非阻塞 I/O 之上,处理结果通常是
Mono<T>或Flux<T>,请求线程不应等待阻塞操作完成。
这里的“请求链”不只是控制器调用顺序,还包括容器、过滤器、框架分发器、参数解析、校验、异常解析和响应写出。只有把这些阶段连起来,才能理解为什么同一个异常在 MVC 和 WebFlux 中表现不同,为什么把 MVC 代码机械地改成 Mono 并不会自动获得非阻塞能力,以及为什么流式接口的性能边界不能只看返回类型。
一、先建立两种模型:Servlet 调度与 Reactive Streams
1. Web MVC 的基本模型
Web MVC 中,Servlet 容器接收请求,并调用一个 DispatcherServlet。它负责:
- 查找能够处理请求的控制器;
- 解析路径变量、查询参数、请求体和请求头;
- 调用控制器方法;
- 将返回值转换成视图或 HTTP 响应体;
- 在异常发生时交给异常解析器;
- 把响应提交给 Servlet 容器。
典型的同步控制器可以直接返回对象:
@RestController
@RequestMapping("/users")
class UserController {
@GetMapping("/{id}")
UserView get(@PathVariable long id) {
return new UserView(id, "Alice");
}
}
record UserView(long id, String name) {
}
控制器返回 UserView 后,RequestResponseBodyMethodProcessor 等基础设施会选择合适的 HttpMessageConverter,例如 Jackson 的 JSON 转换器,将对象写成:
HTTP/1.1 200 OK
Content-Type: application/json
{"id":1,"name":"Alice"}
这里的关键点是:方法返回通常意味着整个响应结果已经可以被解析和写出。如果控制器调用数据库、远程 HTTP 客户端或文件系统,当前执行它的线程通常会同步等待这些操作完成。
MVC 也支持异步返回值,例如 Callable<T、DeferredResult<T>、CompletableFuture<T>、ResponseBodyEmitter 和 SseEmitter。这改变的是 Servlet 请求线程的占用方式,不会把阻塞数据库驱动或阻塞 HTTP 客户端变成非阻塞驱动。
2. WebFlux 的基本模型
WebFlux 的控制器通常返回:
Mono<T>:最多产生一个元素;Flux<T>:可以产生零个、一个或多个元素;Mono<Void>:只表示完成或失败,不表示业务数据。
例如:
@RestController
@RequestMapping("/users")
class ReactiveUserController {
@GetMapping("/{id}")
Mono<UserView> get(@PathVariable long id) {
return Mono.just(new UserView(id, "Alice"));
}
@GetMapping(produces = MediaType.APPLICATION_NDJSON_VALUE)
Flux<UserView> list() {
return Flux.just(
new UserView(1, "Alice"),
new UserView(2, "Bob")
);
}
}
这里返回 Mono<UserView> 并不表示控制器方法内部已经取得了用户。它表示一个异步计算的描述。通常只有框架订阅这个 Mono 后,计算才开始运行;计算产生元素时,响应才有机会写出。
Reactive Streams 用四个核心信号描述过程:
onSubscribe(subscription)
|
| request(n)
v
onNext(value) ... onNext(value)
|
+---- onComplete()
|
+---- onError(exception)
request(n) 表示下游当前愿意接收多少元素,这就是背压(backpressure)。它限制的是元素信号的传递速度,不是一个能够无限抵抗慢客户端的内存保护机制。若上游已经把大量数据预取到内存,背压来得太晚,仍然可能发生内存压力。
3. 两条请求链的对照
flowchart TD
A[客户端请求] --> B{运行时}
B -->|Servlet| C[Servlet Container]
C --> D[Servlet Filter Chain]
D --> E[DispatcherServlet]
E --> F[HandlerMapping]
F --> G[HandlerAdapter]
G --> H[参数解析与校验]
H --> I[Controller]
I --> J[返回值处理器]
J --> K[HttpMessageConverter]
K --> L[HTTP 响应]
B -->|Reactive| M[Reactive HTTP Server]
M --> N[WebFilter Chain]
N --> O[DispatcherHandler]
O --> P[HandlerMapping]
P --> Q[HandlerAdapter]
Q --> R[参数解析与校验]
R --> S[Controller]
S --> T[Mono/Flux Publisher]
T --> U[订阅、信号与响应写出]
U --> L
MVC 和 WebFlux 都有 HandlerMapping、HandlerAdapter、参数解析器和消息转换器等概念,但底层调用协议不同:
- MVC 的基础接口围绕 Servlet 请求、响应和同步/异步 Servlet 生命周期;
- WebFlux 的基础接口围绕
ServerWebExchange、Publisher和响应提交阶段。
WebFlux 并不等于“只能运行在 Netty 上”。它可以运行在 Reactor Netty,也可以适配 Servlet 容器等运行环境;同样,选择 WebFlux 也不保证整个应用自动变成非阻塞。真正决定结果的是调用链中的每一个 I/O 组件和线程调度策略。
二、请求链:从入口到控制器,再到响应
1. MVC 的过滤器、拦截器和 DispatcherServlet
一个 MVC 请求通常经过如下层次:
Servlet Container
└─ FilterChain
└─ Security Filter Chain
└─ DispatcherServlet
├─ HandlerMapping
├─ HandlerInterceptor.preHandle
├─ HandlerAdapter
│ ├─ 参数解析
│ ├─ 数据绑定
│ ├─ 参数校验
│ └─ Controller 调用
├─ 返回值处理
├─ HandlerInterceptor.postHandle
└─ afterCompletion
它们的职责不能混淆:
- Filter 属于 Servlet 层,可以在
DispatcherServlet之前处理请求,也可以决定是否继续执行链。日志、CORS、安全认证等通常位于这一层。 - Spring Security 的
FilterChain也是 Servlet Filter 链中的一部分,负责认证、授权、CSRF 等安全逻辑。 HandlerInterceptor只在 Spring MVC 已经进入处理器映射之后生效,适合做与控制器调用相关的前后置处理,但不能替代通用 Servlet Filter。DispatcherServlet负责 Spring MVC 的核心分发。HandlerAdapter负责以统一方式调用不同类型的处理器,例如@Controller方法。HttpMessageConverter负责 HTTP 表示格式与 Java 对象之间的转换,不负责业务校验。
如果 Filter 在进入 DispatcherServlet 前抛出异常,通常不会经过 MVC 的 HandlerExceptionResolver。这也是为什么认证失败、网关层拒绝和控制器内部业务异常可能使用不同的错误响应机制。
2. WebFlux 的 WebFilter、WebHandler 和 DispatcherHandler
WebFlux 的请求链可以简化为:
Reactive HTTP Server
└─ WebFilter chain
└─ SecurityWebFilterChain
└─ DispatcherHandler
├─ HandlerMapping
├─ HandlerAdapter
│ ├─ 参数解析
│ ├─ 数据绑定
│ ├─ 参数校验
│ └─ Controller 调用
├─ 返回值处理
└─ WebExceptionHandler
WebFlux 的 WebFilter 对应 MVC 的 Servlet Filter,但它返回 Mono<Void>:
@Component
class RequestIdWebFilter implements WebFilter {
@Override
public Mono<Void> filter(
ServerWebExchange exchange,
WebFilterChain chain) {
String requestId = UUID.randomUUID().toString();
exchange.getResponse().getHeaders()
.add("X-Request-Id", requestId);
return chain.filter(exchange);
}
}
chain.filter(exchange) 返回的是一个代表后续链的 Mono<Void>。如果不返回它,后续处理不会继续;如果在它前后追加逻辑,可以观察请求完成或失败:
return chain.filter(exchange)
.doOnSuccess(ignored -> log.info("request completed"))
.doOnError(error -> log.error("request failed", error));
需要注意,doOnSuccess、doOnError 等操作符只观察信号,不会自动把异常转换成业务响应。要生成状态码和响应体,仍需在正确的 WebFlux 异常处理层完成。
3. 控制器调用不是订阅边界
下面的写法虽然返回了 Mono,但内部仍然是阻塞调用:
@GetMapping("/{id}")
Mono<UserView> get(@PathVariable long id) {
UserEntity entity = blockingRepository.findById(id); // 阻塞
return Mono.just(toView(entity));
}
Mono.just 只是把已经取得的结果包装起来。数据库调用发生在控制器方法被调用时,仍然会占用当前 WebFlux 工作线程。
如果暂时无法替换阻塞组件,至少要把阻塞动作移到专用调度器:
private final Scheduler blockingScheduler =
Schedulers.boundedElastic();
@GetMapping("/{id}")
Mono<UserView> get(@PathVariable long id) {
return Mono.fromCallable(() -> blockingRepository.findById(id))
.subscribeOn(blockingScheduler)
.map(this::toView);
}
这个改法的因果关系是:
fromCallable延迟执行调用,避免在组装Mono时立即执行;subscribeOn指定订阅和上游调用在哪个调度器运行;boundedElastic为阻塞任务提供有界的弹性线程资源;- 线程池耗尽时,请求仍可能排队、超时或失败。
它是隔离阻塞调用的过渡手段,不等价于非阻塞 I/O。若阻塞请求数量持续超过线程池容量,WebFlux 仍会受到吞吐和内存限制。
三、请求参数、请求体和校验
1. 数据绑定与校验是两个阶段
数据绑定负责把 HTTP 数据变成 Java 参数;校验负责判断已经绑定的对象是否满足约束。
例如:
public record CreateUserRequest(
@NotBlank(message = "name is required")
String name,
@Email(message = "email is invalid")
@NotBlank(message = "email is required")
String email,
@Min(value = 18, message = "age must be at least 18")
int age
) {
}
控制器:
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
UserView create(@Valid @RequestBody CreateUserRequest request) {
return userService.create(request);
}
要使 @NotBlank、@Email、@Min 生效,需要:
- 使用
jakarta.validation包中的注解; - 工程引入 Bean Validation 实现,Spring Boot 工程通常通过校验 Starter 提供;
- 在参数上使用
@Valid或@Validated; - 请求体能够先被消息转换器成功解析。
这四步的顺序很重要。JSON 语法错误发生在反序列化阶段,通常还没有得到 CreateUserRequest,因此不是普通的字段校验错误。
2. MVC 中的典型错误路径
对于请求体校验,MVC 常见路径如下:
HTTP JSON
└─ Jackson 反序列化
├─ 失败:HttpMessageNotReadableException
└─ 成功
└─ Bean Validation
├─ 失败:MethodArgumentNotValidException
└─ 成功:进入控制器
路径参数和查询参数也有不同的失败类型。例如:
@GetMapping("/search")
List<UserView> search(
@RequestParam
@Size(min = 2, max = 30)
String keyword) {
return userService.search(keyword);
}
参数校验可能通过方法参数校验机制触发。Spring Framework 的不同版本在异常类型和方法校验触发条件上存在差异,不能只根据旧项目经验固定判断一个异常类。生产代码应同时处理当前版本文档中对应的 MethodArgumentNotValidException、HandlerMethodValidationException 等类型,并通过测试确认具体参数场景。
3. WebFlux 中的校验路径
WebFlux 注解式控制器的写法非常相似:
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
Mono<UserView> create(
@Valid @RequestBody CreateUserRequest request) {
return userService.createReactive(request);
}
区别不在 @Valid 的语义,而在请求体读取和控制器返回值的异步生命周期。请求体可能需要异步读取,校验失败会以响应式错误信号向下游传播。
常见类型包括:
WebExchangeBindException:请求体绑定或校验失败的常见异常;HandlerMethodValidationException:方法参数校验相关异常;ServerWebInputException:请求输入无法按要求读取或转换。
不要把这类异常全部归为“业务异常”。例如:
{"age":"not-a-number"}
是输入格式或类型转换错误;而:
{"age":15}
在约束要求成年时才是字段校验错误。两者都可以返回 400 Bad Request,但错误码、日志级别和客户端修复方式可以不同。
4. 统一校验响应
MVC 和 WebFlux 都可以使用 @RestControllerAdvice:
record ApiError(
String code,
String message,
Map<String, String> fields
) {
}
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<ApiError> handleInvalidBody(
MethodArgumentNotValidException ex) {
Map<String, String> fields = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toMap(
FieldError::getField,
error -> Optional.ofNullable(error.getDefaultMessage())
.orElse("invalid"),
(first, ignored) -> first,
LinkedHashMap::new));
return ResponseEntity.badRequest().body(
new ApiError("INVALID_ARGUMENT",
"request validation failed",
fields));
}
@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<ApiError> handleIllegalArgument(
IllegalArgumentException ex) {
return ResponseEntity.badRequest().body(
new ApiError("INVALID_ARGUMENT",
ex.getMessage(),
Map.of()));
}
}
在 WebFlux 中,处理器可以返回 Mono<ResponseEntity<ApiError>>,也可以直接返回 ResponseEntity<ApiError>,具体取决于处理方法是否需要异步操作:
@RestControllerAdvice
class ReactiveApiExceptionHandler {
@ExceptionHandler(WebExchangeBindException.class)
Mono<ResponseEntity<ApiError>> handleBinding(
WebExchangeBindException ex) {
Map<String, String> fields = ex.getFieldErrors()
.stream()
.collect(Collectors.toMap(
FieldError::getField,
error -> Optional.ofNullable(error.getDefaultMessage())
.orElse("invalid"),
(first, ignored) -> first,
LinkedHashMap::new));
return Mono.just(ResponseEntity.badRequest().body(
new ApiError("INVALID_ARGUMENT",
"request validation failed",
fields)));
}
}
实际项目中更推荐使用 RFC 9457 风格的 Problem Details 响应,Spring Framework 和 Spring Boot 已提供相关支持。但无论采用自定义 ApiError 还是 Problem Details,都必须稳定定义:
- HTTP 状态;
- 机器可读错误码;
- 面向用户的消息;
- 字段级错误;
- 请求关联 ID;
- 是否允许客户端重试。
不要把 ex.getMessage() 原样返回给客户端。它可能包含类名、SQL 片段、内部路径或敏感信息。
5. 校验不会自动校验嵌套对象和业务规则
嵌套对象需要使用 @Valid:
public record Address(
@NotBlank String city
) {
}
public record CreateUserRequest(
@NotBlank String name,
@Valid Address address
) {
}
而“邮箱是否已注册”“开始时间是否早于结束时间”“当前用户是否有权修改该资源”不属于单纯的 Bean Validation 约束。把数据库查询塞进字段校验器会导致:
- 校验过程产生 I/O;
- 错误语义混合;
- 事务和并发边界不清楚;
- WebFlux 中可能把阻塞校验放到事件循环线程。
这类规则通常应在应用服务层执行,并将冲突明确转换为 409 Conflict、403 Forbidden 或其他合适状态。
四、异常传播与统一错误处理
1. 异常有多个产生位置
一次请求可能在以下位置失败:
- Filter 或 WebFilter;
- 路由匹配;
- 请求参数转换;
- 请求体反序列化;
- Bean Validation;
- 控制器调用;
- 业务服务或数据库;
- 响应序列化;
- 响应已经提交后的流式写出。
“统一异常处理”只能覆盖它所处的处理边界。控制器中的异常一般可以交给 @ControllerAdvice;Filter 中的异常可能需要 Filter 自己处理;响应已经提交后,通常无法再把状态码改成 500。
2. MVC 的异常解析
MVC 控制器抛出异常后,DispatcherServlet 会调用异常解析器。@ExceptionHandler 方法通常由 ExceptionHandlerExceptionResolver 处理;如果没有匹配的处理器,Boot 的错误处理机制可能生成默认错误响应。
例如:
@ResponseStatus(HttpStatus.NOT_FOUND)
class UserNotFoundException extends RuntimeException {
UserNotFoundException(long id) {
super("user not found: " + id);
}
}
更可控的方式是显式映射:
@ExceptionHandler(UserNotFoundException.class)
ResponseEntity<ApiError> handleNotFound(UserNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
new ApiError("USER_NOT_FOUND", "user does not exist", Map.of()));
}
@ResponseStatus 适合简单固定映射,但大型系统通常需要统一错误结构、日志策略和关联 ID,因此集中处理更容易维护。
3. WebFlux 的异常是错误信号
WebFlux 中,异常既可能在控制器方法被调用时直接抛出,也可能在 Mono 或 Flux 发出 onError 时产生:
@GetMapping("/{id}")
Mono<UserView> get(@PathVariable long id) {
return userService.find(id)
.switchIfEmpty(Mono.error(
new UserNotFoundException(id)));
}
这里 switchIfEmpty 构造的是响应式失败路径。异常不会被普通的 try-catch 捕获:
try {
return userService.find(id)
.switchIfEmpty(Mono.error(new UserNotFoundException(id)));
} catch (UserNotFoundException ex) {
// 通常捕获不到,因为异常在订阅后才可能发生
throw ex;
}
应该使用响应式操作符或框架异常处理:
return userService.find(id)
.switchIfEmpty(Mono.error(new UserNotFoundException(id)))
.onErrorMap(DatabaseException.class,
ex -> new ServiceUnavailableException("database unavailable", ex));
onErrorResume 可以把错误转换成备用结果,但不能无条件使用:
return remoteClient.call()
.onErrorResume(TimeoutException.class,
ex -> Mono.just(defaultView()));
这段代码将超时转换成正常业务响应。只有当“降级结果”不会掩盖数据一致性问题时才成立;对支付扣款、库存扣减等操作,静默返回默认值可能造成严重错误。
4. 响应提交后的异常
普通 JSON 响应通常在对象序列化和写出完成后才结束。流式响应则可能已经发送了部分数据:
发送 HTTP 头和 200
发送 event-1
发送 event-2
上游失败
此时服务器无法把状态码改为 500,因为客户端已经看到 200。可以做的事情通常是:
- 关闭连接;
- 发送协议层定义的错误事件;
- 让客户端根据事件内容或断连重新连接;
- 记录服务端错误并统计流中断。
因此,流式协议的错误设计不能只依赖 HTTP 状态码。
5. Spring Boot 默认错误处理不是业务契约
Spring Boot 的自动配置会提供默认错误端点和错误响应机制,但默认响应的字段、堆栈信息和暴露策略受配置影响,也不应直接当作稳定的业务 API 契约。
如果使用 Actuator 诊断,应区分:
/actuator/health:健康状态;/actuator/metrics:指标;/actuator/loggers等管理能力:需要严格限制访问权限。
Actuator 暴露的是运维面,@RestControllerAdvice 处理的是业务请求面,两者不是同一个错误入口。Spring Security 也可能在控制器之前拒绝请求,所以应分别验证未认证、无权限、校验失败和业务异常的响应。
五、流式响应:数据如何逐步到达客户端
1. “返回 Flux”不等于“客户端立即收到每个元素”
流式传输至少涉及四个缓冲或调度环节:
上游生产者
→ Reactor 算子与预取
→ WebFlux 编码器
→ HTTP 服务器写缓冲
→ TCP/代理缓冲
→ 客户端读取
即使 Flux 每次只产生一个元素,代理、压缩层或客户端也可能积累多个元素后才展示。因此“服务端已经发出”与“用户界面已经看到”不是同一个时刻。
2. Server-Sent Events
SSE 是服务器到客户端的单向事件流,媒体类型是 text/event-stream。WebFlux 示例:
@GetMapping(
value = "/events",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<ServerSentEvent<UserView>> events() {
return Flux.interval(Duration.ofSeconds(1))
.map(sequence -> ServerSentEvent.<UserView>builder()
.id(Long.toString(sequence))
.event("user-update")
.data(new UserView(sequence, "user-" + sequence))
.build());
}
浏览器可以用:
const source = new EventSource("/events");
source.addEventListener("user-update", event => {
console.log(JSON.parse(event.data));
});
Flux.interval 是一个异步定时生产者。请求取消时,订阅会被取消,相关资源也应随之释放。若生产者连接了数据库游标、消息订阅或文件句柄,应使用 usingWhen、doFinally 等机制保证取消、完成和失败路径都清理资源。
MVC 也支持 SSE:
@GetMapping(
value = "/events",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
SseEmitter events() {
SseEmitter emitter = new SseEmitter(60_000L);
taskExecutor.execute(() -> {
try {
for (int i = 0; i < 10; i++) {
emitter.send(SseEmitter.event()
.id(Integer.toString(i))
.name("user-update")
.data(new UserView(i, "user-" + i)));
Thread.sleep(1000);
}
emitter.complete();
} catch (Exception ex) {
emitter.completeWithError(ex);
}
});
return emitter;
}
这段 MVC 代码必须有独立的 TaskExecutor。如果在 Servlet 请求线程中执行循环和 sleep,就会长时间占用容器线程。即便使用异步 Servlet,生产线程、容器写线程和代理超时仍需单独配置。
3. JSON 数组与 NDJSON 的差别
普通 JSON 数组通常要形成完整结构:
[
{"id":1},
{"id":2}
]
如果响应还未结束,客户端不一定能把它当成完整 JSON 解析。NDJSON 则是一行一个 JSON 对象:
Content-Type: application/x-ndjson
{"id":1}
{"id":2}
WebFlux 示例:
@GetMapping(
value = "/users",
produces = MediaType.APPLICATION_NDJSON_VALUE)
Flux<UserView> users() {
return userRepository.findAllReactive()
.limitRate(100);
}
limitRate(100) 可以影响下游请求节奏,但不会替代数据库端分页、游标或结果集限制。若数据库驱动已经把百万行全部读入内存,HTTP 层的限速无法消除上游内存问题。
4. 文件流和资源生命周期
流式读取文件时,资源关闭必须覆盖成功、失败和取消:
@GetMapping(value = "/download", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
Flux<DataBuffer> download() {
Resource resource = new FileSystemResource("/data/report.bin");
return DataBufferUtils.read(
resource,
new DefaultDataBufferFactory(),
8192);
}
实际工程中还应确认:
- 文件是否允许被外部请求访问;
- 路径是否经过规范化和权限校验;
- 客户端断开时底层资源是否释放;
- 代理是否缓存或缓冲响应;
- 是否需要
Content-Length、Content-Disposition和范围请求支持。
直接把用户输入拼接为文件路径可能导致路径穿越,这是授权问题,不是流式 API 能解决的问题。
5. SSE、WebSocket 和长轮询不是同一种能力
- SSE:基于 HTTP,单向服务器到客户端,浏览器有自动重连语义,适合通知和状态更新。
- WebSocket:双向长连接,适合客户端和服务器都频繁发送消息。
- 长轮询:客户端发起普通 HTTP 请求,服务器延迟响应,完成后客户端再次请求,兼容性较好但连接开销更明显。
选择协议时必须考虑负载均衡器超时、连接数量、重连策略、事件顺序、重复消息、断点续传和认证续期。仅仅把返回类型改成 Flux,不会自动获得消息可靠性。
六、Spring Security 如何进入请求链
1. MVC 的安全位置
MVC 应用中的 Spring Security 通常位于 DispatcherServlet 之前:
请求
→ Servlet Filter Chain
→ Security Filter Chain
→ 认证
→ 授权
→ CSRF
→ DispatcherServlet
→ Controller
因此以下情况通常不会进入控制器:
- JWT 缺失或签名错误;
- 当前用户未认证;
- 当前用户没有所需权限;
- CSRF 校验失败。
这些错误分别对应认证入口点、访问拒绝处理器和 CSRF 处理逻辑。不能指望 @RestControllerAdvice 统一接管所有安全错误,因为控制器可能根本没有被调用。
2. WebFlux 的安全位置
WebFlux 使用响应式安全过滤链:
请求
→ WebFilter
→ SecurityWebFilterChain
→ 认证与授权
→ DispatcherHandler
→ Controller
JWT 认证仍然需要验证签名、过期时间、发行者、受众等声明;响应式只是把认证过程放入响应式执行模型。若认证器内部调用阻塞数据库,应同样隔离阻塞操作。
CSRF 的判断不能简单写成“API 不需要 CSRF”。CSRF 主要针对浏览器自动携带凭证的场景,例如 Cookie 会话认证;如果 API 使用不会被浏览器自动附带的 Authorization Bearer Token,风险模型不同。但只要应用仍使用 Cookie 认证并接受浏览器跨站请求,就需要认真处理 CSRF,而不是因为使用 WebFlux 就关闭它。
七、MVC 与 WebFlux 的选择边界
1. 先看依赖链,而不是看控制器风格
选择依据不是“团队喜欢注解”或“返回类型更现代”,而是请求链中是否存在足够多的非阻塞环节。
可以把一次请求抽象成:
请求耗时
= CPU 处理时间
+ 数据库等待
+ 远程服务等待
+ 文件或网络 I/O 等待
在 MVC 中,等待通常占用一个工作线程:
线程 A:接收请求 → 等数据库 → 等远程服务 → 写响应
在非阻塞模型中:
事件循环:注册数据库/网络操作 → 处理其他请求
异步回调:数据库完成 → 继续组装响应
只有当数据库驱动、HTTP 客户端、消息客户端、缓存客户端等都支持非阻塞调用时,第二种模型才真正成立。如果关键环节仍是阻塞式,WebFlux 只是把阻塞搬到了某个线程池。
2. 更适合 Web MVC 的场景
MVC 往往更直接地适合:
- 主要依赖 JDBC、JPA、阻塞式 SDK;
- 业务以请求—响应和事务边界为主;
- 需要成熟的 Servlet Filter、Servlet API 或传统库;
- 团队更熟悉线程池、同步调试和传统调用栈;
- 流式需求有限,可以用
SseEmitter或异步返回值解决。
这不是说 MVC 不能高并发,而是它通常通过容器线程池、连接池、数据库池和超时控制管理并发。容量上限更接近“可同时占用的工作线程和外部资源数量”。
3. 更适合 WebFlux 的场景
WebFlux 更有价值的场景包括:
- 大量并发长连接;
- SSE、WebSocket、持续数据流;
- 端到端采用响应式数据库、响应式 HTTP 客户端和消息系统;
- 需要把多个异步远程调用组合起来;
- 单个请求经常等待 I/O,且不希望每个等待占用平台线程。
但它要求团队理解:
- 发布者何时执行;
- 订阅和取消;
- 背压与预取;
- 调度器;
- 阻塞检测;
- 响应提交和部分写出;
- 响应式上下文中的日志、链路追踪和安全身份。
如果只是把 User user 改成 Mono<User>,而仓储仍然是 JPA,通常不会得到预期收益。
4. 不应只用吞吐基准决定框架
吞吐量和延迟结果依赖:
- 请求是否包含阻塞 I/O;
- 数据库连接池大小;
- HTTP 客户端实现;
- 序列化和压缩;
- 线程池;
- GC;
- 代理和网络;
- 超时与重试策略;
- 流量形态和连接持续时间。
一个只返回内存对象的微基准,不能推导出真实业务中 WebFlux 一定更快。相反,长连接数量和阻塞等待占比很高时,响应式模型可能更适合,但仍需用真实协议、真实依赖和故障条件压测。
八、常见错误与诊断路径
1. 错误:在 WebFlux 事件循环中调用阻塞 API
失败表现可能包括:
- 少量请求正常,大量并发时整体延迟突然升高;
- CPU 不高但请求超时;
- 所有接口同时变慢,因为事件循环被阻塞;
- 线程栈显示卡在 JDBC、文件读取或同步锁。
诊断步骤:
- 查看线程名和线程栈,确认阻塞调用是否发生在事件循环线程;
- 检查数据库、HTTP 客户端、Redis 和消息客户端是否是响应式实现;
- 开启阻塞调用检测工具或使用测试环境的阻塞检测;
- 检查
boundedElastic是否大量排队; - 为外部调用设置连接、读取和整体超时;
- 观察 Actuator 指标、连接池使用率、线程池队列和请求延迟。
2. 错误:只捕获控制器异常
失败表现是:
- 业务异常响应格式统一;
- 但 JSON 解析错误、认证失败、404 和网关拒绝仍使用另一种格式;
- 日志中找不到对应请求 ID。
原因是这些错误可能发生在不同层。应分别测试:
无效 JSON
缺少必填字段
路径参数类型错误
未认证
无权限
CSRF 失败
控制器抛出业务异常
响应流中途失败
每个场景都要验证状态码、响应格式、日志和指标,而不是只测试一个 @ExceptionHandler。
3. 错误:在响应式链中提前执行代码
Mono<UserView> result = Mono.just(loadUser()); // loadUser 已经立即执行
如果 loadUser() 是阻塞调用,问题在创建 Mono 时就发生了。应使用:
Mono<UserView> result =
Mono.fromCallable(this::loadUser)
.subscribeOn(Schedulers.boundedElastic());
Mono.defer 适合延迟创建 Publisher:
Mono<UserView> result =
Mono.defer(() -> Mono.just(loadUser()));
但 defer 只延迟执行,不会把阻塞调用自动迁移到合适线程;仍需根据调用性质选择调度器。
4. 错误:无限流没有生命周期策略
无限 SSE 若没有心跳、超时、取消和重连设计,可能表现为:
- 代理认为连接空闲而关闭;
- 服务器积累大量断开的订阅;
- 客户端重连后收到重复事件;
- 部署或发布时长时间无法释放连接。
生产设计至少要明确:
- 心跳间隔;
- 最大连接时长;
- 客户端重连退避;
Last-Event-ID或其他断点位置;- 重复消息是否允许;
- 事件源取消时如何释放资源;
- 代理的读超时和缓冲配置。
5. 错误:把状态码当作流中每条消息的错误协议
响应一旦提交,后续元素失败时不能重新发送 HTTP 状态码。对于流式接口,应在协议层设计事件:
event: data
data: {...}
event: error
data: {"code":"UPSTREAM_TIMEOUT","retryable":true}
然后关闭流或继续发送,取决于协议是否允许恢复。客户端必须理解这种语义,否则服务器记录了错误,客户端却把半截数据当成成功结果。
九、一个可验证的最小工程边界
MVC 工程
依赖至少需要:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
启动后可以验证:
curl -i -X POST http://localhost:8080/users \
-H 'Content-Type: application/json' \
-d '{"name":"","email":"wrong","age":15}'
预期是 400 Bad Request,并能看到字段级错误。若看到默认错误页而不是统一结构,应检查:
@RestControllerAdvice是否被组件扫描;- 异常类型是否匹配当前 Spring 版本;
- 请求是否在进入 MVC 前就被 Security Filter 拒绝;
- JSON 是否在校验前就已经反序列化失败。
WebFlux 工程
依赖通常是:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
启动后验证流式响应:
curl -N -i http://localhost:8080/events
-N 用于减少客户端缓冲,便于观察事件逐步到达。但这不代表生产环境中的浏览器、反向代理和网络设备都会逐条显示。若命令一直没有数据,应检查:
- 控制器是否声明了
text/event-stream; - Flux 是否真的产生元素;
- 是否在链路中调用了
collectList(),把流重新聚合; - 代理是否启用了响应缓冲;
- 首个事件或心跳是否等待过久;
- 客户端是否已经取消请求。
端到端验证比单元测试更重要
控制器单元测试只能验证方法调用和返回值,不能充分验证:
- 消息转换器;
- 请求体读取;
- 校验异常;
- Security 过滤链;
- 响应提交;
- 流式取消;
- 代理缓冲;
- 客户端断开后的资源释放。
MVC 可使用 MockMvc 验证完整 Servlet 分发;WebFlux 可使用 WebTestClient 验证响应式 HTTP 行为。对流式接口还要测试客户端取消和上游失败,而不仅是“能收到第一条数据”。
十、最终选择:按阻塞边界和交互协议做决定
Spring Web MVC 与 WebFlux 的核心差异,可以归纳为三条因果链:
-
请求执行模型不同
MVC 以 Servlet 调度和线程占用为基础;WebFlux 以 Publisher、订阅、信号和取消为基础。 -
异常和响应完成边界不同
MVC 异常通常由HandlerExceptionResolver在响应提交前处理;WebFlux 异常可能是异步错误信号,流式响应提交后再失败时无法修改 HTTP 状态码。 -
非阻塞收益取决于依赖链完整性
只有从入口、控制器、数据库、远程调用到响应写出都能正确处理异步和取消,WebFlux 的模型才有完整价值;存在不可避免的阻塞组件时,MVC 往往更简单,也更容易正确。
因此,选择框架时应先画出真实请求链:
认证 → 参数读取 → 校验 → 数据库 → 远程调用 → 业务事务 → 序列化 → 代理 → 客户端
再逐段回答:
- 这一段是否阻塞?
- 阻塞时占用哪个线程?
- 是否有超时?
- 是否支持取消?
- 失败发生在响应提交前还是提交后?
- 数据是完整响应还是持续流?
- 认证和授权发生在哪一层?
- 客户端能否理解重试、重复和部分结果?
能明确回答这些问题,MVC 与 WebFlux 就不再是“同步和异步的风格选择”,而是基于运行时模型、依赖能力、错误语义和交互协议的工程选择。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Spring Boot 工程基础:自动配置、Starter、配置绑定、Actuator 和启动
- 下一篇:Spring Security 完整指南:Filter Chain、认证、授权、JWT 和 CSRF
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论