Java 基础体系 · 第 91/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Spring 异常处理:Problem Detail、全局处理、错误码和日志边界
Spring 应用中的异常处理,不只是“捕获异常并返回一个 JSON”。一个完整的处理链至少要回答四个问题:
- 服务端如何把异常转换为 HTTP 响应?
- 响应体如何表达状态、标题、文档链接和机器可读信息?
- 不同控制器、不同异常类型如何采用一致的规则?
- 哪些信息应该返回给客户端,哪些信息只能写入日志?
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"
}
这里的 code 和 traceId 不是 RFC 9457 的固定字段,而是应用定义的扩展属性。
4. 日志事件
日志不是返回给客户端的另一份错误响应,而是面向运维、开发和审计人员的内部事件记录。
日志可以包含:
- 异常堆栈;
- 内部类名和方法名;
- 数据库错误码;
- 下游服务响应;
- 用户标识的脱敏版本;
- trace ID;
- 请求耗时;
- 重试次数。
这些信息通常不应直接进入 API 响应。原因包括:
- 暴露数据库、文件路径或内部服务名称;
- 泄露个人数据和凭证;
- 让攻击者获得堆栈和组件版本信息;
- 造成响应格式不稳定;
- 把内部实现细节变成客户端依赖。
因此,异常响应和日志是两个不同的数据边界:
异常
├─> HTTP 响应:稳定、有限、可供客户端处理
└─> 日志事件:内部、可诊断、包含必要上下文
二、一次异常如何穿过 Spring MVC
以一个典型请求为例:
HTTP 请求
│
▼
DispatcherServlet
│
├─ HandlerMapping 找到控制器
├─ HandlerAdapter 调用控制器
├─ 参数解析与参数校验
└─ 控制器执行业务逻辑
│
└─ 抛出异常
│
▼
HandlerExceptionResolver 链
│
├─ @ExceptionHandler
├─ ResponseEntityExceptionHandler
├─ Spring 内置异常解析
└─ 未处理时交给容器/Boot 的错误兜底
│
▼
HTTP 响应
Spring MVC 中,异常处理并不只是 try/catch。DispatcherServlet 会将处理过程中产生的异常交给异常解析器链。常见解析器包括:
ExceptionHandlerExceptionResolver:处理控制器或@ControllerAdvice中的@ExceptionHandler;ResponseStatusExceptionResolver:处理@ResponseStatus或ResponseStatusException;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,但要注意不要把数据库主键、用户隐私或内部拓扑直接暴露出去。
instance 和 traceId 不是同一个概念:
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,它可以携带一个 ErrorResponse。ProblemDetail 是 ErrorResponse 的重要使用场景之一。
不过,应用不必为了使用 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 契约的错误码通常应当:
- 唯一:不同业务原因不要共用同一个码;
- 稳定:不因异常类名或内部重构变化;
- 可枚举:客户端和监控系统能够识别;
- 有文档:说明触发条件、客户端动作和是否可重试;
- 与状态码一致:但不与状态码重复。
例如:
| 错误码 | 状态 | 客户端通常动作 |
|---|---|---|
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 配置的 AuthenticationEntryPoint 和 AccessDeniedHandler。
认证和授权异常的边界通常是:
- 未认证:
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"
}
如果 age 是 int,转换会失败。这通常属于请求消息无法绑定,不能假设会进入业务方法。统一响应时应覆盖相应 Spring MVC 异常,否则客户端会看到默认错误格式。
十、错误响应中的国际化、字段错误和稳定性
字段错误中的 message 有两种常见用途:
- 面向开发者的调试说明;
- 面向最终用户的展示文本。
这两个用途不应强行使用同一个字段。
一种更稳定的结构是:
{
"code": "REQUEST_VALIDATION_FAILED",
"errors": [
{
"field": "email",
"code": "EMAIL_INVALID",
"message": "邮箱格式不正确"
}
]
}
客户端逻辑依赖:
errors[].field
errors[].code
而不是依赖:
errors[].message
如果采用 Spring Validation 的默认错误码,例如 NotBlank、Email、Min,它们能表达约束类型,但未必能表达业务含义。可以在 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=...
是否使用 INFO 或 WARN 取决于业务含义:
- 正常竞争结果、用户输入错误:常用
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
}
retryable 和 retryAfterSeconds 是应用扩展字段,不是 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)));
}
异常可能在 Mono 或 Flux 信号中异步产生,而不是在方法调用栈中同步抛出。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 默认错误对象或网关自定义对象。
诊断顺序:
- 是否请求经过了目标 Spring Boot 应用?
- 是否被网关、反向代理或安全组件提前返回?
- 异常是否发生在 Filter 或 Security 链中?
- 是否有
@RestControllerAdvice被组件扫描到? - 是否存在多个 Advice,且另一个处理器优先匹配?
- 是否是 HTML 错误页面或容器级错误?
- 是否使用了 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 错误表示的一致性;@RestControllerAdvice 和 ResponseEntityExceptionHandler 解决的是跨控制器的处理入口;业务错误码解决的是客户端精确分类;日志边界解决的是“可诊断性”和“不过度泄露”之间的冲突。
最终应保持以下关系:
HTTP 状态码:协议层分类
Problem type:问题类型标识
业务错误码:应用契约
detail:当前请求的安全说明
异常堆栈:内部诊断信息
trace ID:响应与日志之间的关联键
只要这几个层次没有互相替代,异常处理就能同时满足客户端稳定性、服务端可诊断性和生产环境安全性。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Spring 参数校验:Bean Validation、分组、嵌套、错误模型和国际化
- 下一篇:Spring Cache:抽象、Key、TTL、穿透、一致性和多级缓存
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论