Java 基础体系 · 第 91/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Spring 异常处理:Problem Detail、全局处理、错误码和日志边界

Spring 应用中的异常处理,不只是“捕获异常并返回一个 JSON”。一个完整的处理链至少要回答四个问题:

  1. 服务端如何把异常转换为 HTTP 响应?
  2. 响应体如何表达状态、标题、文档链接和机器可读信息?
  3. 不同控制器、不同异常类型如何采用一致的规则?
  4. 哪些信息应该返回给客户端,哪些信息只能写入日志?

Spring Framework 6 引入并支持 ProblemDetail,为 HTTP API 提供了一种标准化的错误表示方式。Spring Boot 则在此基础上提供默认错误处理、/error 兜底机制以及配置项。真正可靠的实现,还需要将 HTTP 状态、业务错误码、异常类型、日志级别和追踪标识明确分层。

本文以 Spring MVC 为主,使用 Java 25 LTS 语法环境;核心 API 来自 Spring Framework 6.x。Spring WebFlux 的异常处理模型会在相关位置说明差异。


一、先区分四个容易混淆的概念

1. Java 异常

Java 异常描述的是程序执行过程中发生的失败。例如:

throw new IllegalArgumentException("年龄不能为负数");

它属于进程内部的控制流和故障表示,通常包含:

  • 异常类型;
  • 异常消息;
  • cause 链;
  • stack trace。

异常类型适合供服务端代码分类处理,但不适合直接作为 API 契约。例如,把 IllegalArgumentException 原样返回给客户端,会暴露实现细节,而且客户端无法稳定依赖 Java 类名。

2. HTTP 状态码

HTTP 状态码描述的是一次 HTTP 请求在协议层面的结果。

例如:

  • 400 Bad Request:请求本身无法被服务正确理解或验证;
  • 401 Unauthorized:缺少有效认证凭证;
  • 403 Forbidden:已识别身份,但无权执行;
  • 404 Not Found:目标资源不存在,或服务不希望暴露其存在性;
  • 409 Conflict:请求与当前资源状态冲突;
  • 422 Unprocessable Content:请求语法正确,但语义验证失败;
  • 500 Internal Server Error:服务端发生未被更具体分类的错误。

状态码适合让通用 HTTP 客户端、网关和监控系统理解失败类别,但它的粒度通常不足以表示具体业务原因。

例如下面三个错误都可能使用 409

  • 用户名已经存在;
  • 订单状态不允许取消;
  • 库存版本发生并发冲突。

因此,状态码不能替代业务错误码。

3. Problem Detail

ProblemDetail 是 Spring 对 RFC 9457 Problem Details for HTTP APIs 的支持。它表示一个面向 HTTP API 的结构化错误文档,核心字段包括:

字段 含义
type 描述问题类型的 URI
title 问题类型的简短、稳定标题
status HTTP 状态码
detail 当前请求下的具体说明
instance 当前问题实例的 URI

其中:

  • title 描述“这类问题是什么”;
  • detail 描述“这一次请求具体发生了什么”;
  • type 用于标识问题类型;
  • instance 用于标识当前发生的具体问题。

ProblemDetail 还支持扩展属性。例如可以添加稳定的业务错误码:

{
  "type": "https://api.example.com/problems/username-taken",
  "title": "Username is already taken",
  "status": 409,
  "detail": "The username 'alice' has already been registered.",
  "instance": "/users",
  "code": "USER_USERNAME_TAKEN",
  "traceId": "7f4c1d2a9e6b"
}

这里的 codetraceId 不是 RFC 9457 的固定字段,而是应用定义的扩展属性。

4. 日志事件

日志不是返回给客户端的另一份错误响应,而是面向运维、开发和审计人员的内部事件记录。

日志可以包含:

  • 异常堆栈;
  • 内部类名和方法名;
  • 数据库错误码;
  • 下游服务响应;
  • 用户标识的脱敏版本;
  • trace ID;
  • 请求耗时;
  • 重试次数。

这些信息通常不应直接进入 API 响应。原因包括:

  • 暴露数据库、文件路径或内部服务名称;
  • 泄露个人数据和凭证;
  • 让攻击者获得堆栈和组件版本信息;
  • 造成响应格式不稳定;
  • 把内部实现细节变成客户端依赖。

因此,异常响应和日志是两个不同的数据边界:

异常
 ├─> HTTP 响应:稳定、有限、可供客户端处理
 └─> 日志事件:内部、可诊断、包含必要上下文

二、一次异常如何穿过 Spring MVC

以一个典型请求为例:

HTTP 请求
   │
   ▼
DispatcherServlet
   │
   ├─ HandlerMapping 找到控制器
   ├─ HandlerAdapter 调用控制器
   ├─ 参数解析与参数校验
   └─ 控制器执行业务逻辑
          │
          └─ 抛出异常
                 │
                 ▼
        HandlerExceptionResolver 链
                 │
        ├─ @ExceptionHandler
        ├─ ResponseEntityExceptionHandler
        ├─ Spring 内置异常解析
        └─ 未处理时交给容器/Boot 的错误兜底
                 │
                 ▼
             HTTP 响应

Spring MVC 中,异常处理并不只是 try/catchDispatcherServlet 会将处理过程中产生的异常交给异常解析器链。常见解析器包括:

  • ExceptionHandlerExceptionResolver:处理控制器或 @ControllerAdvice 中的 @ExceptionHandler
  • ResponseStatusExceptionResolver:处理 @ResponseStatusResponseStatusException
  • DefaultHandlerExceptionResolver:处理 Spring MVC 定义的一些标准异常。

