写在前面:说实话,我刚入行那会儿,每个 Controller 里都写 try-catch,异常信息直接怼给前端。结果前端同事天天在群里@我:"这个 NullPointerException 是什么意思?""这个 SQLSyntaxErrorException 我该展示什么?"后来我才明白,用户不需要知道你的堆栈,他们只需要知道"出了什么问题,该怎么办"。这篇文章就是我这几年在异常处理上踩坑、填坑的完整总结。

文章目录
-
- 一、为什么需要统一异常处理?
-
- [1.1 一个让人崩溃的真实场景](#1.1 一个让人崩溃的真实场景)
- [1.2 生活类比:餐厅服务员](#1.2 生活类比:餐厅服务员)
- [1.3 统一处理的核心价值](#1.3 统一处理的核心价值)
- 二、全局响应封装设计
-
- [2.1 统一响应体结构](#2.1 统一响应体结构)
- [2.2 状态码设计规范](#2.2 状态码设计规范)
- [2.3 Result<T> 泛型封装类](#2.3 Result
泛型封装类)
- 三、统一异常处理实现
-
- [3.1 业务异常定义](#3.1 业务异常定义)
- [3.2 全局异常处理类](#3.2 全局异常处理类)
- [3.3 异常分类处理策略](#3.3 异常分类处理策略)
- [3.4 异常日志记录与 MDC 链路追踪](#3.4 异常日志记录与 MDC 链路追踪)
- 四、参数校验与国际化
-
- [4.1 @Valid + @Validated 分组校验](#4.1 @Valid + @Validated 分组校验)
- [4.2 自定义校验注解](#4.2 自定义校验注解)
- [4.3 校验错误信息国际化(i18n)](#4.3 校验错误信息国际化(i18n))
- [五、ResponseBodyAdvice 全局包装](#五、ResponseBodyAdvice 全局包装)
-
- [5.1 自动包装 Controller 返回值](#5.1 自动包装 Controller 返回值)
- [5.2 跳过包装的注解](#5.2 跳过包装的注解)
- [5.3 使用示例](#5.3 使用示例)
- 六、踩坑指南
-
- [6.1 @ExceptionHandler 优先级问题](#6.1 @ExceptionHandler 优先级问题)
- [6.2 ResponseBodyAdvice 与 Swagger 冲突](#6.2 ResponseBodyAdvice 与 Swagger 冲突)
- [6.3 异常处理中的循环依赖](#6.3 异常处理中的循环依赖)
- [6.4 异步方法异常捕获不到](#6.4 异步方法异常捕获不到)
- 七、问题与解答
-
- [Q1:用了全局异常处理后,Controller 里还需要 try-catch 吗?](#Q1:用了全局异常处理后,Controller 里还需要 try-catch 吗?)
- [Q2:ResponseBodyAdvice 会包装所有响应吗?包括 404 Not Found?](#Q2:ResponseBodyAdvice 会包装所有响应吗?包括 404 Not Found?)
- [Q3:全局异常处理怎么和 Feign 客户端的异常结合?](#Q3:全局异常处理怎么和 Feign 客户端的异常结合?)
- 八、面试高频考点汇总
-
- [考点 1:@ControllerAdvice 和 @RestControllerAdvice 有什么区别?](#考点 1:@ControllerAdvice 和 @RestControllerAdvice 有什么区别?)
- [考点 2:@ExceptionHandler 的匹配规则是什么?](#考点 2:@ExceptionHandler 的匹配规则是什么?)
- [考点 3:ResponseBodyAdvice 和 HandlerInterceptor 有什么区别?](#考点 3:ResponseBodyAdvice 和 HandlerInterceptor 有什么区别?)
- [考点 4:如何设计一个支持国际化的异常处理系统?](#考点 4:如何设计一个支持国际化的异常处理系统?)
- [考点 5:全局异常处理在微服务架构中怎么设计?](#考点 5:全局异常处理在微服务架构中怎么设计?)
- 九、模拟面试官提问
-
- [场景题 1:设计一个完整的异常处理体系](#场景题 1:设计一个完整的异常处理体系)
- [场景题 2:Controller 返回 String 时,ResponseBodyAdvice 包装失败怎么办?](#场景题 2:Controller 返回 String 时,ResponseBodyAdvice 包装失败怎么办?)
- [场景题 3:如何防止异常处理逻辑本身抛出异常?](#场景题 3:如何防止异常处理逻辑本身抛出异常?)
- [场景题 4:前后端分离项目中,异常信息怎么给前端?](#场景题 4:前后端分离项目中,异常信息怎么给前端?)
- [场景题 5:生产环境出现异常,如何快速定位问题?](#场景题 5:生产环境出现异常,如何快速定位问题?)
- 十、互动话题
- 参考资料
一、为什么需要统一异常处理?
1.1 一个让人崩溃的真实场景
看看下面这个 Controller,眼熟不?
java
@GetMapping("/user/{id}")
public User getUser(@PathVariable Long id) {
try {
User user = userService.getById(id);
if (user == null) {
return null; // 前端收到 200,但 data 是 null,懵了
}
return user;
} catch (SQLException e) {
e.printStackTrace(); // 堆栈直接打印,用户啥也看不到
return null;
} catch (Exception e) {
return null; // 所有异常都吞掉,出了问题根本不知道
}
}
这种代码我见过太多了。问题在哪?
- 异常信息直接暴露堆栈,安全风险
- 每个接口都写 try-catch,代码臃肿
- 前端收到的响应格式不统一,难以处理
1.2 生活类比:餐厅服务员
想象你去餐厅点菜。厨房可能出问题:没食材了、厨师请假了、设备坏了。但服务员给你的回复永远是标准化的:
"抱歉,这道菜暂时无法提供,您看换一道可以吗?"
而不是:
"厨师刚辞职了,灶台的煤气阀也坏了,冰箱里的牛肉过期了,所以您的菜做不了。"
统一异常处理就是那个"服务员"------不管后端出了什么问题,给前端的回复应该是统一、友好、可控的。
1.3 统一处理的核心价值
| 价值点 | 说明 |
|---|---|
| 代码整洁 | 业务代码里不再写 try-catch,专注业务逻辑 |
| 用户体验好 | 前端收到统一格式的错误信息,知道怎么展示 |
| 安全性高 | 堆栈信息不暴露,攻击者无法获取系统内部细节 |
| 维护成本低 | 异常处理逻辑集中管理,改一处全局生效 |
二、全局响应封装设计
2.1 统一响应体结构
json
{
"code": 200,
"message": "success",
"data": {
"userId": 1,
"username": "zhangsan"
}
}
三个字段,简单清晰:
code:业务状态码,不是 HTTP 状态码message:给用户的友好提示data:实际业务数据
2.2 状态码设计规范
| 状态码 | 含义 | 使用场景 |
|---|---|---|
| 200 | 成功 | 一切正常 |
| 400 | 参数错误 | 请求参数缺失、格式不对 |
| 401 | 未授权 | Token 缺失或过期 |
| 403 | 禁止访问 | 没有权限 |
| 404 | 资源不存在 | 查询的数据找不到 |
| 500 | 系统错误 | 服务器内部异常 |
| 600+ | 业务错误 | 自定义业务场景,如余额不足(601)、库存不足(602) |
踩坑提醒:我见过有人直接用 HTTP 状态码当业务码,比如 404 表示"用户不存在"。千万别这样!HTTP 404 是"接口找不到",业务上的"用户不存在"应该用 200 + 业务码 60001。两者完全不是一个层面的事。
2.3 Result 泛型封装类
java
import com.fasterxml.jackson.annotation.JsonInclude;
import java.io.Serializable;
/**
* 统一响应结果封装
* @param <T> 数据类型
*/
@JsonInclude(JsonInclude.Include.NON_NULL) // null 字段不序列化
public class Result<T> implements Serializable {
private static final long serialVersionUID = 1L;
/** 状态码 */
private Integer code;
/** 提示信息 */
private String message;
/** 响应数据 */
private T data;
/** 时间戳 */
private Long timestamp;
/** 请求追踪 ID */
private String traceId;
private Result() {
this.timestamp = System.currentTimeMillis();
this.traceId = MDC.get("traceId"); // 链路追踪 ID
}
// ==================== 成功响应 ====================
public static <T> Result<T> success() {
Result<T> result = new Result<>();
result.code = 200;
result.message = "操作成功";
return result;
}
public static <T> Result<T> success(T data) {
Result<T> result = success();
result.data = data;
return result;
}
public static <T> Result<T> success(String message, T data) {
Result<T> result = success(data);
result.message = message;
return result;
}
// ==================== 失败响应 ====================
public static <T> Result<T> error(Integer code, String message) {
Result<T> result = new Result<>();
result.code = code;
result.message = message;
return result;
}
public static <T> Result<T> error(String message) {
return error(500, message);
}
public static <T> Result<T> badRequest(String message) {
return error(400, message);
}
// ==================== 业务错误 ====================
public static <T> Result<T> businessError(Integer businessCode, String message) {
return error(businessCode, message);
}
// ==================== 判断方法 ====================
public boolean isSuccess() {
return Integer.valueOf(200).equals(this.code);
}
// ==================== Getter / Setter ====================
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public Long getTimestamp() {
return timestamp;
}
public void setTimestamp(Long timestamp) {
this.timestamp = timestamp;
}
public String getTraceId() {
return traceId;
}
public void setTraceId(String traceId) {
this.traceId = traceId;
}
@Override
public String toString() {
return "Result{" +
"code=" + code +
", message='" + message + '\'' +
", data=" + data +
", traceId='" + traceId + '\'' +
'}';
}
}
三、统一异常处理实现
3.1 业务异常定义
java
/**
* 业务异常
* 用于表示可预期的业务错误,如余额不足、库存不足等
*/
public class BusinessException extends RuntimeException {
private final Integer code;
public BusinessException(String message) {
super(message);
this.code = 600; // 默认业务错误码
}
public BusinessException(Integer code, String message) {
super(message);
this.code = code;
}
public BusinessException(Integer code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
public Integer getCode() {
return code;
}
}
3.2 全局异常处理类
java
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.servlet.http.HttpServletRequest;
import java.util.stream.Collectors;
/**
* 全局异常处理
* 统一捕获所有异常,返回标准格式的错误响应
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 业务异常处理
*/
@ExceptionHandler(BusinessException.class)
public Result<Void> handleBusinessException(BusinessException e, HttpServletRequest request) {
log.warn("[业务异常] URI={}, traceId={}, code={}, message={}",
request.getRequestURI(),
MDC.get("traceId"),
e.getCode(),
e.getMessage());
return Result.error(e.getCode(), e.getMessage());
}
/**
* 参数校验异常 - @Valid 校验失败
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidationException(MethodArgumentNotValidException e, HttpServletRequest request) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
log.warn("[参数校验失败] URI={}, traceId={}, message={}",
request.getRequestURI(),
MDC.get("traceId"),
message);
return Result.badRequest("参数校验失败:" + message);
}
/**
* 参数绑定异常 - @ModelAttribute 校验失败
*/
@ExceptionHandler(BindException.class)
public Result<Void> handleBindException(BindException e, HttpServletRequest request) {
String message = e.getFieldErrors().stream()
.map(error -> error.getField() + ":" + error.getDefaultMessage())
.collect(Collectors.joining("; "));
log.warn("[参数绑定失败] URI={}, traceId={}, message={}",
request.getRequestURI(),
MDC.get("traceId"),
message);
return Result.badRequest("参数绑定失败:" + message);
}
/**
* 非法参数异常 - IllegalArgumentException
*/
@ExceptionHandler(IllegalArgumentException.class)
public Result<Void> handleIllegalArgumentException(IllegalArgumentException e, HttpServletRequest request) {
log.warn("[非法参数] URI={}, traceId={}, message={}",
request.getRequestURI(),
MDC.get("traceId"),
e.getMessage());
return Result.badRequest(e.getMessage());
}
/**
* 非法状态异常 - IllegalStateException
*/
@ExceptionHandler(IllegalStateException.class)
public Result<Void> handleIllegalStateException(IllegalStateException e, HttpServletRequest request) {
log.warn("[非法状态] URI={}, traceId={}, message={}",
request.getRequestURI(),
MDC.get("traceId"),
e.getMessage());
return Result.error(500, "系统状态异常,请稍后重试");
}
/**
* 空指针异常 - NullPointerException
*/
@ExceptionHandler(NullPointerException.class)
public Result<Void> handleNullPointerException(NullPointerException e, HttpServletRequest request) {
log.error("[空指针异常] URI={}, traceId={}",
request.getRequestURI(),
MDC.get("traceId"),
e); // 打印完整堆栈
return Result.error("系统繁忙,请稍后重试");
}
/**
* 算术异常 - ArithmeticException
*/
@ExceptionHandler(ArithmeticException.class)
public Result<Void> handleArithmeticException(ArithmeticException e, HttpServletRequest request) {
log.error("[算术异常] URI={}, traceId={}, message={}",
request.getRequestURI(),
MDC.get("traceId"),
e.getMessage());
return Result.error("计算错误,请检查输入数据");
}
/**
* 数组越界异常 - ArrayIndexOutOfBoundsException
*/
@ExceptionHandler(ArrayIndexOutOfBoundsException.class)
public Result<Void> handleArrayIndexOutOfBoundsException(ArrayIndexOutOfBoundsException e, HttpServletRequest request) {
log.error("[数组越界] URI={}, traceId={}",
request.getRequestURI(),
MDC.get("traceId"),
e);
return Result.error("数据索引错误,请稍后重试");
}
/**
* 类型转换异常 - ClassCastException
*/
@ExceptionHandler(ClassCastException.class)
public Result<Void> handleClassCastException(ClassCastException e, HttpServletRequest request) {
log.error("[类型转换异常] URI={}, traceId={}, message={}",
request.getRequestURI(),
MDC.get("traceId"),
e.getMessage());
return Result.error("数据类型不匹配");
}
/**
* 数字格式异常 - NumberFormatException
*/
@ExceptionHandler(NumberFormatException.class)
public Result<Void> handleNumberFormatException(NumberFormatException e, HttpServletRequest request) {
log.warn("[数字格式异常] URI={}, traceId={}, message={}",
request.getRequestURI(),
MDC.get("traceId"),
e.getMessage());
return Result.badRequest("数字格式错误:" + e.getMessage());
}
/**
* 所有其他未捕获的异常
*/
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e, HttpServletRequest request) {
log.error("[系统异常] URI={}, traceId={}",
request.getRequestURI(),
MDC.get("traceId"),
e); // 打印完整堆栈,便于排查
return Result.error("系统繁忙,请稍后重试");
}
}
3.3 异常分类处理策略
┌────────────────────────────────────────────────────────────┐
│ 异常分类处理策略 │
├────────────────────────────────────────────────────────────┤
│ 业务异常 (BusinessException) │
│ ├── 日志级别:WARN │
│ ├── 返回:业务错误码 + 友好提示 │
│ └── 示例:余额不足、库存不足、订单已取消 │
├────────────────────────────────────────────────────────────┤
│ 参数校验异常 (ValidationException) │
│ ├── 日志级别:WARN │
│ ├── 返回:400 + 具体校验错误信息 │
│ └── 示例:手机号格式错误、必填字段为空 │
├────────────────────────────────────────────────────────────┤
│ 系统异常 (RuntimeException / Exception) │
│ ├── 日志级别:ERROR(打印完整堆栈) │
│ ├── 返回:500 + 统一友好提示(不暴露细节) │
│ └── 示例:NPE、数据库连接失败、第三方服务超时 │
└────────────────────────────────────────────────────────────┘
3.4 异常日志记录与 MDC 链路追踪
java
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;
/**
* 请求追踪过滤器
* 为每个请求生成唯一的 traceId,用于全链路日志追踪
*/
@Component
public class TraceIdFilter extends OncePerRequestFilter {
private static final String TRACE_ID_KEY = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
// 优先从请求头获取(微服务间调用传递)
String traceId = request.getHeader(TRACE_ID_KEY);
if (traceId == null || traceId.isEmpty()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
// 放入 MDC,后续日志自动带上 traceId
MDC.put(TRACE_ID_KEY, traceId);
// 响应头也返回 traceId,方便前端排查问题
response.setHeader(TRACE_ID_KEY, traceId);
filterChain.doFilter(request, response);
} finally {
// 请求结束后清理 MDC,防止线程池复用导致 traceId 污染
MDC.clear();
}
}
}
四、参数校验与国际化
4.1 @Valid + @Validated 分组校验
java
import javax.validation.constraints.*;
import javax.validation.groups.Default;
/**
* 用户请求 DTO
* 使用分组校验区分新增和更新场景
*/
public class UserRequest {
// 更新时需要传 ID,新增时不需要
@NotNull(message = "用户ID不能为空", groups = UpdateGroup.class)
private Long id;
@NotBlank(message = "用户名不能为空", groups = {CreateGroup.class, UpdateGroup.class})
@Size(min = 2, max = 20, message = "用户名长度必须在2-20之间")
private String username;
@NotBlank(message = "密码不能为空", groups = CreateGroup.class)
@Size(min = 6, max = 32, message = "密码长度必须在6-32之间", groups = {CreateGroup.class, UpdateGroup.class})
private String password;
@NotBlank(message = "邮箱不能为空")
@Email(message = "邮箱格式不正确")
private String email;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@Min(value = 0, message = "年龄不能小于0")
@Max(value = 150, message = "年龄不能大于150")
private Integer age;
// 分组接口
public interface CreateGroup extends Default {}
public interface UpdateGroup extends Default {}
// Getter / Setter 省略...
}
java
/**
* Controller 中使用分组校验
*/
@RestController
@RequestMapping("/users")
public class UserController {
@PostMapping
public Result<Long> create(@Validated(UserRequest.CreateGroup.class) @RequestBody UserRequest request) {
Long userId = userService.create(request);
return Result.success("创建成功", userId);
}
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id,
@Validated(UserRequest.UpdateGroup.class) @RequestBody UserRequest request) {
userService.update(id, request);
return Result.success("更新成功", null);
}
}
4.2 自定义校验注解
java
import javax.validation.Constraint;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import javax.validation.Payload;
import java.lang.annotation.*;
/**
* 手机号校验注解
*/
@Documented
@Constraint(validatedBy = PhoneValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface Phone {
String message() default "手机号格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
/**
* 手机号校验器
*/
class PhoneValidator implements ConstraintValidator<Phone, String> {
private static final String PHONE_REGEX = "^1[3-9]\\d{9}$";
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isEmpty()) {
return true; // @NotBlank 负责判空
}
return value.matches(PHONE_REGEX);
}
}
java
/**
* 身份证号校验注解
*/
@Documented
@Constraint(validatedBy = IdCardValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface IdCard {
String message() default "身份证号格式不正确";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
class IdCardValidator implements ConstraintValidator<IdCard, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null || value.isEmpty()) {
return true;
}
// 简化版校验,实际应该用完整的身份证号校验算法
return value.matches("^\\d{17}[\\dXx]$");
}
}
4.3 校验错误信息国际化(i18n)
java
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
import java.util.Locale;
/**
* 国际化配置
*/
@Configuration
public class I18nConfig {
@Bean
public LocaleResolver localeResolver() {
AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
resolver.setDefaultLocale(Locale.CHINA);
return resolver;
}
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("messages"); // 加载 messages_zh_CN.properties、messages_en_US.properties
messageSource.setDefaultEncoding("UTF-8");
messageSource.setUseCodeAsDefaultMessage(true); // 找不到配置时返回 code 本身
return messageSource;
}
}
properties
# messages_zh_CN.properties
user.username.notBlank=用户名不能为空
user.email.invalid=邮箱格式不正确
user.phone.invalid=手机号格式不正确
properties
# messages_en_US.properties
user.username.notBlank=Username is required
user.email.invalid=Invalid email format
user.phone.invalid=Invalid phone number
java
/**
* 在全局异常处理中使用国际化消息
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidationException(MethodArgumentNotValidException e,
HttpServletRequest request,
Locale locale) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(error -> messageSource.getMessage(error, locale)) // 使用国际化消息
.collect(Collectors.joining("; "));
return Result.badRequest(message);
}
五、ResponseBodyAdvice 全局包装
5.1 自动包装 Controller 返回值
java
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
/**
* 全局响应体包装器
* 自动将 Controller 的返回值包装为 Result<T> 格式
*/
@RestControllerAdvice(basePackages = "com.example.controller")
public class GlobalResponseAdvice implements ResponseBodyAdvice<Object> {
private final ObjectMapper objectMapper;
public GlobalResponseAdvice(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 判断是否需要进行包装
*/
@Override
public boolean supports(MethodParameter returnType,
Class<? extends HttpMessageConverter<?>> converterType) {
// 如果方法或类上有 @IgnoreResponseWrapper 注解,跳过包装
if (returnType.hasMethodAnnotation(IgnoreResponseWrapper.class)) {
return false;
}
// 如果返回值已经是 Result 类型,不需要再包装
if (returnType.getParameterType().isAssignableFrom(Result.class)) {
return false;
}
// 如果返回值是 String 类型,需要特殊处理(后面说)
return true;
}
/**
* 执行包装逻辑
*/
@Override
public Object beforeBodyWrite(Object body,
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request,
ServerHttpResponse response) {
// 已经是 Result 类型,直接返回
if (body instanceof Result) {
return body;
}
// String 类型需要特殊处理,因为 StringHttpMessageConverter 会直接写字符串
if (body instanceof String) {
try {
Result<Object> result = Result.success(body);
return objectMapper.writeValueAsString(result);
} catch (JsonProcessingException e) {
throw new RuntimeException("响应序列化失败", e);
}
}
// 其他类型直接包装
return Result.success(body);
}
}
5.2 跳过包装的注解
java
import java.lang.annotation.*;
/**
* 标记该方法/类的返回值不需要被全局包装
* 适用于文件下载、特殊接口等场景
*/
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface IgnoreResponseWrapper {
}
5.3 使用示例
java
@RestController
@RequestMapping("/api")
public class DemoController {
// 自动包装为 Result.success(data)
@GetMapping("/user")
public User getUser() {
return new User("zhangsan", 20);
}
// 返回 Result,不会重复包装
@GetMapping("/custom")
public Result<String> customResponse() {
return Result.success("自定义响应");
}
// 跳过包装,直接返回原始内容(如下载接口)
@IgnoreResponseWrapper
@GetMapping("/download")
public byte[] download() {
return new byte[0]; // 文件内容
}
}
踩坑提醒 :ResponseBodyAdvice 这个坑我踩过------Swagger 的
/v3/api-docs接口也会被包装,导致 Swagger UI 打不开。解决方法是配置basePackages限定只包装业务接口,或者在supports()方法里排除 Swagger 相关路径。
六、踩坑指南
6.1 @ExceptionHandler 优先级问题
Spring 的异常处理是按声明顺序匹配的,第一个匹配的处理器就会被调用。
java
// 错误示例:Exception 放在最前面,后面的都不会被触发
@ExceptionHandler(Exception.class) // 这个会捕获所有异常!
public Result<Void> handleException(Exception e) { ... }
@ExceptionHandler(BusinessException.class) // 永远不会被触发
public Result<Void> handleBusinessException(BusinessException e) { ... }
正确做法:子类异常放前面,父类异常放最后。
java
@ExceptionHandler(BusinessException.class) // 1. 业务异常
@ExceptionHandler(ValidationException.class) // 2. 校验异常
@ExceptionHandler(RuntimeException.class) // 3. 运行时异常
@ExceptionHandler(Exception.class) // 4. 兜底异常
6.2 ResponseBodyAdvice 与 Swagger 冲突
Swagger 的 API 文档接口返回的是 JSON Schema,如果被 GlobalResponseAdvice 包装,Swagger UI 就无法解析。
解决方案:
java
@RestControllerAdvice(basePackages = "com.example.controller")
public class GlobalResponseAdvice implements ResponseBodyAdvice<Object> {
// 只处理业务包下的 Controller,排除 Swagger
}
或者在 supports() 里排除:
java
@Override
public boolean supports(MethodParameter returnType,
Class<? extends HttpMessageConverter<?>> converterType) {
String path = // 从 request 获取路径
if (path.startsWith("/swagger") || path.startsWith("/v3/api-docs")) {
return false;
}
// ...
}
6.3 异常处理中的循环依赖
这个坑比较隐蔽。如果你的 GlobalExceptionHandler 注入了某个 Service,而这个 Service 又间接依赖了 Web 层的 Bean,就可能导致循环依赖。
解决方案:
- 异常处理器里尽量不注入复杂依赖
- 如果必须注入,用
@Lazy延迟加载 - 或者把日志记录逻辑抽成一个独立的
ErrorLogService,只依赖底层组件
6.4 异步方法异常捕获不到
java
@Service
public class AsyncService {
@Async
public void asyncMethod() {
throw new BusinessException("异步任务异常"); // 这个异常不会被 @RestControllerAdvice 捕获!
}
}
原因 :@Async 会在新的线程中执行,而 @RestControllerAdvice 只能捕获请求线程中的异常。
解决方案:
java
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import java.lang.reflect.Method;
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new AsyncUncaughtExceptionHandler() {
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
// 统一处理异步方法的异常
if (ex instanceof BusinessException) {
log.error("[异步业务异常] method={}, message={}", method.getName(), ex.getMessage());
} else {
log.error("[异步系统异常] method={}", method.getName(), ex);
}
// 可以在这里发送告警通知
}
};
}
}
踩坑提醒 :异步方法的异常处理最容易被忽略。建议项目里统一配置
AsyncUncaughtExceptionHandler,别让异步异常"无声无息"地吞掉。
七、问题与解答
Q1:用了全局异常处理后,Controller 里还需要 try-catch 吗?
A:一般情况下不需要。全局异常处理已经覆盖了绝大部分场景。
但有一种情况例外:你需要在 catch 里做资源清理 或事务回滚时,可以写 try-finally 或 try-catch-rethrow:
java
@PostMapping("/transfer")
public Result<Void> transfer(@RequestBody TransferRequest request) {
try {
accountService.transfer(request);
return Result.success("转账成功");
} catch (BusinessException e) {
// 记录转账失败日志,然后重新抛出让全局处理器处理
log.warn("转账失败:{}", e.getMessage());
throw e;
}
}
记住原则:能抛就抛,让全局处理器统一处理。
Q2:ResponseBodyAdvice 会包装所有响应吗?包括 404 Not Found?
A :不会。ResponseBodyAdvice 只包装正常执行到 Controller 并返回的响应。
如果是 404(接口不存在)、405(方法不允许)这种由 Spring 直接返回的错误,不会经过 ResponseBodyAdvice。
要统一处理这些错误,需要配合 ErrorController:
java
@RestController
public class CustomErrorController implements ErrorController {
@RequestMapping("/error")
public Result<Void> handleError(HttpServletRequest request) {
Integer statusCode = (Integer) request.getAttribute("javax.servlet.error.status_code");
String message = (String) request.getAttribute("javax.servlet.error.message");
if (statusCode == 404) {
return Result.error(404, "请求的资源不存在");
}
if (statusCode == 405) {
return Result.error(405, "请求方法不允许");
}
return Result.error(statusCode, message);
}
}
Q3:全局异常处理怎么和 Feign 客户端的异常结合?
A :Feign 调用失败会抛出 FeignException,你可以在全局处理器里统一转换:
java
@ExceptionHandler(FeignException.class)
public Result<Void> handleFeignException(FeignException e, HttpServletRequest request) {
log.error("[服务调用异常] URI={}, service={}, status={}",
request.getRequestURI(),
e.request().url(),
e.status());
// 尝试解析下游服务的错误信息
String responseBody = e.contentUTF8();
if (responseBody != null && !responseBody.isEmpty()) {
try {
Result<?> result = objectMapper.readValue(responseBody, Result.class);
return Result.error(result.getCode(), "下游服务异常:" + result.getMessage());
} catch (Exception ignored) {
}
}
return Result.error("服务暂时不可用,请稍后重试");
}
八、面试高频考点汇总
考点 1:@ControllerAdvice 和 @RestControllerAdvice 有什么区别?
答案:
@ControllerAdvice是@Controller+@ExceptionHandler的组合,返回值需要配合@ResponseBody才会序列化为 JSON。@RestControllerAdvice等于@ControllerAdvice+@ResponseBody,返回值自动序列化为 JSON。- 在 RESTful 项目中,统一用
@RestControllerAdvice。
考点 2:@ExceptionHandler 的匹配规则是什么?
答案:
- 按声明顺序从上到下匹配,第一个匹配的处理器生效。
- 匹配时看抛出的异常类型是否是指定类型的子类。
- 因此要把子类异常放在前面 ,父类异常放在后面。
- 如果多个
@ExceptionHandler都能匹配,Spring 会选择最具体的那个(异常层次最近的)。
考点 3:ResponseBodyAdvice 和 HandlerInterceptor 有什么区别?
答案:
| 特性 | ResponseBodyAdvice | HandlerInterceptor |
|---|---|---|
| 作用时机 | Controller 返回之后,序列化之前 | 请求进入 Controller 之前 / 之后 |
| 主要用途 | 修改响应体内容(如统一包装) | 权限校验、日志记录、耗时统计 |
| 能否修改返回值 | 能 | 不能直接修改返回值 |
| 异常时是否执行 | 否(异常不走正常返回流程) | preHandle 一定执行,postHandle 异常时不执行 |
考点 4:如何设计一个支持国际化的异常处理系统?
答案:
- 异常类里不直接存中文消息,而是存消息 key ,如
error.user.notFound。 - 全局异常处理器里通过
MessageSource根据当前 Locale 解析具体消息。 - 前端通过
Accept-Language请求头传递语言偏好。 - 准备多份
messages_xx.properties资源文件。
java
// 异常里存 key
throw new BusinessException("error.user.notFound");
// 处理器里解析
String message = messageSource.getMessage(e.getMessage(), null, locale);
考点 5:全局异常处理在微服务架构中怎么设计?
答案:
- 网关层:统一处理 404、405、限流、鉴权失败等网关级错误。
- 业务服务层 :每个服务都有自己的
@RestControllerAdvice,处理业务异常和系统异常。 - Feign 调用 :封装
ErrorDecoder,把下游服务的错误响应转换为本地异常。 - 统一错误码规范 :全公司一套错误码规范,方便问题定位。比如
A01001表示 A 服务 01 模块的 001 号错误。
九、模拟面试官提问
场景题 1:设计一个完整的异常处理体系
面试官:从零开始设计一个项目的异常处理体系,你会怎么设计?
参考答案:
-
异常分层:
BusinessException:业务异常,用户可感知SystemException:系统异常,用户不可感知ThirdPartyException:第三方服务异常
-
统一响应体 :
Result<T>包含 code、message、data、traceId -
全局异常处理器 :
@RestControllerAdvice分类处理各种异常 -
参数校验 :
@Valid+ 自定义注解 + 分组校验 -
响应包装 :
ResponseBodyAdvice自动包装返回值 -
链路追踪:MDC + traceId,全链路日志可追踪
-
异步异常 :
AsyncUncaughtExceptionHandler处理异步任务异常
场景题 2:Controller 返回 String 时,ResponseBodyAdvice 包装失败怎么办?
面试官:有个接口返回 String,结果被包装成了 JSON 字符串的字符串,怎么解决?
参考答案:
- 原因是
StringHttpMessageConverter直接把 String 写入响应体,不会走 JSON 序列化。 - 解决方案:在
beforeBodyWrite里判断如果是 String 类型,手动用ObjectMapper序列化包装后的Result对象,再返回 String。
java
if (body instanceof String) {
return objectMapper.writeValueAsString(Result.success(body));
}
场景题 3:如何防止异常处理逻辑本身抛出异常?
面试官:如果全局异常处理器里又抛出了异常,会怎么样?
参考答案:
- Spring 会捕获异常处理器的异常,返回 500,但响应格式不受控。
- 防御措施:
- 异常处理器里的代码尽量简单,不要调用复杂逻辑。
- 日志打印用
try-catch保护,确保日志异常不会影响主流程。 - 关键操作(如发送告警)用异步线程执行,避免阻塞。
场景题 4:前后端分离项目中,异常信息怎么给前端?
面试官:有些错误需要给用户看,有些只需要给前端看,怎么区分?
参考答案:
message字段:给用户的友好提示,前端直接展示。- 如果需要给前端更详细的信息(但不展示给用户),可以扩展字段:
json
{
"code": 400,
"message": "提交失败,请检查输入",
"data": {
"fieldErrors": {
"email": "邮箱格式不正确",
"phone": "手机号已存在"
}
}
}
- 前端用
message做 Toast 提示,用data.fieldErrors做表单字段级错误展示。
场景题 5:生产环境出现异常,如何快速定位问题?
面试官:线上报了一个 500 错误,用户截图只有一个"系统繁忙",你怎么排查?
参考答案:
- 让用户提供响应头里的
traceId。 - 根据
traceId在日志系统(如 ELK)里搜索全链路日志。 - 查看异常堆栈,定位具体代码位置。
- 结合链路追踪(如 SkyWalking)查看调用链,确认是哪个服务/数据库/缓存出了问题。
- 如果是第三方服务问题,查看熔断器状态和降级日志。
十、互动话题
你在项目里做异常处理时,遇到过什么让人崩溃的问题?是 @ExceptionHandler 死活不生效?还是 Swagger 被包装后打不开?又或者是异步任务的异常无声无息地消失了?欢迎在评论区聊聊你的踩坑经历。