资讯详情

OncePerRequestFilter 使用指南:解决Spring过滤器重复执行问题

📅 2026/10/11 12:27:45 | 华诺云谱 👁 阅读
OncePerRequestFilter 使用指南:解决Spring过滤器重复执行问题
如果你在 Spring 项目里写过一个普通的过滤器并且恰好用过内部转发forward这类能力大概率撞见过一个怪现象浏览器里明明只发了一次请求过滤器里的日志却打了两遍甚至三遍。我第一次遇到时以为是容器的重试机制结果排查半天才发现问题不在请求发送端而是过滤器本身被调用了多次。这个问题的标准解法其实早就有现成方案OncePerRequestFilter。这个名字翻译过来就是“每次请求只过滤一次”。它是我在 Spring 项目里兜底横切逻辑时最常用的基类没有之一。这篇内容我会把原理、核心 API、实际场景和几个容易翻车的细节全部摊开讲适合正在被过滤器重复执行困扰、或者想系统搞懂这个类怎么用的同学。1. 一个过滤器打印三遍日志先理解请求内部流转1.1 日志重复的现场还原先看一个最简单的普通过滤器Component public class PlainFilter implements Filter { private static final Logger log LoggerFactory.getLogger(PlainFilter.class); Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest (HttpServletRequest) request; log.info(PlainFilter 开始处理: {}, httpRequest.getRequestURI()); chain.doFilter(request, response); log.info(PlainFilter 结束处理: {}, httpRequest.getRequestURI()); } }正常情况下一次 /hello 请求进来日志只有一对“开始/结束”。但如果你在某个 Controller 里做了这样的操作GetMapping(/dispatch) public String dispatch(HttpServletRequest request) throws ServletException, IOException { RequestDispatcher dispatcher request.getRequestDispatcher(/target); dispatcher.forward(request, response); return null; }访问 /dispatch 时你会在日志里看到两对“开始/结束”第二次打印的 URI 变成了 /target。这里是RequestDispatcher.forward造成的内部转发整个流程仍然是一次 HTTP 请求但容器内部把请求移交给了另一个资源去处理。1.2 为什么普通 Filter 拦不住这种重复很多人第一次遇到时会陷入一个误区以为过滤器被创建了多个实例。其实并不是。Servlet 容器中的过滤器对象默认是单例同一个实例会被多个线程、多个请求共用。真正的原因是Servlet 容器对过滤器的匹配是按“被请求的资源”来做的。请求第一次到达 /dispatch容器匹配出一套过滤链内部转发到 /target 后容器又根据 /target 重新匹配出一套过滤链。虽然过滤器实例还是同一个但doFilter方法被调用了两次。如果这个请求里再叠加include或者异步分派AsyncDispatch触发次数会更多。日志重复只是最表面的现象藏在后面的是一些更麻烦的问题请求计数翻倍、埋点数据不准确、耗时统计被重复计算、某些非幂等的初始化逻辑执行了多次。1.3 同类问题的影响面这个影响面其实比想象中大。不只是forward会触发下面这些场景同样会让普通 Filter 重复执行Controller 内部通过RequestDispatcher.forward做资源迁移JSP 或模板引擎使用% include %包含公共页面片段使用 Servlet 3.1 的 AsyncContext 做异步处理请求会在异步线程中再次经过过滤链某些网关或安全组件内部的 Error Dispatch 处理这些场景的共同特征是网络请求只有一个但是 Servlet 容器的“分发路径”不止一条。普通 Filter 感知不到它们属于同一个请求周期只会机械地对每条分发路径执行一遍。2. OncePerRequestFilter 的防重复原理请求标记法2.1 一张“已处理”凭证按请求记录执行状态OncePerRequestFilter的解决思路非常朴素在同一个请求周期内用一个标记记录“这个过滤器已经跑过了”下次再被触发时直接跳过。这个标记不是放在 Spring 容器的某个 Map 里而是直接放在HttpServletRequest的 attribute 中。整个执行流程可以概括成四步请求进入doFilter方法这个方法是final的不允许子类重写检查request.getAttribute(某个固定的标记名)是否存在如果不存在说明当前请求还没被这个过滤器处理过那就在执行过滤逻辑前把标记设置进去然后执行真正的业务逻辑如果已经存在说明要么是内部转发要么是异步分派阶段直接放行不重复处理标记名由alreadyFilteredAttributeName()方法决定默认是“类的全限定名 .FILTERED”。比如某个过滤器类全名是com.example.LogFilter那么标记就是com.example.LogFilter.FILTERED。不同类名的过滤器之间互不影响。2.2 标记放在请求上而不是实例字段上有讲究为什么不把这个标记存在过滤器的实例字段里比如加一个boolean executed第一次执行时改成 true后面直接跳过原因在于过滤器实例是单例的而请求是并发的。如果多个请求同时打到同一个过滤器实例上A 请求和 B 请求会互相污染executed字段。A 请求还在处理中B 请求进来发现executed true就把逻辑跳过了这绝对是灾难。而HttpServletRequest对象天然是每个请求独立的。它从请求进入容器时创建到请求结束被销毁生命周期和一次完整请求严格对齐。标记放这里既不会串数据也不用担心回收问题——请求结束attribute 随之销毁。这种设计和编程里的“上下文传参”思路很一致动态信息跟着调用链走不要随手放进一个被多方共享的静态位置。2.3 与 Filter 接口的行为差异对比对比点Filter 接口OncePerRequestFilter类型接口抽象类需要实现的方法doFilterdoFilterInternal内部转发时会重复执行只执行一次异步分派时完全自己控制默认会再次执行可用开关调整标记的位置无内置机制请求 attribute简单说一旦改成继承OncePerRequestFilter防重复这件事就交给了父类我只需要关心业务本身。3. 核心 API 拆解doFilterInternal 与配套扩展点3.1 请求日志过滤器第一行业务代码先看一个最常用、也最典型的例子。Component public class RequestLogFilter extends OncePerRequestFilter { private static final Logger log LoggerFactory.getLogger(RequestLogFilter.class); 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; log.info(请求处理完成, URI {}, 耗时 {} ms, request.getRequestURI(), cost); } } }注意几个点入参直接就是HttpServletRequest和HttpServletResponse不用自己去做强转filterChain.doFilter(request, response)一定要调用否则后面的过滤器和目标接口都不会执行所以我把记录耗时放在finally里这样即使下游抛了异常耗时的日志也能打出来3.2 shouldNotFilterURL 白名单的正确打开方式有时候我只想拦截一部分接口。比如登录态校验过滤器注册接口、登录接口、静态资源应该放行。很多人的第一直觉是在doFilterInternal里写一大堆if (uri.startsWith(...))然后决定放行或拦截。但OncePerRequestFilter给了一个更干净的扩展点shouldNotFilter(HttpServletRequest)。返回 true表示当前请求不需要走过滤逻辑直接放行。Component public class LoginCheckFilter extends OncePerRequestFilter { Override protected boolean shouldNotFilter(HttpServletRequest request) { String uri request.getRequestURI(); return uri.startsWith(/api/auth/login) || uri.startsWith(/api/auth/register) || uri.startsWith(/static/); } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token request.getHeader(X-Auth-Token); if (token null || token.isEmpty()) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录\}); return; } filterChain.doFilter(request, response); } }我把白名单判断从主逻辑里拆出来好处是职责清晰。以后新增放行接口只需要在shouldNotFilter()里加一个条件不用动校验逻辑。3.3 异步分派场景下的行为开关还有一个可能被忽略的扩展点shouldNotFilterAsyncDispatch()。它决定异步请求里“第二次分派”时过滤器要不要再次执行。默认情况下这个方法返回 false。也就是说如果项目里用了 Spring 的异步接口或者 AsyncContext请求进入异步处理阶段后doFilterInternal会被再次触发。对一个只做日志记录的过滤器重复一次回去影响不大但如果是计数、校验、加锁这类非幂等操作就可能出问题。所以我的习惯是凡是面向异步接口的业务过滤器先想清楚这次重复执行是否有副作用如果没有幂等保障直接在子类里覆盖Override protected boolean shouldNotFilterAsyncDispatch() { return true; }这个开关值得记下来很多人踩坑就踩在默认行为上。4. 三个真实场景的落地写法4.1 场景一全链路请求日志与耗时统计日志和耗时统计是拦在最前面的过滤器。如果统计逻辑写在普通 Filter 里一次 forward 就可能多出几行假日志后续写监控脚本的人会被误导。用OncePerRequestFilter后一条请求不论内部如何流转都只留一条完整日志。我通常还会顺手把客户端 IP、请求方法也带上Component public class AccessLogFilter extends OncePerRequestFilter { private static final Logger log LoggerFactory.getLogger(AccessLogFilter.class); 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; log.info([ACCESS] {} {} from {} cost {} ms, request.getMethod(), request.getRequestURI(), request.getRemoteAddr(), cost); } } }这样做了之后监控面板上的接口耗时才算真实可信。4.2 场景二登录态校验接口放行逻辑别再写进 if除了前面那个登录校验示例实际项目里还有一个更常见的需求从请求头取出用户信息放到当前请求上下文里供后续业务使用。Component public class UserContextFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String userId request.getHeader(X-User-Id); UserContext.set(userId); try { filterChain.doFilter(request, response); } finally { UserContext.clear(); } } }这里顺便提醒UserContext如果是基于ThreadLocal实现的必须在finally里调用 clear。因为线程池中的线程会被复用不清空的话下一次业务线程拿到的还是上一个请求的用户信息这种数据串线问题隐蔽且难查。4.3 场景三为业务安全组件提供幂等的执行入口我遇到过一类需求在请求入口统一做一次数据签名校验校验通过后放行但签名校验过程中可能会把请求转发到内部错误页。如果不做防重复处理转发到错误页时签名校验又会来一遍用户会看到两次 401 响应。这类场景特别适合OncePerRequestFilter。因为同一请求内的转发路径不管怎么走校验逻辑只执行一次省掉了“重复拦截”的麻烦。需要说明的是这只是“单请求内幂等”。如果业务目标是防止接口被同一个用户重复提交那单靠它是远远不够的还得靠 Redis 里的幂等 token 等机制。这个类只解决请求生命周期内的问题不解决跨请求的问题。5. 注册与执行顺序组件注解和过滤器链的顺序到底听谁的5.1 两种常见注册方式的区别Spring Boot 项目里注册自定义过滤器有两条路线。第一条直接在过滤器类上标Component。这样做最省事Spring Boot 会自动把它注册到过滤链里并匹配所有 URL。问题是不太容易精确控制它和别的过滤器之间的先后顺序。第二条用FilterRegistrationBean手动注册Configuration public class FilterConfig { Bean public FilterRegistrationBeanRequestLogFilter requestLogFilterRegistration(RequestLogFilter filter) { FilterRegistrationBeanRequestLogFilter registration new FilterRegistrationBean(filter); registration.addUrlPatterns(/*); registration.setOrder(1); return registration; } Bean public FilterRegistrationBeanLoginCheckFilter loginCheckFilterRegistration(LoginCheckFilter filter) { FilterRegistrationBeanLoginCheckFilter registration new FilterRegistrationBean(filter); registration.addUrlPatterns(/api/*); registration.setOrder(2); return registration; } }注意既然用FilterRegistrationBean注册了过滤器类上的Component就可以去掉否则会注册两次导致过滤逻辑执行两遍。5.2 为什么 Order 在过滤器上经常不生效这是个非常容易踩的坑我在过滤器类上标了Component和Order(1)满心期待它排在前面执行结果运行起来发现顺序没变化。原因在于 Servlet 容器对过滤器的排序规则里Order注解并不能直接被识别为过滤器顺序。Spring Boot 只会识别FilterRegistrationBean的setOrder()或者实现Ordered接口的过滤器 bean。所以我现在的规范很简单需要控制顺序的过滤器一律用FilterRegistrationBean注册顺序和是否生效一目了然。纯粹的自动注册只适合那种顺序无关紧要的过滤器。6. 容易翻车的细节与排查链路6.1 异步接口里过滤器为什么又跑了一次现象接口用了异步返回日志里同一个过滤器的“开始处理”出现了两次。排查思路先确认接口走的是异步 Servlet 链路然后在doFilterInternal里打印request.isAsyncStarted()和当前线程名。如果第一次执行是在 Tomcat 工作线程第二次执行是在异步线程基本可以确定是异步分派触发了重复执行。根因是shouldNotFilterAsyncDispatch()默认返回 false。解决办法前面已经讲过不需要异步阶段重复执行的过滤器直接返回 true。这一步想清楚了之后你会发现它其实不是 bug而是框架给二次分发留的开关只是很多场景下我们用不上第二跳。6.2 过滤器顺序错乱时如何通过日志定位现象请求日志过滤器没有最先执行反而在业务代码之后才打日志。排查思路打印出所有已注册过滤器的清单。有一个很土但有效的方法在每个过滤器的doFilterInternal第一行打印当前类和顺序值然后看启动日志里 Spring Boot 输出的FilterRegistrationBean映射顺序。定位之后基本都能追到同一个根因有的过滤器用Component自动注册有的用FilterRegistrationBean两套方式混用时顺序就会变得很不直观。6.3 同一个请求经过多个过滤器时各自独立打标记这是很多人的另一个误区以为 A 过滤器执行过之后B 过滤器就不用执行了。不对。OncePerRequestFilter防的是“同一个过滤器在同一个请求里重复执行”而不是“同一个请求只执行一个过滤器”。每个过滤器子类都有自己的标记名A 过滤器执行完了B 过滤器检查自己的标记发现不存在照常执行。理解这一点很重要。所以不要把跨过滤器的共享逻辑寄托在它的防重复机制上需要共享的标记请自己放到 request attribute 里。6.4 实例字段保存状态引发的数据串线现象不是必现但一旦出现就是线上事故级别。比如某个过滤器里写了一个private String currentUri每进来一个请求就重新赋值然后下游方法读取这个字段。在一次请求里看起来没问题但在并发请求下A 请求刚设置完B 请求就把字段改了A 请求后面对这个字段的读取已经变成了 B 的值。OncePerRequestFilter实例是单例的它只为 “是否已过滤”这个状态提供了安全方案所有自定义业务状态都应该放进 request attribute、方法局部变量或 ThreadLocal绝不能直接写在实例字段里。这是我反复强调的一点也是每个过滤器基类使用者的共同教训。7. Filter、OncePerRequestFilter、Interceptor 到底怎么选7.1 三个维度的横向比较维度FilterOncePerRequestFilterHandlerInterceptor处理层级Servlet 容器最外层同左Spring MVC 内部是否能感知 Handler 方法不能不能能还能拿到目标方法依赖 Spring MVC不依赖不依赖依赖适合场景通用横切逻辑需要防重复的通用横切逻辑需要 Controller 信息的逻辑很多时候一个需求用 Interceptor 也行用OncePerRequestFilter也行。我的取舍标准是看“需求是否在进入 Spring MVC 之前就必须生效”。比如登录态校验最好是越早拦截越好这样非法请求连 Controller 都不用进我会选过滤器比如要判断某个 Handler 上有没有指定注解来做权限控制那是 Interceptor 的强项。7.2 我一直在用的选型经验个人经验是纯 Servlet 层面的横切逻辑一律默认继承OncePerRequestFilter防重复是白送的保障需要访问目标 Handler 方法或方法注解时用HandlerInterceptor尽量不要在同一个切面逻辑上同时使用两者否则会出现校验执行两次、但标记不共享的尴尬局面这套组合够解决项目中绝大多数统一处理需求而且代码结构很直观。最后分享一个排查技巧如果搞不清某个过滤器到底被调了几次最快的办法是在doFilterInternal第一行打印请求 URI、当前线程名和request.isAsyncStarted()配合日志跑一遍立即就能看清每次触发来自哪条链路。很多人其实只要看到“线程名变了 同一个 URI”就能立刻联想到异步分派问题也就迎刃而解了。这个类看起来简单但它背后的请求生命周期理念值得每个做 Web 开发的同学好好吃透。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