在 Spring Boot 中,如果异常最终没有被应用层处理,通常还会进入 Boot 的错误处理机制。对于普通 HTTP 请求,常见兜底路径是 /error,由 BasicErrorController 等组件生成错误响应。

需要注意,具体解析器顺序和最终输出形式属于框架实现的一部分;应用不应依赖某个异常一定经过某个内部类,而应通过公开扩展点定义自己的 API 契约。


三、ProblemDetail 的字段如何设计

3.1 type:问题类型,而不是异常类型

type 应标识一个稳定的问题类别。它通常是 URI:

https://api.example.com/problems/user-not-found

这个 URI 不要求一定可以被浏览器访问,但它应当在文档和版本策略中具有稳定含义。

不推荐:

{
  "type": "java.lang.IllegalArgumentException"
}

原因是 Java 异常类型是服务端实现细节,重构包名、替换异常类型或切换语言后,客户端契约就会变化。

也不推荐每次生成随机 URI:

{
  "type": "https://api.example.com/problems/3e7c..."
}

随机值只能标识一次事件,不能标识问题类型,会削弱客户端分类和监控聚合能力。

3.2 title:稳定的类别标题

title 应是简短、稳定的标题,例如:

"title": "User not found"

它不是堆栈信息,也不是当前请求的完整错误说明。对于多语言 API,通常可以让 title 保持稳定,或者根据请求语言返回本地化文本,但客户端不应依赖自然语言文本进行逻辑判断。

3.3 status:响应状态的文档表示

status 描述 HTTP 状态。实际 HTTP 响应行也必须发送对应状态:

HTTP/1.1 404 Not Found

同时 JSON 中可以包含:

"status": 404

两者应保持一致。若响应头是 404,正文却是 500,客户端、网关和日志系统可能产生不同判断。

ProblemDetail#setStatus 设置的是对象中的状态表示;返回对象时,Spring 通常会据此生成响应状态,但对于自定义封装或响应实体,仍应验证最终 HTTP 状态行,不要只检查 JSON。

3.4 detail:当前请求的具体事实

detail 应解释当前请求失败的具体原因,例如:

"detail": "The username 'alice' has already been registered."

它可以包含请求值,但必须先判断该值是否敏感。密码、访问令牌、完整身份证号、支付卡号等都不应写入 detail

对于输入校验错误,detail 可以是总体说明,具体字段错误放在扩展属性中:

{
  "type": "https://api.example.com/problems/validation-failed",
  "title": "Request validation failed",
  "status": 400,
  "detail": "One or more request fields are invalid.",
  "code": "REQUEST_VALIDATION_FAILED",
  "errors": [
    {
      "field": "email",
      "code": "Email",
      "message": "must be a well-formed email address"
    },
    {
      "field": "age",
      "code": "Min",
      "message": "must be greater than or equal to 18"
    }
  ]
}

3.5 instance:当前问题实例

instance 用于标识这一次问题实例,常见做法是使用请求路径:

"instance": "/users/42"

也可以使用带有追踪信息的内部问题 URI,但要注意不要把数据库主键、用户隐私或内部拓扑直接暴露出去。

instancetraceId 不是同一个概念:

  • instance 关联一个 HTTP 问题文档;
  • traceId 关联分布式调用链。

应用可以同时返回二者,但不应把它们混为一谈。


四、Spring 中创建 ProblemDetail 的方式

4.1 直接创建

ProblemDetail problem = ProblemDetail.forStatusAndDetail(
        HttpStatus.CONFLICT,
        "The username has already been registered."
);

problem.setTitle("Username is already taken");
problem.setType(URI.create(
        "https://api.example.com/problems/username-taken"
));
problem.setProperty("code", "USER_USERNAME_TAKEN");

ProblemDetail.forStatusAndDetail 根据状态创建对象,并设置详细信息。扩展字段通过 setProperty 添加。

如果使用 ProblemDetail.forStatus,则需要自己设置 detail

ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
problem.setDetail("The requested user does not exist.");

4.2 使用 ResponseStatusException

Spring 提供 ResponseStatusException

throw new ResponseStatusException(
        HttpStatus.NOT_FOUND,
        "User not found"
);

它适合控制器边界上的简单 HTTP 错误,但不适合承载复杂、稳定的业务错误模型。直接在业务服务中抛出它,会让业务层依赖 HTTP 类型:

业务服务 → HTTP 状态码

这种耦合会使同一个服务难以复用于消息消费、批处理或内部调用。

对于简单的资源不存在场景,可以使用它;对于稳定的领域错误,通常更适合定义领域异常,再在 Web 层映射为 ProblemDetail

4.3 使用 ErrorResponseException

Spring Framework 还提供 ErrorResponseException,它可以携带一个 ErrorResponseProblemDetailErrorResponse 的重要使用场景之一。

不过,应用不必为了使用 Problem Detail 而让所有领域异常继承 Spring 的 Web 异常类型。更重要的是保持层次边界:

领域层异常:表达业务事实
Web 层异常处理器:把业务事实映射为 HTTP Problem Detail

五、一个可运行的端到端示例

下面给出一个最小 Spring Boot MVC 示例。它展示:

  • 领域异常;
  • 全局 @RestControllerAdvice
  • ProblemDetail
  • 业务错误码;
  • Bean Validation 错误;
  • trace ID;
  • 未知异常的日志边界。

5.1 Maven 配置

