一次 Spring Boot 全局异常处理导致响应体出现两段 JSON 的排查记录
背景
项目使用的是 Spring Boot + Spring Security 架构,系统里有一套统一异常返回格式,例如:
{
"code": "xxx",
"message": "xxx"
}
正常情况下,接口异常时应该只返回这一段业务定义好的 JSON。
但这次遇到了一个很诡异的问题:某些异常发生时,接口响应体里会出现两段 JSON 内容。
第一段是我们自己统一异常处理器写出的:
{
"code": "xxx",
"message": "系统异常"
}
第二段则像是 Spring Boot 或 Servlet 默认错误处理生成的:
{
"timestamp": "...",
"status": 500,
"error": "Internal Server Error",
"path": "..."
}
最终响应体变成了类似这样:
{"code":"xxx","message":"系统异常"}
{"timestamp":"...","status":500,"error":"Internal Server Error","path":"/xxx"}
这显然不是合法 JSON,也会导致前端解析异常。
初步怀疑
一开始怀疑是统一异常处理器被执行了两次,或者是 @RestControllerAdvice 和 Filter 都在写响应,导致重复写出。
我们项目里有一个统一异常 Filter,内部大致逻辑是:
servletResponse.getWriter().write(message);
所以最初重点怀疑:
write(message)之后是否又继续抛出了异常;- 是否又继续执行了
filterChain.doFilter(...); - 是否调用了
response.sendError(...); - 是否没有
return,导致后续链路继续执行; - 是否
@RestControllerAdvice又处理了一遍异常。
第一步:确认是否进入了默认错误处理
我先在全局异常处理类 @RestControllerAdvice 的 @ExceptionHandler(Exception.class) 上打断点,发现确实会进入这里。
同时,在前面的 Filter 中观察到当前路径变成了:
/auth/error
并且请求类型是:
DispatcherType.ERROR
这说明请求不是普通的业务请求了,而是已经被容器进行了 ERROR 派发。
也就是说,原始请求发生异常后,Tomcat/Spring Boot 将请求转发到了错误处理路径 /auth/error。
第二步:确认默认四个字段是谁生成的
为了确认 {timestamp, status, error, path} 这几个字段是谁生成的,我继续在下面几个位置打断点:
org.springframework.boot.web.servlet.error.DefaultErrorAttributes#getErrorAttributes
以及:
org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController#error
最终确认,默认的错误字段确实是 Spring Boot 默认错误处理链路生成的。
也就是说,第二段 JSON 并不是我们自己的业务代码主动拼出来的,而是 Spring Boot 在处理 /auth/error 时组装出来的。
第三步:确认是谁转发到了 /auth/error
继续在 Tomcat 的转发逻辑上打断点:
org.apache.catalina.core.ApplicationDispatcher#forward
调用栈显示上一层是:
StandardHostValve.custom
然后继续追踪发现进入了:
StandardHostValve.throwable
这个信息非常关键。
它说明:
不是业务代码主动
forward("/auth/error"),也不是简单的状态码触发错误页,而是异常最终冒泡到了 Tomcat 容器层,Tomcat 认为这是一个未处理异常,于是触发了错误页机制。
因此链路变成了:
业务异常
↓
异常没有被最外层完全兜住
↓
Tomcat StandardHostValve.throwable
↓
StandardHostValve.custom
↓
forward 到 /auth/error
↓
Spring Boot DefaultErrorAttributes
↓
生成默认错误 JSON
第四步:确认原始异常
在 StandardHostValve.throwable 中查看异常内容,发现异常仍然是 Redis/Jedis 相关异常:
InvalidDataAccessApiUsageException:
READONLY ...
nested exception is redis.clients.jedis.exceptions.JedisDataException:
READONLY ...
这个异常表示应用写 Redis 时,打到了只读节点,也就是 Redis 从节点或故障切换后的旧主节点。
这个 Redis 问题是业务异常的根源,但它不是导致“双重 JSON”的直接原因。
导致双重 JSON 的直接原因是:
Redis 异常发生后,虽然我们的异常 Filter 写出了一段自定义 JSON,但异常最终仍然被 Tomcat 认为没有被彻底处理,于是又触发了
/auth/error错误转发。
第五步:排查 Filter 执行情况
为了确认 Filter 的执行情况,我在 ExceptionFilter 的入口和 catch 块里打印日志,包括:
System.identityHashCode(this)
request.getRequestURI()
request.getDispatcherType()
e.toString()
日志显示,同一个 Filter 实例执行了多次:
/login DispatcherType.REQUEST
/login DispatcherType.REQUEST catch 到 Redis READONLY 异常
/auth/error DispatcherType.ERROR
/auth/error DispatcherType.ERROR catch 到 NestedServletException
这说明并不是 Filter 有多个实例,而是同一个 Filter 经历了两轮派发:
-
第一轮是原始业务请求
/login,类型是REQUEST; -
第二轮是错误转发请求
/auth/error,类型是ERROR。也就是说,系统先处理了一次业务异常,然后又进入了默认错误处理链路,于是导致响应体里出现两段内容。
真正原因:ExceptionFilter 注册位置不对
最后发现,ExceptionFilter 原来是通过 Spring Security 配置进去的:
http.addFilterAfter(exceptionFilter, CorsFilter.class);
这个注册方式意味着:
ExceptionFilter 只是 Spring Security FilterChain 内部的一个 Filter,并不是整个 Servlet Filter 链最外层的兜底 Filter。
Spring Web 请求大致可以理解为:
Servlet Filter Chain
↓
Spring Security FilterChainProxy
↓
Security 内部 Filter 链
↓
DispatcherServlet
↓
Controller
原来的 ExceptionFilter 位置大概是:
Servlet Filter A
↓
Servlet Filter B
↓
Spring Security FilterChainProxy
↓
CorsFilter
↓
ExceptionFilter
↓
其他 Security Filter
↓
DispatcherServlet
这种情况下,ExceptionFilter 只能捕获它后面的异常,不能保证兜住整个 Servlet Filter 链。
如果异常在更外层继续冒泡,或者外层 Filter 在后置逻辑里继续处理异常,Tomcat 仍然可能捕获到异常,并触发默认错误页机制。
解决方案
最终解决方式是:不要再通过 Spring Security 的 addFilterAfter 注册这个全局异常 Filter,而是把它注册成 Servlet 容器级 Filter,并设置最高优先级。
原来的配置:
http.addFilterAfter(exceptionFilter, CorsFilter.class);
改为:
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<ExceptionFilter> exceptionFilterRegistration(
ExceptionFilter exceptionFilter) {
FilterRegistrationBean<ExceptionFilter> registrationBean =
new FilterRegistrationBean<>();
registrationBean.setFilter(exceptionFilter);
registrationBean.addUrlPatterns("/*");
registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
return registrationBean;
}
}
关键是这一行:
registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
它会让 ExceptionFilter 尽可能处在整个 Servlet Filter 链的最前面。
修改后的链路变成:
ExceptionFilter
↓
其他 Servlet Filter
↓
Spring Security FilterChainProxy
↓
Security 内部 Filter
↓
DispatcherServlet
↓
Controller
这样后面任何同步异常都会先回到最外层的 ExceptionFilter,由它统一捕获并写出响应。
为什么这个方案会生效
修改前,异常链路是:
/login 请求
↓
Redis/Jedis 抛 READONLY 异常
↓
Security 内部 ExceptionFilter 捕获并写出第一段 JSON
↓
异常仍然被外层链路或 Tomcat 认为未完全处理
↓
Tomcat StandardHostValve.throwable
↓
forward 到 /auth/error
↓
Spring Boot DefaultErrorAttributes 组装默认错误 JSON
↓
出现第二段 JSON
修改后,异常链路变成:
/login 请求
↓
最外层 ExceptionFilter
↓
后续 Filter / Security / DispatcherServlet / Controller
↓
Redis/Jedis 抛 READONLY 异常
↓
异常向外冒泡
↓
回到最外层 ExceptionFilter catch
↓
写出统一 JSON
↓
return
↓
异常不再到达 Tomcat
↓
不会进入 /auth/error
↓
不会生成第二段 JSON
所以本质上,Ordered.HIGHEST_PRECEDENCE 生效的原因是:
它把统一异常 Filter 放到了整个 Servlet 请求链路最外层,使异常在进入 Tomcat 默认错误页机制之前就被消费掉了。
补充:Filter 的执行模型
这次问题也顺便复习了 Java Filter 的执行模型。
Filter 并不是请求进来执行一遍,响应出去再执行一遍 doFilter()。
实际上,doFilter() 只调用一次,但方法内部通常分为三段:
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain chain) {
// 请求进入时执行
before();
chain.doFilter(request, response);
// 响应返回时执行
after();
}
多个 Filter 的执行顺序类似洋葱模型:
FilterA before
FilterB before
FilterC before
Controller
FilterC after
FilterB after
FilterA after
所以如果 Filter 的位置不够靠外,它只能捕获自己后面链路里的异常,不能捕获自己前面或外层 Filter 的异常。
这也是这次问题的关键。
GenericFilterBean 和 OncePerRequestFilter 的区别
这次使用的是 GenericFilterBean。
GenericFilterBean 只是 Spring 对 Servlet Filter 的基础封装,方便注入 Spring Bean 生命周期,但它不会自动避免重复执行,也不会自动跳过 ERROR 派发。
当发生错误转发时:
/login DispatcherType.REQUEST
/auth/error DispatcherType.ERROR
同一个 GenericFilterBean 可能会执行两次。
如果使用 OncePerRequestFilter,它会通过 request attribute 尽量保证同一个 request dispatch 中只执行一次。
但需要注意的是,OncePerRequestFilter 并不意味着整个 HTTP 请求生命周期绝对只执行一次。遇到 ERROR、ASYNC、FORWARD 等不同派发类型时,仍然要根据配置判断。
常见写法:
@Override
protected boolean shouldNotFilterErrorDispatch() {
return true;
}
用于避免 ERROR 派发时再次执行这个 Filter。
最终建议
对于全局异常兜底 Filter,建议:
-
不要放在 Spring Security 内部链路里;
-
使用
FilterRegistrationBean注册为 Servlet 级 Filter; -
设置
Ordered.HIGHEST_PRECEDENCE; -
catch 后明确
return; -
写响应时设置 content-type、status,并
flushBuffer(); -
避免 ERROR 派发时重复处理。
示例:
@Override
public void doFilter(ServletRequest servletRequest,
ServletResponse servletResponse,
FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
if (request.getDispatcherType() == DispatcherType.ERROR) {
return;
}
try {
filterChain.doFilter(servletRequest, servletResponse);
} catch (Exception e) {
if (response.isCommitted()) {
return;
}
response.resetBuffer();
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(buildErrorJson(e));
response.flushBuffer();
return;
}
}
总结
这次问题表面上是“接口返回了两段 JSON”,但本质是:
统一异常 Filter 的注册位置不够靠外,导致异常先被业务异常处理写出一段响应,又继续冒泡到 Tomcat 默认错误页机制,触发
/auth/error后再次生成错误响应。
最终通过:
registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE);
把 ExceptionFilter 提升为 Servlet 链路最外层兜底 Filter 后,异常在进入 Tomcat 默认错误处理前被统一捕获并消费,问题解决。
这次排查最大的收获是:
全局异常处理不只是写一个 catch 就够了,Filter 的注册层级和执行顺序同样决定了它到底能不能真正“全局兜底”。

浙公网安备 33010602011771号