1. 直接对标需求:OncePerRequestFilter 到底解决什么问题
1.1 原生 Filter 会在什么情况下被重复调用
很多刚接触 Spring Boot 的人,第一次写过滤器都是直接实现javax.servlet.Filter接口。写个doFilter,然后注册,看起来一切正常。直到某一天你发现:日志打印了两次,统计接口耗时的数字翻了一倍,或者某个过滤器里的“初始化”逻辑执行了两遍。排查半天才发现,同一个请求里doFilter被容器调用了多次。
为什么会这样?关键要理解 Servlet 容器里的“分发(dispatch)”这个概念。一次 HTTP 请求到达容器后,容器可能把同一个请求再派发给其他资源,常见的有forward、include,还有error分发。在 Servlet 3.0 之前的某些容器实现里,过滤器的执行范围比较粗,遇到这些内部转发时,过滤器链会被重新激活一次。即使现在的 Tomcat、Jetty 已经按规范处理,只要你的项目里用了转发、错误页跳转,或者某些代理层有特殊行为,过滤器被重复执行的情况依然可能出现。
更隐蔽的是异步请求。Servlet 3.0 之后支持异步处理,请求从容器线程交给业务线程,执行完再回来。在这个处理过程中,某些过滤器如果没做特殊处理,会在异步分发回来的时候又跑一遍。而OncePerRequestFilter这个名字本身就是在防这个最核心的坑。
1.2 OncePerRequestFilter 的一次性机制源码解读
OncePerRequestFilter是 Spring 提供的一个抽象基类,位于org.springframework.web.filter包下。它并没有做什么神奇的事,核心逻辑就一句话:在 request 上打一个标记,如果标记已经存在,就直接放行不再执行过滤逻辑。
源码里有一个内部属性alreadyFilteredAttributeName,默认生成规则是getClass().getName() + ".FILTERED"。doFilter方法里先判断request.getAttribute(alreadyFilteredAttributeName)是否为空,为空才往下执行doFilterInternal,并在开头setAttribute打上标记;如果不为空,就直接filterChain.doFilter继续走。这样不管容器把同一个请求分发几次,过滤器本体只会执行一次。
需要注意的是,这个“一次”是针对同一个 request 对象而言的。如果你在过滤器里手动 new 了一个新的请求包装器,或者容器在处理过程中创建了新的 request 实例(某些异步场景下有可能),那标记就会丢失,过滤器可能再次执行。所以有一种经验之谈是:判断是否重复执行不能只看名字,要理解标记挂在哪。
另外,OncePerRequestFilter还内置了对异步分发的处理。Spring 5 之后的源码里有一个skipDispatch方法,逻辑大概是:如果当前请求是ASYNC分发,并且你没有显式设置shouldNotFilterAsyncDispatch=true,那么过滤器直接跳过。简单说,默认情况下异步分发回来不会再执行过滤逻辑,这个设计对大多数业务是对的,但如果你需要异步场景下做资源清理,就要留个心眼。
1.3 它在 Spring 生态里的角色:既是工具类,也是设计约束
对于 Spring 项目来说,OncePerRequestFilter不只是一个避免重复执行的工具类,它更是一种规范。它强制你聚焦在doFilterInternal里写核心逻辑,把分发判断、异步跳过这些通用处理全部交给基类,这样你写的过滤器可维护性会好很多。
同时它天然在 Spring 容器管理内:你可以通过构造器注入、@Autowired注入 Service、Mapper、RedisTemplate,这一点比直接在容器配置里写原生 Filter 舒服太多。配合 Spring Boot 的自动装配,你可以把它声明成@Component或通过FilterRegistrationBean注册,灵活度很高。
从项目实践的视角看,OncePerRequestFilter通常承担的是“横切面”职责:日志追踪、登录态校验、CORS、请求体日志、响应包装、接口幂等性校验这类逻辑。它和 Spring AOP 有点像,但是作用的位置更靠前,能拦截到进入 Controller 之前的所有流量,包括静态资源,也包括那些 ControllerAdvice 根本管不到的异常边界。
2. 从零写一个 OncePerRequestFilter:核心方法与注册姿势
2.1 需要重写的方法只有三个
OncePerRequestFilter里你可以只关注三个方法。
第一个是doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain),这是唯一必须实现的方法,你的业务逻辑都写在这。千万注意,方法执行完不代表请求结束,你必须在合适的位置手动调用filterChain.doFilter(request, response),否则请求会直接被“截胡”,后面的过滤器、Servlet、Controller 永远等不到请求。如果你想在请求后做处理,可以在doFilter之后写finally,或者直接在filterChain.doFilter之后继续写代码。
第二个是shouldNotFilter(HttpServletRequest request),返回true表示这次请求不需要执行过滤器。这个方法很适合做白名单。比如登录校验过滤器,如果请求路径是/login、/register、/doc.html,就可以通过这个方法直接放行。注意它只能针对每个请求动态判断,不能替代全局的 URL 配置。
第三个是getFilterName(),默认返回 Bean 名称,主要用于日志输出。如果你用匿名内部类或者非 Spring 管理的类创建过滤器,这里可以自定义名字。
有一个容易忽略的点:doFilterInternal方法本身没有声明抛出特定异常,但运行时异常和非受检异常会直接向上抛。如果你没有在过滤器内部 try-catch,异常会跳过整个过滤器链,最终由容器的错误处理机制接管。ControllerAdvice 是接不到的,这个细节后面专门讲。
2.2 一个可以直接抄的 TraceId 过滤器
先放一个我在项目里经常用的模板,这个过滤器用来做 TraceId 全链路日志追踪,核心逻辑很简单但很实用。
@Component public class TraceIdFilter extends OncePerRequestFilter { private static final String TRACE_ID_HEADER = "X-Trace-Id"; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String traceId = request.getHeader(TRACE_ID_HEADER); if (traceId == null || traceId.trim().isEmpty()) { traceId = UUID.randomUUID().toString().replace("-", ""); } // 放入 MDC,logback 配置里可以用 %X{traceId} 输出 MDC.put("traceId", traceId); response.setHeader(TRACE_ID_HEADER, traceId); try { filterChain.doFilter(request, response); } finally { MDC.remove("traceId"); } } }这段代码看着简单,细节都在最后的finally里。MDC 是 logback 提供的线程上下文容器,本质上是一个ThreadLocal的封装。如果你不清理,线程池里的线程会被污染,下一条请求可能打出上一条的 traceId,排查线上问题的时候极其痛苦。我见过不止一个项目因为这个细节,日志全部串号,最后只能靠加时间过滤硬查。
如果你想在日志里输出 traceId,logback 的 pattern 里加一项%X{traceId}即可。如果项目里还有第三方调用、MQ 消费、异步任务,建议把 traceId 一致性地传递下去,不过那是另一个话题,先把过滤器这层打通是第一步。
2.3 注册方式对比:@WebFilter 与 FilterRegistrationBean
Spring Boot 项目里注册过滤器有三种常见姿势,很多新人容易混。
第一种是@Component + @WebFilter + @ServletComponentScan。@WebFilter是 Servlet 3.0 的注解,标在过滤器类上,然后在启动类上加@ServletComponentScan才能被扫描到。这种方式看起来最“原生”,但有很多局限:不好控制多个过滤器之间的执行顺序,很难通过代码向过滤器传递动态配置,而且一旦用了@WebFilter,它就不再受 Spring 容器对 Filter 的 AOP 代理管理,某些依赖注入的行为会变得奇怪。
第二种是@Component声明过滤器本身,然后靠@Order排序。这种方式简单,但你想自定义 URL 匹配模式就很麻烦,因为@Component注册的过滤器会默认拦截所有请求,你只能用shouldNotFilter做白名单。适合那种确定要拦截所有请求的过滤器,比如 TraceId。
第三种也是我强烈推荐的:用FilterRegistrationBean手动注册。
@Configuration public class FilterConfig { @Bean public FilterRegistrationBean<TraceIdFilter> traceIdFilterRegistration(TraceIdFilter filter) { FilterRegistrationBean<TraceIdFilter> registration = new FilterRegistrationBean<>(filter); registration.addUrlPatterns("/*"); registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10); registration.setName("traceIdFilter"); return registration; } @Bean public FilterRegistrationBean<AuthFilter> authFilterRegistration(AuthFilter filter) { FilterRegistrationBean<AuthFilter> registration = new FilterRegistrationBean<>(filter); registration.addUrlPatterns("/api/*"); registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 20); return registration; } }在这个配置里,setOrder决定了过滤器顺序,数字越小越先执行。URL 匹配灵活,可以通过addUrlPatterns控制只拦截/api/*,也可以通过setServletNames指定要过滤的 Servlet。这个方式最贴近生产,建议默认用它。
2.4 多个过滤器的顺序怎么控制才不出幺蛾子
过滤器顺序是 Spring Boot Web 项目里最容易出“灵异问题”的地方。举一个我实际遇到的例子:项目里有一个 CORS 过滤器,负责给跨域请求加响应头;同时又有一个登录校验过滤器,负责校验 token。两个过滤器顺序写反了,结果是跨域预检请求 OPTIONS 被登录校验拦截了,前端报跨域错误,后端的日志里全是 401。
这里的关键是理解 Filter 的执行顺序和 FilterChain 的关系。过滤器链是按注册顺序正向执行doFilter,但filterChain.doFilter之后的代码是按逆序执行的。也就是说,最外层过滤器先执行前置逻辑,最后执行后置逻辑。如果你希望某个过滤器在逻辑上“包住”其他过滤器,它的 order 要更小(更靠前)。
下面是我个人比较推荐的一套顺序参考:
| Order | 过滤器类型 | 说明 |
|---|---|---|
| 最高优先级+10 | 字符编码过滤器 | 确保请求和响应的编码正确 |
| 最高优先级+20 | CORS 过滤器 | 跨域相关,必须放在鉴权前面 |
| 最高优先级+30 | TraceId/Session 过滤器 | 先建立上下文 |
| 最高优先级+40 | 登录/权限过滤器 | 校验请求身份 |
| 正常优先级 | 业务日志/包装过滤器 | 处理请求体日志、响应包装 |
这个顺序不是绝对标准,但原则很清楚:基础设置在前,上下文注入次之,鉴权再后,最后才是业务相关。如果你把鉴权放在 CORS 前面,预检请求会被拦截;如果你把 TraceId 放在最外层,后面所有过滤器打印日志都能带上 traceId。想清楚这层逻辑,顺序基本不会出大问题。
3. 三个高频落地场景:日志链路、登录校验、耗时统计
3.1 场景一:TraceId 全链路日志追踪
先说日志链路。单机时代排查问题很简单,打开日志翻一翻就行。但现代项目基本都是多服务、多实例,一次用户请求会经过网关、业务服务、数据库、Redis,还可能发起第三方 HTTP 调用。如果每个日志都没有一个共同标识,你根本没法把一条请求的日志串起来看。
TraceId 过滤器的目标就是在请求入口生成一个全局唯一 ID,并让它贯穿整个请求生命周期。前面那个示例已经给出了核心代码,这里补充几个细节。
第一,优先透传上游的 TraceId。如果网关已经生成了X-Trace-Id,下游服务直接取用,不要重新生成,否则全链路追踪就断了。第二,响应头也要带上 TraceId,这样前端排查问题或者用户反馈时,能直接把这串 ID 提供给你,你拿日志系统一搜就能定位。第三,MDC 的清理一定要放在finally里,不要放 try 块末尾,否则异常路径会漏清理。
在此基础上可以做扩展:在过滤器里把请求方法、URI、耗时统一打一条 access log,效果等同于 Nginx 的 access log,但能匹配到业务上下文。日志格式建议包含时间、traceId、方法、URI、状态码、耗时、来源 IP。这样排障时先看过滤器日志定位链路,再进业务日志看具体细节,效率会高很多。
3.2 场景二:登录态校验与白名单放行
登录态校验是OncePerRequestFilter最常见的业务场景。实现思路是:从请求头或 Cookie 中取出 token,调用认证服务解析,成功就把用户信息写入 ThreadLocal 或请求属性,失败直接返回 401。
一个比较标准的写法是这样:
@Component public class AuthFilter extends OncePerRequestFilter { private final TokenService tokenService; public AuthFilter(TokenService tokenService) { this.tokenService = tokenService; } @Override protected boolean shouldNotFilter(HttpServletRequest request) { String uri = request.getRequestURI(); return uri.startsWith("/login") || uri.startsWith("/register") || uri.startsWith("/doc.html") || "/favicon.ico".equals(uri); } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = request.getHeader("Authorization"); if (token == null || token.trim().isEmpty()) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录\"}"); return; } try { UserInfo user = tokenService.parseToken(token); request.setAttribute("currentUser", user); filterChain.doFilter(request, response); } catch (TokenExpiredException e) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write("{\"code\":401,\"message\":\"登录已过期\"}"); } } }这里有两个容易踩的坑。第一,shouldNotFilter里的判断条件是“请求不需要认证”,不是“请求需要认证”,写反了会导致所有请求都被放行。第二,白名单判断要留意路径前缀匹配的边界,比如你写了startsWith("/api/user")来放行登录接口,可能会把/api/user/list也放行掉。在白名单上宁可用精确路径,或者在注册 FilterRegistrationBean 时就直接通过 URL 模式控制范围。
如果是 Spring Security 项目,这类过滤器通常会被整合进 Spring Security 的过滤器链里,通过SecurityFilterChain配置。OnecePerRequestFilter 在这个体系里扮演的角色仍然一致,区别是白名单和异常响应策略交给 Security 统一管理。
3.3 场景三:统一耗时统计与安全响应头
第三种场景偏向运维和性能观测:在过滤器里记录每次请求的耗时,顺手把一些安全响应头统一加上去。
耗时统计的核心思路非常简单:进入时记录System.currentTimeMillis()(或者用System.nanoTime()),在finally里算差值。重点是要把耗时的起点放在最前面,把 end 放在filterChain.doFilter之后,才能涵盖真正的业务执行时间。
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { long start = System.currentTimeMillis(); try { filterChain.doFilter(request, response); } finally { long cost = System.currentTimeMillis() - start; if (log.isInfoEnabled()) { log.info("[accessLog] uri={}, method={}, cost={}ms, status={}", request.getRequestURI(), request.getMethod(), cost, response.getStatus()); } } }安全响应头这块,比如X-Content-Type-Options: nosniff、X-Frame-Options: DENY、Cache-Control: no-store,在网关层加也行,但如果网关不可控,放在过滤器里是兜底方案。注意一点:有些响应头需要在 Controller 写响应之后才能完整设置,所以放在finally里用response.setHeader会比较保险,只要响应还没有提交,就能设置成功。
如果响应已经提交(比如流式输出、大文件下载),再setHeader会抛IllegalStateException。解决方式是在过滤器中包装一个HttpServletResponseWrapper,拦截setHeader调用,或者提前设置。具体看项目需求,一般小项目在finally里设置已经够用。
4. 过滤器抛出异常,ControllerAdvice 为什么接不住
4.1 根因:过滤器根本不在 DispatcherServlet 的管辖范围
这个问题的搜索热度非常高,我单独拿出一章说。现象是:OncePerRequestFilter里抛了一个业务异常,结果@RestControllerAdvice里的@ExceptionHandler完全没有反应,请求要么返回 500,要么返回一个容器默认错误页。
要理解这个现象,得先搞清楚 Servlet 处理请求的链路。在 Spring Boot 里,请求先进入容器,容器按顺序执行过滤器链,过滤器链的末端才是DispatcherServlet。DispatcherServlet完成两件事:根据 URL 找到对应的 Controller 方法并执行,以及当 Controller 抛出异常时,用HandlerExceptionResolver解析异常,把结果封装成响应。
@ControllerAdvice的异常处理器本质上是DispatcherServlet内部的机制。它只能捕获DispatcherServlet执行 Controller 方法时抛出的异常。而过滤器的执行发生在DispatcherServlet之前,它抛出的异常根本进不了DispatcherServlet的处理流程,自然也就轮不到@ControllerAdvice来接管。
所以“过滤器异常 ControllerAdvice 捕捉不到”不是配置问题,是架构分层问题。理解这一点之后,解决方案就很清晰了。
4.2 方案一:在过滤器内部直接捕获并封装响应
最直接可靠的方案是过滤器内部自己 try-catch,在异常发生处直接把响应写出去。因为过滤器是异常发生的最外层,你有 request 和 response 的完整控制权。
具体写法可以参考登录校验过滤器里的 catch 块:设置状态码、设置 Content-Type、输出 JSON。如果是统一异常结构,建议在过滤器里封装一个简单的 Result 对象,用 Jackson 序列化后再写出去,不要手写 JSON 字符串,容易写出语法错误。
try { filterChain.doFilter(request, response); } catch (BusinessException e) { response.setStatus(e.getStatus().value()); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(JsonUtils.toJson(Result.failure(e.getCode(), e.getMessage()))); }这里有个细节:如果在 catch 里写响应,通常不会再调用filterChain.doFilter,因为异常已经发生,继续调用毫无意义。但如果你在finally里有日志记录、MDC 清理,这些还是要执行。
有一种更优雅的做法是自定义一个统一异常处理过滤器,专门放在最外层,内部 try-catch 所有异常,然后把异常转成标准响应。这样业务过滤器可以专注于业务逻辑,不需要每个过滤器都写一遍 catch。不过要注意,这个统一异常过滤器只能捕获它后面执行的过滤器以及 DispatcherServlet 里抛出的异常,它自己之前的异常管不了,所以它必须放在过滤器链的最外层,order 最小。
4.3 方案二:联动 ErrorController / 错误页
如果你希望走 Spring Boot 统一的错误处理机制,可以不让过滤器内部吞异常,而是把异常继续向上抛。容器收到未捕获异常后,会触发错误分发(error dispatch),转发到/error端点,最终由BasicErrorController或者你自定义的ErrorController处理。
这种方式的好处是异常处理逻辑可以复用现有的一套;坏处是如果你依赖@ControllerAdvice,依然接不到。你需要额外实现一个ErrorController,在它的 error 方法里解析异常信息并输出标准化响应。实现起来代码量和维护成本都不低,所以我个人更建议在过滤器入口做兜底,把“最后一道防线”这层责任明确下来。
实现 ErrorController 的示例:
@RestController public class CustomErrorController implements ErrorController { @RequestMapping("/error") public Result handleError(HttpServletRequest request) { Integer status = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE); // 从 request 里取异常信息 return Result.failure(status, "系统繁忙,请稍后重试"); } }4.4 关键注意:finally 里做日志与 MDC 清理
最后一个容易忽略的点:过滤器异常最终不管是被 catch 还是抛给容器,你如果想要可靠的日志,一定要把日志记录放在finally块里。因为异常路径上的日志输出如果放在 catch 里,一旦异常类型不对,连 catch 都不进去,日志就丢了。
一个健壮的模式是:
long start = System.currentTimeMillis(); try { filterChain.doFilter(request, response); } finally { // 无论是否异常,都会执行到这里 long cost = System.currentTimeMillis() - start; log.info("request finished, uri={}, cost={}ms, status={}", request.getRequestURI(), cost, response.getStatus()); MDC.remove("traceId"); }如果这个过滤器是全局第一个,异常发生时还没有进入任何业务代码,你至少能得到一条 uri 和耗时日志。配合前面 TraceId 过滤器,这条日志还带 traceId,排障体验会好很多。这是我做线上问题排查时最依赖的一条兜底日志。
5. 进阶细节:Async 分发、RequestBody 包装与顺序设计
5.1 异步请求下过滤器还会被二次执行吗
前面提到OncePerRequestFilter默认会跳过 ASYNC 分发,这里把细节补完整。
在 Servlet 3.0 的异步处理模型里,请求可能在业务线程中执行,执行完再返回到容器。整个过程对过滤器来说,可能经历两次 dispatch:第一次是请求进入的 INITIAL dispatch,第二次是异步结果返回后的 ASYNC dispatch。如果你没有做特殊包装,原生 Filter 会在两次 dispatch 中都执行一遍,这也是一种“过滤器重复执行”的场景。
OncePerRequestFilter的skipDispatch方法专门处理了这个逻辑。它判断当前请求如果处于 ASYNC 分发状态,并且shouldNotFilterAsyncDispatch默认是 false,就跳过整段过滤逻辑。换句话说,默认情况下,异步分发回来不会再执行你的doFilterInternal。
但要注意:如果你在异步处理中需要清理 ThreadLocal、释放资源、关闭连接,靠这个默认行为可能不够。因为异步执行时,原线程可能已经被回收,MDC 里的 traceId 在线程切换后未必能带到新线程。这种情况需要配置TaskDecorator或者手动传递上下文,属于异步链路治理的范畴,和过滤器本身关系不大,但排查问题时容易混在一起。
5.2 读请求体要小心:别把 Controller 的入参读没了
在过滤器里读取请求体,比如做请求体日志、签名校验,很容易遇到一个经典问题:request.getInputStream()或getReader()读了一次之后,请求体就被消费掉了,后续 Controller 方法用@RequestBody再读时拿到的是空流,接口直接报错。
解决方式是包装请求:用HttpServletRequestWrapper重写getInputStream()和getReader(),让数据可以被反复读取。Spring 已经提供了现成的工具类ContentCachingRequestWrapper,但这东西有一个坑:它默认不会主动缓存请求体,只有当你调用了getInputStream().read()之后才缓存。实际使用时,常常需要先主动读一遍输入流,把内容塞进缓存,再交给后续流程用。
一个比较实用的做法是使用 Spring 提供的ContentCachingRequestWrapper配合一个“预读”步骤:
ContentCachingRequestWrapper cachedRequest = new ContentCachingRequestWrapper(request); // 主动读取请求体,触发缓存 cachedRequest.getInputStream().readAllBytes(); filterChain.doFilter(cachedRequest, response); // 请求处理完后,从缓存里取内容 byte[] body = cachedRequest.getContentAsByteArray();注意readAllBytes会读到底,这个小动作本身不会影响后续流程,因为包装后的getInputStream会返回一个可以重复读取的流。响应侧同理,可以用ContentCachingResponseWrapper包装,在请求结束后拿getContentAsByteArray()读取响应体,用于日志输出。但这些包装类会带来额外的内存消耗,大请求体项目要评估是否分片读取,别把内存直接打爆。
5.3 我沉淀下来的一套过滤器链顺序参考
过滤器多了之后,顺序问题会逐渐变得致命。下面给出一套我在项目里常用且验证过较稳的顺序,供你参考。
| 优先级 | 过滤器 | 职责 | 说明 |
|---|---|---|---|
| 1 | CorsFilter | 跨域 | 必须最先处理,OPTIONS 预检请求不能走进业务鉴权 |
| 2 | CharacterEncodingFilter | 编码 | 和 CORS 谁先谁后影响不大,但建议排在前面 |
| 3 | TraceIdFilter | 日志上下文 | 确保后续所有过滤器打印日志都有 traceId |
| 4 | AuthFilter | 登录鉴权 | 基于 token 校验,白名单放行 |
| 5 | RequestLogFilter | 请求体/响应体日志 | 如果要记录请求体,建议在这个位置包一下 request |
| 6 | DispatcherServlet | 业务处理 | 过滤器链终点 |
这套顺序的一个核心原则是:可以先失败的校验尽量放前面,避免无意义的耗时操作;而会读取请求体的包装过滤器要放在鉴权之后,不然未登录的请求也读了一遍请求体,浪费 I/O 和内存。另一点是 CORS 一定要放最顶层,一旦鉴权过滤器先于 CORS 执行,跨域预检请求铁定被拦,前端报的错会让你排查到怀疑人生。
当然,实际顺序还是要结合你的业务做调整。比如某些项目希望统计所有请求的耗时,哪怕鉴权失败也要统计,那 AccessLogFilter 就要放在最外层,包住其他所有过滤器。关键是想清楚每个过滤器之间的依赖关系和边界,然后主动设计顺序,而不是等着出现问题再补救。
最后再分享一个实际项目里的小习惯:每次新增过滤器之前,我都是先问自己三个问题——它需要拦截哪些 URL?它必须在哪个过滤器之后执行?它会不会消费请求体?这三个问题想清楚了再动手写代码,基本能避开大部分过滤器相关的坑。OncePerRequestFilter确实是 Spring 提供的一个优秀基类,但真正让过滤器好用的,还是你对请求链路整体结构的理解。