<project>
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.5.0</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>problem-detail-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>25</java.version>
    </properties>

    <dependencies>
        <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>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

版本号应以项目实际使用的 Spring Boot 版本为准。这里的关键不是某个特定补丁版本,而是使用支持 ProblemDetail 的 Spring Framework 6 系列 API。

5.2 定义领域异常

package com.example.demo.user;

public final class UsernameTakenException extends RuntimeException {

    private final String username;

    public UsernameTakenException(String username) {
        super("Username is already registered");
        this.username = username;
    }

    public String username() {
        return username;
    }
}

这个异常表达的是一个业务事实:用户名已被占用。它没有继承 ResponseStatusException,因此领域层不直接依赖 HTTP。

在真实系统中,异常消息不应无条件包含敏感输入。这里用户名可作为示例,但如果用户名本身属于隐私数据,应在日志和响应中采用脱敏值。

5.3 定义请求对象和控制器

package com.example.demo.user;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;

public record CreateUserRequest(
        @NotBlank
        String username,

        @NotBlank
        @Email
        String email,

        @Min(18)
        int age
) {
}
package com.example.demo.user;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
public class UserController {

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserResponse create(@Valid @RequestBody CreateUserRequest request) {
        if ("alice".equalsIgnoreCase(request.username())) {
            throw new UsernameTakenException(request.username());
        }

        return new UserResponse(
                request.username(),
                request.email(),
                request.age()
        );
    }

    public record UserResponse(
            String username,
            String email,
            int age
    ) {
    }
}

@Valid @RequestBody 会触发 Bean Validation。校验失败通常发生在控制器方法真正执行之前,因此异常不会从 create 方法体中抛出,而是由 Spring MVC 的参数解析和校验流程产生。

5.4 编写全局异常处理器

package com.example.demo.web;

import com.example.demo.user.UsernameTakenException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.net.URI;
import java.util.List;
import java.util.UUID;

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log =
            LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(UsernameTakenException.class)
    public ProblemDetail handleUsernameTaken(
            UsernameTakenException exception,
            HttpServletRequest request
    ) {
        String traceId = traceId(request);

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.CONFLICT,
                "The username is already registered."
        );

        problem.setType(URI.create(
                "https://api.example.com/problems/username-taken"
        ));
        problem.setTitle("Username is already taken");
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("code", "USER_USERNAME_TAKEN");
        problem.setProperty("traceId", traceId);

        log.info(
                "User creation rejected because username is taken, " +
                "username={}, traceId={}",
                maskUsername(exception.username()),
                traceId
        );

        return problem;
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleMethodArgumentNotValid(
            MethodArgumentNotValidException exception,
            HttpServletRequest request
    ) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST,
                "One or more request fields are invalid."
        );

        problem.setType(URI.create(
                "https://api.example.com/problems/validation-failed"
        ));
        problem.setTitle("Request validation failed");
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("code", "REQUEST_VALIDATION_FAILED");
        problem.setProperty(
                "errors",
                exception.getBindingResult()
                        .getFieldErrors()
                        .stream()
                        .map(error -> new FieldErrorBody(
                                error.getField(),
                                error.getCode(),
                                error.getDefaultMessage()
                        ))
                        .toList()
        );

        return problem;
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public ProblemDetail handleConstraintViolation(
            ConstraintViolationException exception,
            HttpServletRequest request
    ) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST,
                "One or more request parameters are invalid."
        );

        problem.setType(URI.create(
                "https://api.example.com/problems/constraint-violation"
        ));
        problem.setTitle("Request parameters are invalid");
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("code", "REQUEST_CONSTRAINT_VIOLATION");

        List<FieldErrorBody> errors = exception.getConstraintViolations()
                .stream()
                .map(violation -> new FieldErrorBody(
                        violation.getPropertyPath().toString(),
                        violation.getMessageTemplate(),
                        violation.getMessage()
                ))
                .toList();

        problem.setProperty("errors", errors);
        return problem;
    }

    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(
            Exception exception,
            HttpServletRequest request
    ) {
        String traceId = traceId(request);

        log.error(
                "Unexpected exception while processing request, " +
                "method={}, path={}, traceId={}",
                request.getMethod(),
                request.getRequestURI(),
                traceId,
                exception
        );

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR,
                "The server could not complete the request."
        );

        problem.setType(URI.create(
                "https://api.example.com/problems/internal-error"
        ));
        problem.setTitle("Internal server error");
        problem.setInstance(URI.create(request.getRequestURI()));
        problem.setProperty("code", "INTERNAL_ERROR");
        problem.setProperty("traceId", traceId);

        return problem;
    }

    private static String traceId(HttpServletRequest request) {
        String incoming = request.getHeader("X-Trace-Id");
        return incoming == null || incoming.isBlank()
                ? UUID.randomUUID().toString()
                : incoming;
    }

    private static String maskUsername(String username) {
        if (username == null || username.length() <= 2) {
            return "***";
        }
        return username.substring(0, 1) + "***"
                + username.substring(username.length() - 1);
    }

    private record FieldErrorBody(
            String field,
            String code,
            String message
    ) {
    }
}

这个示例中,@RestControllerAdvice 的作用是把异常处理逻辑应用到多个控制器。@ControllerAdvice 本身负责跨控制器共享处理逻辑;@RestControllerAdvice 等价于带有响应体语义的组合形式,适合 REST API。

异常处理方法返回 ProblemDetail,Spring MVC 会将其写入响应体。对于 Problem Details 响应,客户端通常应看到 application/problem+json 内容类型;具体内容协商仍受请求头、消息转换器和配置影响,因此应通过集成测试验证。

