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。它负责:

  1. 查找能够处理请求的控制器;
  2. 解析路径变量、查询参数、请求体和请求头;
  3. 调用控制器方法;
  4. 将返回值转换成视图或 HTTP 响应体;
  5. 在异常发生时交给异常解析器;
  6. 把响应提交给 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<TDeferredResult<T>CompletableFuture<T>ResponseBodyEmitterSseEmitter。这改变的是 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 都有 HandlerMappingHandlerAdapter、参数解析器和消息转换器等概念,但底层调用协议不同:

  • MVC 的基础接口围绕 Servlet 请求、响应和同步/异步 Servlet 生命周期;
  • WebFlux 的基础接口围绕 ServerWebExchangePublisher 和响应提交阶段。

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));

需要注意,doOnSuccessdoOnError 等操作符只观察信号,不会自动把异常转换成业务响应。要生成状态码和响应体,仍需在正确的 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);
}

这个改法的因果关系是:

  1. fromCallable 延迟执行调用,避免在组装 Mono 时立即执行;
  2. subscribeOn 指定订阅和上游调用在哪个调度器运行;
  3. boundedElastic 为阻塞任务提供有界的弹性线程资源;
  4. 线程池耗尽时,请求仍可能排队、超时或失败。

它是隔离阻塞调用的过渡手段,不等价于非阻塞 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 的不同版本在异常类型和方法校验触发条件上存在差异,不能只根据旧项目经验固定判断一个异常类。生产代码应同时处理当前版本文档中对应的 MethodArgumentNotValidExceptionHandlerMethodValidationException 等类型,并通过测试确认具体参数场景。

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 Conflict403 Forbidden 或其他合适状态。


四、异常传播与统一错误处理

1. 异常有多个产生位置

一次请求可能在以下位置失败:

  1. Filter 或 WebFilter;
  2. 路由匹配;
  3. 请求参数转换;
  4. 请求体反序列化;
  5. Bean Validation;
  6. 控制器调用;
  7. 业务服务或数据库;
  8. 响应序列化;
  9. 响应已经提交后的流式写出。

“统一异常处理”只能覆盖它所处的处理边界。控制器中的异常一般可以交给 @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 中,异常既可能在控制器方法被调用时直接抛出,也可能在 MonoFlux 发出 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 是一个异步定时生产者。请求取消时,订阅会被取消,相关资源也应随之释放。若生产者连接了数据库游标、消息订阅或文件句柄,应使用 usingWhendoFinally 等机制保证取消、完成和失败路径都清理资源。

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-LengthContent-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、文件读取或同步锁。

诊断步骤:

  1. 查看线程名和线程栈,确认阻塞调用是否发生在事件循环线程;
  2. 检查数据库、HTTP 客户端、Redis 和消息客户端是否是响应式实现;
  3. 开启阻塞调用检测工具或使用测试环境的阻塞检测;
  4. 检查 boundedElastic 是否大量排队;
  5. 为外部调用设置连接、读取和整体超时;
  6. 观察 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 的核心差异,可以归纳为三条因果链:

  1. 请求执行模型不同
    MVC 以 Servlet 调度和线程占用为基础;WebFlux 以 Publisher、订阅、信号和取消为基础。

  2. 异常和响应完成边界不同
    MVC 异常通常由 HandlerExceptionResolver 在响应提交前处理;WebFlux 异常可能是异步错误信号,流式响应提交后再失败时无法修改 HTTP 状态码。

  3. 非阻塞收益取决于依赖链完整性
    只有从入口、控制器、数据库、远程调用到响应写出都能正确处理异步和取消,WebFlux 的模型才有完整价值;存在不可避免的阻塞组件时,MVC 往往更简单,也更容易正确。

因此,选择框架时应先画出真实请求链:

认证 → 参数读取 → 校验 → 数据库 → 远程调用 → 业务事务 → 序列化 → 代理 → 客户端

再逐段回答:

  • 这一段是否阻塞?
  • 阻塞时占用哪个线程?
  • 是否有超时?
  • 是否支持取消?
  • 失败发生在响应提交前还是提交后?
  • 数据是完整响应还是持续流?
  • 认证和授权发生在哪一层?
  • 客户端能否理解重试、重复和部分结果?

能明确回答这些问题,MVC 与 WebFlux 就不再是“同步和异步的风格选择”,而是基于运行时模型、依赖能力、错误语义和交互协议的工程选择。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。