资讯动态

Spring OncePerRequestFilter 核心原理与实战指南

发布时间:2026/9/9 18:01:46 来源:尧图企业网站定制
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分发并且你没有显式设置shouldNotFilterAsyncDispatchtrue那么过滤器直接跳过。简单说默认情况下异步分发回来不会再执行过滤逻辑这个设计对大多数业务是对的但如果你需要异步场景下做资源清理就要留个心眼。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(-, ); } // 放入 MDClogback 配置里可以用 %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排查线上问题的时候极其痛苦。我见过不止一个项目因为这个细节日志全部串号最后只能靠加时间过滤硬查。如果你想在日志里输出 traceIdlogback 的 pattern 里加一项%X{traceId}即可。如果项目里还有第三方调用、MQ 消费、异步任务建议把 traceId 一致性地传递下去不过那是另一个话题先把过滤器这层打通是第一步。2.3 注册方式对比WebFilter 与 FilterRegistrationBeanSpring 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 FilterRegistrationBeanTraceIdFilter traceIdFilterRegistration(TraceIdFilter filter) { FilterRegistrationBeanTraceIdFilter registration new FilterRegistrationBean(filter); registration.addUrlPatterns(/*); registration.setOrder(Ordered.HIGHEST_PRECEDENCE 10); registration.setName(traceIdFilter); return registration; } Bean public FilterRegistrationBeanAuthFilter authFilterRegistration(AuthFilter filter) { FilterRegistrationBeanAuthFilter 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字符编码过滤器确保请求和响应的编码正确最高优先级20CORS 过滤器跨域相关必须放在鉴权前面最高优先级30TraceId/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;charsetUTF-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;charsetUTF-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 我沉淀下来的一套过滤器链顺序参考过滤器多了之后顺序问题会逐渐变得致命。下面给出一套我在项目里常用且验证过较稳的顺序供你参考。优先级过滤器职责说明1CorsFilter跨域必须最先处理OPTIONS 预检请求不能走进业务鉴权2CharacterEncodingFilter编码和 CORS 谁先谁后影响不大但建议排在前面3TraceIdFilter日志上下文确保后续所有过滤器打印日志都有 traceId4AuthFilter登录鉴权基于 token 校验白名单放行5RequestLogFilter请求体/响应体日志如果要记录请求体建议在这个位置包一下 request6DispatcherServlet业务处理过滤器链终点这套顺序的一个核心原则是可以先失败的校验尽量放前面避免无意义的耗时操作而会读取请求体的包装过滤器要放在鉴权之后不然未登录的请求也读了一遍请求体浪费 I/O 和内存。另一点是 CORS 一定要放最顶层一旦鉴权过滤器先于 CORS 执行跨域预检请求铁定被拦前端报的错会让你排查到怀疑人生。当然实际顺序还是要结合你的业务做调整。比如某些项目希望统计所有请求的耗时哪怕鉴权失败也要统计那 AccessLogFilter 就要放在最外层包住其他所有过滤器。关键是想清楚每个过滤器之间的依赖关系和边界然后主动设计顺序而不是等着出现问题再补救。最后再分享一个实际项目里的小习惯每次新增过滤器之前我都是先问自己三个问题——它需要拦截哪些 URL它必须在哪个过滤器之后执行它会不会消费请求体这三个问题想清楚了再动手写代码基本能避开大部分过滤器相关的坑。OncePerRequestFilter确实是 Spring 提供的一个优秀基类但真正让过滤器好用的还是你对请求链路整体结构的理解。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价