5.5 启动和调用

启动:

mvn spring-boot:run

前置条件:

  • Java 25 已安装并位于 PATH
  • Maven 可用;
  • 端口 8080 未被占用。

调用用户名冲突场景:

curl -i \
  -H 'Content-Type: application/json' \
  -d '{"username":"alice","email":"alice@example.com","age":20}' \
  http://localhost:8080/users

预期结果类似:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
  "type": "https://api.example.com/problems/username-taken",
  "title": "Username is already taken",
  "status": 409,
  "detail": "The username is already registered.",
  "instance": "/users",
  "code": "USER_USERNAME_TAKEN",
  "traceId": "..."
}

调用参数校验失败场景:

curl -i \
  -H 'Content-Type: application/json' \
  -d '{"username":"","email":"not-an-email","age":12}' \
  http://localhost:8080/users

预期状态为 400,并包含 REQUEST_VALIDATION_FAILED 和字段级 errors

这里有一个重要的执行顺序:

读取 JSON
  ↓
绑定 CreateUserRequest
  ↓
执行 Bean Validation
  ↓
校验失败:不进入 create 方法
  ↓
全局异常处理器生成 ProblemDetail

因此,如果只处理业务异常而不处理参数绑定和校验异常,API 仍可能返回 Spring 默认格式,导致同一个接口出现多种错误结构。


六、为什么应该把错误码和 HTTP 状态码分开

可以把一次 API 错误表示为一个二元组:

E = (H, C)

其中:

  • H 是 HTTP 状态码;
  • C 是应用定义的稳定错误码。

例如:

E = (409, USER_USERNAME_TAKEN)

H 负责粗粒度协议语义,C 负责应用内部的精确分类。

6.1 状态码不是错误码

下面的设计不稳定:

{
  "status": 409,
  "message": "username exists"
}

客户端如果需要判断具体原因,只能解析自然语言 message。一旦服务端修改语言、措辞或标点,客户端逻辑就可能失效。

更稳定的设计是:

{
  "status": 409,
  "code": "USER_USERNAME_TAKEN",
  "detail": "The username is already registered."
}

客户端根据 code 进行程序判断,根据 detail 展示或记录人类可读信息。

6.2 错误码应该具备什么性质

一个适合作为 API 契约的错误码通常应当:

  1. 唯一:不同业务原因不要共用同一个码;
  2. 稳定:不因异常类名或内部重构变化;
  3. 可枚举:客户端和监控系统能够识别;
  4. 有文档:说明触发条件、客户端动作和是否可重试;
  5. 与状态码一致:但不与状态码重复。

例如:

错误码 状态 客户端通常动作
REQUEST_VALIDATION_FAILED 400 修正请求字段
AUTHENTICATION_REQUIRED 401 获取或刷新认证凭证
USER_USERNAME_TAKEN 409 更换用户名
ORDER_STATE_INVALID 409 刷新订单状态后重试或停止操作
RATE_LIMITED 429 Retry-After 延迟重试
INTERNAL_ERROR 500 不展示内部细节,可使用 trace ID 联系支持

6.3 不要把内部数据库错误码直接当 API 错误码

数据库唯一索引冲突可能表现为某个数据库驱动异常,但 API 应返回领域含义:

数据库唯一键异常
  ↓
持久化适配器识别冲突
  ↓
转换为 UsernameTakenException
  ↓
Web 层转换为 USER_USERNAME_TAKEN / 409

如果直接把数据库异常传到 Web 层,API 会与数据库实现绑定。未来更换数据库、调整索引或修改 ORM 后,错误契约会无故变化。


七、全局处理器的匹配规则和优先级

7.1 优先处理更具体的异常

以下处理器:

@ExceptionHandler(UsernameTakenException.class)

比以下处理器更适合处理用户名冲突:

@ExceptionHandler(RuntimeException.class)

也比:

@ExceptionHandler(Exception.class)

更具体。

一个典型的匹配关系是:

UsernameTakenException
    └─ RuntimeException
        └─ Exception
            └─ Throwable

如果只有 Exception.class 的处理器,所有未处理异常都会落入通用分支。通用分支应作为最终兜底,而不是承载所有业务分类。

7.2 @ExceptionHandler 不会捕获所有执行位置的异常

它主要处理 Spring MVC 请求处理链中的异常。例如:

  • 控制器方法;
  • 参数绑定;
  • 参数校验;
  • 消息转换;
  • Spring MVC 处理过程。

但以下异常可能不经过控制器异常处理器:

  • Filter 中抛出的异常;
  • Servlet 容器尚未进入 Spring MVC 前的异常;
  • 异步线程中脱离请求处理链的异常;
  • 定时任务异常;
  • 消息监听器异常;
  • 启动阶段异常。

这意味着“全局异常处理器”是“全局 Web 请求异常处理器”,不是整个 JVM 或整个应用的异常捕获器。

7.3 Filter 中的认证异常需要明确处理

例如 JWT 认证过滤器在 Filter 中发现令牌无效时,不能假设 @RestControllerAdvice 一定会接管。常见方式是直接通过 HttpServletResponse 写出响应,或者将异常交给 Spring Security 配置的 AuthenticationEntryPointAccessDeniedHandler

认证和授权异常的边界通常是:

  • 未认证:401
  • 已认证但无权限:403

如果把二者都返回 403,客户端无法知道应该重新登录还是提示权限不足。


八、使用 ResponseEntityExceptionHandler 统一 Spring 内置异常

