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

所以最初重点怀疑:

  1. write(message) 之后是否又继续抛出了异常;
  2. 是否又继续执行了 filterChain.doFilter(...)
  3. 是否调用了 response.sendError(...)
  4. 是否没有 return,导致后续链路继续执行;
  5. 是否 @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 经历了两轮派发:

  1. 第一轮是原始业务请求 /login,类型是 REQUEST

  2. 第二轮是错误转发请求 /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 请求生命周期绝对只执行一次。遇到 ERRORASYNCFORWARD 等不同派发类型时,仍然要根据配置判断。

常见写法:

@Override
protected boolean shouldNotFilterErrorDispatch() {
    return true;
}

用于避免 ERROR 派发时再次执行这个 Filter。


最终建议

对于全局异常兜底 Filter,建议:

  1. 不要放在 Spring Security 内部链路里;

  2. 使用 FilterRegistrationBean 注册为 Servlet 级 Filter;

  3. 设置 Ordered.HIGHEST_PRECEDENCE

  4. catch 后明确 return

  5. 写响应时设置 content-type、status,并 flushBuffer()

  6. 避免 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 的注册层级和执行顺序同样决定了它到底能不能真正“全局兜底”。

posted @ 2026-06-30 16:53  逐东  阅读(8)  评论(0)    收藏  举报