当应用需要系统处理 Spring MVC 内置异常时,可以继承 ResponseEntityExceptionHandler,重写相应方法,再用 ProblemDetail 统一格式。

package com.example.demo.web;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import org.springframework.web.bind.MethodArgumentNotValidException;

import java.net.URI;

@RestControllerAdvice
public class SpringWebExceptionHandler
        extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException exception,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request
    ) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                status,
                "One or more request fields are invalid."
        );

        problem.setType(URI.create(
                "https://api.example.com/problems/validation-failed"
        ));
        problem.setTitle("Request validation failed");
        problem.setProperty("code", "REQUEST_VALIDATION_FAILED");

        var errors = exception.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> new FieldErrorBody(
                        error.getField(),
                        error.getCode(),
                        error.getDefaultMessage()
                ))
                .toList();

        problem.setProperty("errors", errors);

        return handleExceptionInternal(
                exception,
                problem,
                headers,
                status,
                request
        );
    }

    private record FieldErrorBody(
            String field,
            String code,
            String message
    ) {
    }
}

ResponseEntityExceptionHandler 的价值是提供一个集中位置来覆盖框架定义的异常处理方法,例如:

  • 参数绑定失败;
  • 请求体校验失败;
  • 请求方法不支持;
  • 媒体类型不支持;
  • 缺少请求参数;
  • 类型转换失败。

使用时要注意:同一个异常如果同时被父类重写方法和另一个 @ExceptionHandler 处理,可能造成处理规则重复或优先级不清。应让每类异常有一个明确的最终归属。


九、请求体格式错误和字段校验错误不是一回事

9.1 JSON 语法错误

请求体:

{"username":

这是 JSON 语法不完整,通常会在 HTTP 消息转换阶段失败,常见异常类型是 HttpMessageNotReadableException

它意味着服务器无法将请求体解析成 Java 对象,通常应返回 400

{
  "type": "https://api.example.com/problems/malformed-json",
  "title": "Malformed JSON",
  "status": 400,
  "detail": "The request body is not valid JSON.",
  "code": "MALFORMED_JSON"
}

9.2 JSON 结构正确但字段不合法

请求体:

{
  "username": "",
  "email": "not-an-email",
  "age": 12
}

它可以被解析为对象,但违反了 Bean Validation 约束。这是字段校验错误,仍然可能返回 400,但错误码和字段列表应与 JSON 语法错误区分。

9.3 类型转换错误

请求体:

{
  "username": "alice",
  "email": "alice@example.com",
  "age": "twenty"
}

如果 ageint,转换会失败。这通常属于请求消息无法绑定,不能假设会进入业务方法。统一响应时应覆盖相应 Spring MVC 异常,否则客户端会看到默认错误格式。


十、错误响应中的国际化、字段错误和稳定性

字段错误中的 message 有两种常见用途:

  1. 面向开发者的调试说明;
  2. 面向最终用户的展示文本。

这两个用途不应强行使用同一个字段。

一种更稳定的结构是:

{
  "code": "REQUEST_VALIDATION_FAILED",
  "errors": [
    {
      "field": "email",
      "code": "EMAIL_INVALID",
      "message": "邮箱格式不正确"
    }
  ]
}

客户端逻辑依赖:

errors[].field
errors[].code

而不是依赖:

errors[].message

如果采用 Spring Validation 的默认错误码,例如 NotBlankEmailMin,它们能表达约束类型,但未必能表达业务含义。可以在 Web 层映射为应用级错误码:

@NotBlank → FIELD_REQUIRED
@Email    → EMAIL_INVALID
@Min      → VALUE_TOO_SMALL

映射时必须处理错误码为空、多个候选错误码和嵌套对象字段路径等情况,不能假设每个 FieldError 都有完整的业务码。


十一、日志边界:什么时候记录什么级别

日志级别不应只根据“有没有异常对象”决定,而应根据失败责任和运维动作决定。

11.1 预期的客户端错误

例如:

  • 参数校验失败;
  • 资源不存在;
  • 用户名已存在;
  • 没有权限;
  • 请求状态冲突。

这些错误通常是业务流程中的可预期分支,不应每次都打印完整堆栈。可以记录:

INFO 或 WARN
method=POST
path=/users
code=USER_USERNAME_TAKEN
traceId=...

是否使用 INFOWARN 取决于业务含义:

  • 正常竞争结果、用户输入错误:常用 INFO
  • 疑似攻击、异常频率、权限探测:可用 WARN
  • 不应因为客户端输错一个字段就记录 ERROR 堆栈。

11.2 服务端未预期异常

例如:

  • NullPointerException
  • 数据库连接池耗尽;
  • 下游响应格式破坏;
  • 未处理的状态分支;
  • 程序违反内部不变量。

这类错误应记录 ERROR,并保留异常对象以输出堆栈:

log.error(
        "Unexpected exception, traceId={}, path={}",
        traceId,
        request.getRequestURI(),
        exception
);

把异常作为最后一个参数传递,SLF4J 才能正确记录堆栈。下面这种写法会丢失堆栈,或者把异常错误地当成普通参数:

log.error("Unexpected exception: {}", exception.getMessage());

11.3 不要在每层重复打印同一个堆栈

以下链路会产生三份重复日志:

Repository catch + error
    ↓
Service catch + error
    ↓
ControllerAdvice catch + error

重复日志会放大日志量,并让告警系统误以为发生了三次故障。

更合理的边界是:

  • 负责恢复、重试或转换的层:可以记录必要上下文,但不一定打印堆栈;
  • 负责最终失败响应的边界:对未处理异常统一记录一次完整堆栈;
  • 已被识别为预期业务异常:记录结构化事件,不打印堆栈。

如果数据库层捕获异常后补充上下文再重新抛出,建议使用异常 cause 链,而不是立即打印并再次抛出:

throw new UserRepositoryException(
        "Failed to load user",
        exception
);

11.4 日志中的敏感数据

以下内容不应直接记录:

  • 密码;
  • Authorization 请求头;
  • Cookie;
  • 访问令牌;
  • 完整身份证号;
  • 完整支付卡号;
  • 未脱敏的个人健康数据。

同时,ProblemDetail.detail 也应遵守同样的限制。不能因为“只返回给当前用户”就忽略日志、代理、浏览器历史和客户端错误采集系统的传播范围。


十二、trace ID 的生成、传播和边界

客户端需要一个标识来向服务支持人员描述问题:

{
  "code": "INTERNAL_ERROR",
  "traceId": "7f4c1d2a9e6b"
}

但返回 traceId 只有在日志中也能检索到它时才有意义。

一个完整链路应满足:

请求进入
  ↓
读取或生成 traceId
  ↓
放入日志上下文 MDC
  ↓
业务日志携带 traceId
  ↓
异常响应返回 traceId

示例中通过 HttpServletRequest 临时读取或生成 ID,能够说明概念,但生产系统通常还需要:

  • 在 Filter 或 Spring Security 链早期建立上下文;
  • 使用 MDC 让所有日志自动带上 ID;
  • 校验外部传入的 trace ID 格式和长度;
  • 防止攻击者注入换行符或伪造复杂内容;
  • 与 OpenTelemetry 的 trace/span 语义保持一致;
  • 对异步线程和线程池任务正确传播上下文。

不要把客户端传入的 X-Trace-Id 无条件当成可信身份信息。它可以作为关联字段,但不能用于授权、审计身份或安全决策。


十三、Boot 默认错误处理和配置风险

当异常没有被自定义处理器处理时,Spring Boot 通常会通过错误处理机制生成默认响应。开发环境中常见的默认错误结构可能包含:

{
  "timestamp": "...",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/users"
}

实际字段取决于 Boot 版本、请求类型和相关配置。

以下配置会影响默认错误响应中是否包含额外信息:

server.error.include-message=never
server.error.include-stacktrace=never
server.error.include-binding-errors=never
server.error.include-exception=false

这些配置主要影响 Boot 默认错误处理路径,不能替代自定义 @ExceptionHandler 中的安全控制。如果应用代码自己把异常消息写入 ProblemDetail.detail,上述配置不会自动移除它。

生产环境不应启用:

server.error.include-stacktrace=always
server.error.include-exception=true

因为这可能导致:

  • 堆栈暴露;
  • 内部类名暴露;
  • 文件路径暴露;
  • 数据库或下游错误信息泄露。

开发环境可以临时启用部分信息帮助调试,但应通过环境配置隔离,并在集成测试中确认生产配置没有泄漏。


十四、异常处理的故障路径

考虑一个订单取消接口:

POST /orders/100/cancel

业务流程如下:

请求进入
  │
  ▼
认证
  ├─ 无凭证 ───────────────> 401
  └─ 已认证
       │
       ▼
参数绑定和校验
  ├─ JSON 非法 ────────────> 400
  ├─ 字段非法 ─────────────> 400
  └─ 校验通过
       │
       ▼
查询订单
  ├─ 不存在 ───────────────> 404
  └─ 存在
       │
       ▼
检查订单状态
  ├─ 已发货 ───────────────> 409 / ORDER_STATE_INVALID
  └─ 可取消
       │
       ▼
更新订单
  ├─ 乐观锁冲突 ──────────> 409 / ORDER_VERSION_CONFLICT
  ├─ 数据库不可用 ─────────> 503 或 500
  └─ 成功 ─────────────────> 200

每个分支都应有自己的责任边界:

  • 认证失败由安全组件处理;
  • 参数失败由 MVC 异常处理器处理;
  • 订单不存在由领域异常映射;
  • 状态冲突由业务错误码表达;
  • 数据库不可用由基础设施异常处理,并进行告警;
  • 成功路径不应通过异常实现。

一个常见错误是将所有异常都转换为:

{
  "status": 500,
  "message": "operation failed"
}

这样会丢失客户端可恢复的信息。例如,用户名冲突、参数错误和服务崩溃都返回 500,客户端无法判断是否应该修正请求、等待重试还是停止操作。


十五、可重试性不能仅由状态码决定

HTTP 状态码提供了粗粒度提示,但是否可重试还取决于操作的幂等性和业务语义。

例如:

  • GET 通常是幂等的,网络超时后较容易重试;
  • POST /payments 可能创建重复支付,不能因为收到 500 就盲目重试;
  • 409 ORDER_VERSION_CONFLICT 通常要求客户端重新读取资源,而不是立即重复同一请求;
  • 429 通常应结合 Retry-After
  • 503 可能表示暂时不可用,但仍需结合请求是否已在服务端执行。

因此,错误响应可以通过扩展属性提供更精确的信息:

{
  "type": "https://api.example.com/problems/rate-limited",
  "title": "Too many requests",
  "status": 429,
  "code": "RATE_LIMITED",
  "retryable": true,
  "retryAfterSeconds": 10
}

retryableretryAfterSeconds 是应用扩展字段,不是 Problem Details 的标准固定字段。客户端应以接口文档为准,而不是擅自对所有 5xx 或所有 409 重试。


十六、不要把异常处理器当作事务补偿器

全局异常处理器的职责是将已经发生的失败转换为 HTTP 响应;它通常不适合执行:

  • 回滚外部系统操作;
  • 重新发送消息;
  • 补偿支付;
  • 删除已创建的文件;
  • 修改数据库状态;
  • 启动复杂重试。

事务回滚应由事务边界负责,消息重试应由消息消费框架负责,分布式补偿应由工作流、Outbox、Saga 等机制负责。

例如:

数据库事务
  ├─ 写订单
  ├─ 写订单事件
  └─ 事务提交
          ↓
      消息发布或异步处理

如果在 @ExceptionHandler 中尝试“补偿订单”,可能已经错过原事务上下文,或者再次产生不可控副作用。异常响应层只应根据最终结果生成对外表示。


十七、异常继承关系和响应状态的常见陷阱

17.1 在异常类上使用 @ResponseStatus

可以定义:

@ResponseStatus(HttpStatus.NOT_FOUND)
public class UserNotFoundException extends RuntimeException {
}

这是一种简单方式,但它把 HTTP 语义直接放进异常类。对于小型应用可接受,对于复杂领域模型则会带来层次耦合。

此外,使用 @ResponseStatus 并不会自动保证你得到完整、统一的 Problem Detail;响应正文仍取决于异常处理链和 Boot 配置。

17.2 把所有异常声明为 throws Exception

public UserResponse create(...) throws Exception

这不会改善异常处理。Java 的 throws 主要是编译期声明,对运行时异常路由没有特殊帮助。真正决定响应的是异常解析器和处理器匹配规则。

17.3 捕获后重新抛出但丢失 cause

错误:

catch (SQLException exception) {
    throw new UserRepositoryException("load failed");
}

原始数据库异常被丢弃,日志只能看到表层消息。

正确:

catch (SQLException exception) {
    throw new UserRepositoryException("load failed", exception);
}

然后由最终边界记录完整 cause 链。

17.4 返回 200 并在正文中写错误

HTTP/1.1 200 OK

{
  "success": false,
  "error": "USER_USERNAME_TAKEN"
}

这会让通用客户端、网关、缓存、指标系统误判请求成功。除非协议明确规定采用这种模式,否则 HTTP 失败应使用合适的非 2xx 状态码。


十八、WebFlux 的差异

Spring WebFlux 同样支持 ProblemDetail@ExceptionHandler@RestControllerAdvice,但执行模型是响应式的。

例如控制器返回:

@GetMapping("/users/{id}")
public Mono<UserResponse> find(@PathVariable long id) {
    return repository.find(id)
            .switchIfEmpty(Mono.error(new UserNotFoundException(id)));
}

异常可能在 MonoFlux 信号中异步产生,而不是在方法调用栈中同步抛出。Spring WebFlux 的异常处理器仍可以将它转换为响应,但需要注意:

  • 不要在响应式链中阻塞;
  • 不要依赖 ThreadLocal 传播请求上下文;
  • MDC、trace ID 和安全上下文需要响应式上下文传播;
  • WebFlux 的过滤器和 MVC 的 Filter 不是同一套扩展点;
  • 同一个异常如果已被响应式链转换为错误信号,后续处理路径与同步 MVC 不同。

因此,MVC 示例不能机械复制到 WebFlux。Problem Detail 的数据结构可以复用,但生命周期、上下文和错误传播机制不同。


十九、测试要验证“状态、媒体类型和契约”

仅测试 Java 方法返回值是不够的。异常处理属于 HTTP 边界,至少应验证:

  • 状态码;
  • Content-Type
  • type
  • title
  • status
  • code
  • 字段错误结构;
  • 是否泄漏堆栈和内部消息。

使用 MockMvc 的示例:

@WebMvcTest(UserController.class)
@Import(GlobalExceptionHandler.class)
class UserControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    void duplicateUsernameReturnsProblemDetail() throws Exception {
        mockMvc.perform(post("/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "username": "alice",
                                  "email": "alice@example.com",
                                  "age": 20
                                }
                                """))
                .andExpect(status().isConflict())
                .andExpect(content().contentTypeCompatibleWith(
                        MediaType.APPLICATION_PROBLEM_JSON
                ))
                .andExpect(jsonPath("$.type").value(
                        "https://api.example.com/problems/username-taken"
                ))
                .andExpect(jsonPath("$.code").value(
                        "USER_USERNAME_TAKEN"
                ))
                .andExpect(jsonPath("$.status").value(409));
    }

    @Test
    void invalidRequestReturnsFieldErrors() throws Exception {
        mockMvc.perform(post("/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {
                                  "username": "",
                                  "email": "invalid",
                                  "age": 12
                                }
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value(
                        "REQUEST_VALIDATION_FAILED"
                ))
                .andExpect(jsonPath("$.errors").isArray());
    }
}

MediaType.APPLICATION_PROBLEM_JSON 用于表示 application/problem+json。测试使用 contentTypeCompatibleWith 比直接硬编码完整媒体类型更宽松,因为响应可能包含字符集等参数。

还应测试未知异常:

未知异常 → 500
响应不包含 exception
响应不包含 stackTrace
日志包含 traceId 和完整堆栈

最后一项通常需要使用日志采集器或测试 appender 验证,而不是只看 HTTP 响应。


二十、建立一套可审查的异常映射表

复杂服务不应让异常映射规则散落在几十个控制器中。可以先建立映射表:

内部异常或条件 HTTP 状态 Problem type 业务 code 日志
JSON 解析失败 400 malformed-json MALFORMED_JSON INFO
Bean Validation 失败 400 validation-failed REQUEST_VALIDATION_FAILED INFO
未认证 401 authentication-required AUTHENTICATION_REQUIRED INFO/WARN
无权限 403 access-denied ACCESS_DENIED INFO/WARN
资源不存在 404 resource-not-found RESOURCE_NOT_FOUND INFO
业务状态冲突 409 具体问题类型 具体业务码 INFO
限流 429 rate-limited RATE_LIMITED WARN
下游临时不可用 503 dependency-unavailable DEPENDENCY_UNAVAILABLE ERROR
未知服务端异常 500 internal-error INTERNAL_ERROR ERROR + stack

这张表的价值不是替代实现,而是明确三种不同的分类:

内部原因:异常类型、数据库错误、下游错误
协议结果:HTTP 状态
对外契约:Problem type 和业务 code

如果三者混为一谈,通常会出现以下退化:

  • 用异常类名充当 API 错误码;
  • 用 HTTP 状态码表达全部业务原因;
  • 用日志消息作为客户端契约;
  • 用用户可读文本作为机器判断条件。

二十一、边界场景和诊断方法

21.1 响应不是 Problem Detail

现象:

Content-Type: application/json

正文却是 Boot 默认错误对象或网关自定义对象。

诊断顺序:

  1. 是否请求经过了目标 Spring Boot 应用?
  2. 是否被网关、反向代理或安全组件提前返回?
  3. 异常是否发生在 Filter 或 Security 链中?
  4. 是否有 @RestControllerAdvice 被组件扫描到?
  5. 是否存在多个 Advice,且另一个处理器优先匹配?
  6. 是否是 HTML 错误页面或容器级错误?
  7. 是否使用了 WebFlux,却按 MVC 配置处理?

21.2 状态码正确但响应体错误

这通常表示:

  • ResponseStatusExceptionResolver 设置了状态,但没有统一正文;
  • 自定义处理器只返回了字符串或普通 DTO;
  • 异常被 Boot /error 兜底;
  • 内容协商没有匹配到 Problem Detail 的消息转换器。

应通过 MockMvc 或真实 HTTP 集成测试同时检查状态码、正文和媒体类型。

21.3 生产日志缺少堆栈

检查是否写成:

log.error("request failed: {}", exception.getMessage());

而不是:

log.error("request failed", exception);

还要检查日志采集器是否截断多行堆栈,以及是否在上层将异常包装时丢失了 cause。

21.4 客户端收到内部异常消息

排查:

  • 是否在 ProblemDetail.detail 中直接使用 exception.getMessage()
  • 是否启用了 server.error.include-message
  • 是否启用了 server.error.include-stacktrace
  • 是否有网关把服务端日志或错误页转发出去;
  • 是否对数据库和下游异常做了安全映射。

二十二、一套合理的实现结构

对于中型服务,可以按下面的边界组织代码:

domain/
  UserAlreadyExistsException.java
  OrderStateConflictException.java

application/
  UserService.java
  OrderService.java

adapter/
  persistence/
    UserRepositoryAdapter.java
  remote/
    PaymentClient.java

web/
  GlobalExceptionHandler.java
  ProblemTypes.java
  ErrorCodes.java
  TraceIdFilter.java

其中:

  • domain 只表达领域失败事实;
  • adapter 把数据库、HTTP 客户端等技术异常转换为应用可理解的异常;
  • web 将异常映射为 HTTP 状态和 Problem Detail;
  • ErrorCodes 保存对外契约,不应直接使用数据库错误编号;
  • TraceIdFilter 或观测性组件建立请求关联上下文;
  • 日志在最终边界统一记录未预期异常。

ProblemTypes 可以集中定义 URI:

public final class ProblemTypes {

    private ProblemTypes() {
    }

    public static final URI USERNAME_TAKEN =
            URI.create("https://api.example.com/problems/username-taken");

    public static final URI INTERNAL_ERROR =
            URI.create("https://api.example.com/problems/internal-error");
}

集中定义可以减少拼写错误,但不应把所有错误处理逻辑压缩成一个巨大 Map<Exception, ProblemDetail>。不同异常通常需要提取不同上下文、设置不同状态并执行不同日志策略。


二十三、最终边界

Spring 异常处理的核心不是“捕获更多异常”,而是建立一条可预测的转换链:

内部故障或业务事实
    ↓
异常分类
    ↓
HTTP 状态映射
    ↓
Problem Detail
    ↓
稳定业务错误码
    ↓
有限的客户端信息

同时:

内部故障或业务事实
    ↓
结构化日志
    ↓
trace ID、cause、堆栈和诊断上下文

ProblemDetail 解决的是 HTTP 错误表示的一致性;@RestControllerAdviceResponseEntityExceptionHandler 解决的是跨控制器的处理入口;业务错误码解决的是客户端精确分类;日志边界解决的是“可诊断性”和“不过度泄露”之间的冲突。

最终应保持以下关系:

HTTP 状态码:协议层分类
Problem type:问题类型标识
业务错误码:应用契约
detail:当前请求的安全说明
异常堆栈:内部诊断信息
trace ID:响应与日志之间的关联键

只要这几个层次没有互相替代,异常处理就能同时满足客户端稳定性、服务端可诊断性和生产环境安全性。


系列导航与关联阅读

官方资料

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