
搞Java Web的兄弟多少都遇到过这种灵魂拷问同一个请求进到Controller之前项目里既有Aspect切面、也有Filter、还有Spring MVC拦截器到底谁先执行网上搜出来的结论五花八门有的说“Filter最外层拦截器其次切面最后”有的说“顺序随便排”真到了自己打日志的时候看到的却是另一码事。我在维护一个报表系统的时候就被这个顺序坑过登录状态放拦截器里校验操作权限放Aspect里判断结果同事在切面里拿到当前用户信息是null排了半天才发现三层组件的执行顺序被一个不起眼的配置改变了。这篇文章我不讲空话直接拿一个可以复现的Demo把这几个顺序钉死再聊聊实战里常见的顺序坑和设计建议适合Spring Boot项目里写过滤器、拦截器、切面的开发同学。1. 一次“权限校验失效”事故带出的顺序问题1.1 事故现场还原项目是Spring Boot 2.4 Redis接口权限逻辑拆了三层Filter负责把请求头里的token解析出来塞进ThreadLocalInterceptor负责校验当前用户是否登录Aspect负责记录操作日志和做数据权限判断。看上去职责很清晰。结果上游网关联调时发现凡是带数据权限的接口切面里都查不到当前用户。Aspect里从ThreadLocal取user永远是null。Filter明明已经解析了token并写进了ThreadLocal为什么Aspect读不到排查日志时才发现Filter的doFilter前半段确实先执行但Aspect的环绕通知并没有排在Interceptor的preHandle后面而是更早进入了执行。准确地说拦截器的preHandle还没跑到Aspect就已经开始执行此时ThreadLocal里自然还没有当前用户信息。这是我第一次直面“执行顺序”不是背个口诀就能糊弄过去的问题任何一层的注册方式发生变化整条调用链都会跟着变。1.2 为什么“网上说的顺序”和“本地跑的顺序”对不上很多文章只给了一张三层顺序图Filter - Interceptor - Aspect - Controller。这本身没有错但它忽略了一个前提三个组件分别属于不同的框架层级顺序由Servlet容器、HandlerExecutionChain、AOP代理生成三套机制共同决定。任何一处的注册方式不一样最终的调用链都会变化。比如Filter可以走Servlet的WebFilter也可以走Spring Boot的FilterRegistrationBean拦截器必须通过WebMvcConfigurer注册Aspect则需要Spring AOP代理在Bean初始化后生成。漏掉其中任何一个注册细节你看到的顺序都有可能和预期不一致。所以要真正掌握执行顺序就得先搞清楚三层组件各自在哪一层、是怎么挂上去的。这也是下面两节要展开的内容。2. Filter、拦截器、Aspect的站位与三层机制2.1 三道关卡从请求进来到方法返回把一次HTTP请求在Spring Boot里的完整旅程展开大致是这样客户端请求进入Servlet容器Tomcat等按URL路径匹配到自定义Filter或内置Filter组成的过滤器链过滤链路放行后请求到达DispatcherServletDispatcherServlet根据请求路径找到HandlerMapping中的Controller方法并组装一条HandlerExecutionChain里面除了handler还有匹配上的InterceptorHandlerExecutionChain按顺序执行所有拦截器的preHandlepreHandle全部返回true后DispatcherServlet调用Handler本身如果这个Controller方法被Spring AOP代理过实际执行的就是代理对象里的增强逻辑也就是Aspect的环绕通知真正执行Controller方法体业务方法返回后Aspect的后置通知、环绕通知后段继续执行回到HandlerExecutionChain按逆序执行postHandle再按逆序执行afterCompletion最后回到Filter的doFilter剩余逻辑把响应交回Servlet容器。这一串总结下来就是三层Filter是Servlet容器层的过滤器Interceptor是Spring MVC层的拦截器Aspect是Spring容器里生成的AOP代理增强。三者所在的层面不同是理解顺序的第一步。2.2 底层机制责任链、执行链和动态代理Filter不是Spring的专属概念它属于Servlet规范。N多个Filter串起来就是一条责任链每个Filter持有对下一个Filter的引用调用chain.doFilter时才往后放行。Spring Boot把Filter对象交给Servlet容器的过滤器链来调度所以它天然包在所有Spring MVC的东西外层。Interceptor是Spring MVC自己的组件。它不像Filter在Servlet容器里工作而是被DispatcherServlet在“已经找到Controller方法”之后调用。这意味着请求必须先能被HandlerMapping匹配到具体方法否则即使Filter已经放行、请求最终走404拦截器也碰不到。Aspect则是Spring AOP的产物。Spring AOP在Bean初始化阶段判断目标类是否需要代理如果匹配上切点就生成JDK动态代理或CGLIB代理对象后续其他Bean注入的、HandlerAdapter调用的都是这个代理对象。Aspect里的通知就织入到代理逻辑中。因为代理发生在方法调用之前并且可以在方法前后插入逻辑Aspect的环绕通知自然就出现在Controller方法体的外层。2.3 用代码把三层“装”出来先给一个最小骨架。ControllerRestController RequestMapping(/order) public class OrderController { PostMapping(/list) public Result list(RequestBody OrderQuery query) { System.out.println(4. Controller方法体被执行); return Result.ok(); } }自定义FilterComponent public class RequestTokenFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { System.out.println(0. Filter进入); chain.doFilter(request, response); System.out.println(9. Filter返回); } }拦截器Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { System.out.println(2. Interceptor.preHandle); return true; } Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) { System.out.println(7. Interceptor.postHandle); } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { System.out.println(8. Interceptor.afterCompletion); } }注册拦截器Configuration public class WebConfig implements WebMvcConfigurer { Resource private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/order/**); } }AspectAspect Component public class OrderLogAspect { Around(execution(* com.example.controller.OrderController.*(..))) public Object around(ProceedingJoinPoint pjp) throws Throwable { System.out.println(3. Aspect环绕通知-前); try { return pjp.proceed(); } finally { System.out.println(6. Aspect环绕通知-后); } } }这套代码跑起来日志顺序就已经非常清楚了。这就是后面验证顺序的实验基础。3. 一个可复现的最小案例把执行顺序钉死3.1 Demo结构与完整代码上一节我已经把骨架给出这一节再给一点实际观测到的“证据”。实际项目里为了看得清楚最好在每个组件打印当前线程名和时间戳或者用日志框架输出带统一traceId的日志。我这里直接用序号代表观测到的顺序。请求进入阶段日志顺序是Filter进入Interceptor.preHandleAspect环绕通知-前Controller方法体被执行响应返回阶段日志顺序是Aspect环绕通知-后Interceptor.postHandleInterceptor.afterCompletionFilter返回这里有个容易误解的点为什么Aspect的“前”排在Controller方法体之前却排在Interceptor.preHandle之后因为Aspect环绕通知的前置部分本质上是“Controller方法真正被执行前的最后一道动态代理逻辑”它写在Controller方法第一行之前。而Interceptor.preHandle在DispatcherServlet准备调用Handler的时候就已经执行那时代理对象还没被真正调用。所以这个顺序不是随便定的它由调用链每一层的职责决定。3.2 日志里看到的真实顺序用Spring Boot 2.7 Tomcat跑出来的实测日志关键行如下[req-1] 0. Filter进入 [req-1] 2. Interceptor.preHandle [req-1] 3. Aspect环绕通知-前 [req-1] 4. Controller方法体被执行 [req-1] 6. Aspect环绕通知-后 [req-1] 7. Interceptor.postHandle [req-1] 8. Interceptor.afterCompletion [req-1] 9. Filter返回对应阶段可以整理成一张表阶段组件说明请求进入Filter最外层Servlet容器调度请求进入Interceptor.preHandle路由命中之后执行请求进入Aspect前置逻辑方法调用前的最后一层代理逻辑Controller业务方法最里层响应阶段Aspect后置逻辑从业务方法退出后立刻执行响应阶段Interceptor.postHandleController返回后执行响应阶段Interceptor.afterCompletion视图渲染完成后执行无视图时仍在响应阶段Filter返回逻辑最后回到最外层上面的顺序对“加了Around且业务方法正常返回”的情况成立。从这套顺序可以延伸出一个重要结论Aspect的后置逻辑紧挨着业务方法退出后执行往往早于postHandle。如果你把资源清理、线程变量删除放在Aspect后置里而别人把依赖线程变量的操作放在postHandle里两者之间就存在天然的先后差。3.3 多个同类组件并存时的排序规则同一个请求路径上可能存在多个Filter、多个拦截器、多个切面。排序规则分别是多个Filter通过FilterRegistrationBean#setOrder或Order注解控制数字越小越先执行Filter链在容器内按顺序嵌套执行。多个Interceptor在InterceptorRegistry.addInterceptor()时给每个拦截器设置order数字小的先执行preHandle但返回阶段会倒序执行postHandle和afterCompletion。多个Aspect在切面类上使用Order(1)、Order(2)数字小的先执行前置通知后置通知则是数字大的先返回原来的调用链。以两个拦截器为例InterceptorA.preHandle InterceptorB.preHandle Controller执行 InterceptorB.postHandle InterceptorA.postHandle InterceptorB.afterCompletion InterceptorA.afterCompletion这里前后阶段顺序相反是因为拦截器在设计上效仿了“栈”结构先进入的后退出。多个Filter存在时情况与此相似相当于俄罗斯套娃式的嵌套责任链。4. 顺序背后为什么是这样4.1 Filter与DispatcherServlet的前后关系Filter是Servlet规范定义的组件。Tomcat在收到请求后会先执行Servlet的过滤器链再调用对应Servlet。而Spring Boot中的DispatcherServlet本质上也是一个Servlet被注册在/*这个路径上。所以只要Filter的URL匹配覆盖了/*它就天然包在DispatcherServlet外层。这也是为什么“Filter永远最先进入、最后退出”。但这里有一个极易被忽视的细节WebFilter如果配置成WebFilter(urlPatterns /foo)那么只有/foo路径才会触发Filter别把生效范围整个忘了。Spring Boot中我更推荐用FilterRegistrationBean因为可以同时指定urlPatterns和order还能把Filter明确注册成Spring管理的Bean方便注入依赖。4.2 Interceptor与HandlerExecutionChainDispatcherServlet在处理请求时会调用getHandler()从HandlerMapping中得到一个HandlerExecutionChain。这个链里包含一个handlerController方法对象和拦截器列表。只有preHandle()全部通过DispatcherServlet才会调用HandlerAdapter.handle()去真正触发业务逻辑。所以说Interceptor能做的事比Filter精细很多它可以拿到handler的Method信息甚至可以决定某个路径到底要不要拦截。但也正因如此如果请求匹配不上Controller方法它根本不会执行。这也是“Filter先拦截、Interceptor后拦截”的结构性原因。4.3 AOP代理如何包裹ControllerSpring AOP的代理逻辑发生在Bean创建阶段。一个Controller被切面命中后容器里实际放的是代理对象HandlerAdapter调用的也是这个代理对象的方法。AOP通知的织入顺序体现在代理方法内部的调用序列中Around的环绕前置部分、Before通知、目标方法、AfterReturning/After通知、环绕后置部分。这些通知都发生在Controller方法体这一层比DispatcherServlet对HandlerExecutionChain的管理更靠内。如果你在切面里同时写Around和Before会看到Around前置逻辑先启动然后再进入其他通知逻辑。这里不展开AOP内部通知的复杂顺序只强调和三层顺序相关的结论所有Aspect逻辑都封存在代理方法内部所以在Controller方法的视角看Filter在外面Interceptor在中层Aspect最贴近业务方法。5. 高频业务场景里的顺序坑与解决预案5.1 Filter里注入Redis怎么就是null一个很常见的线上场景Filter里用Autowired注入RedisTemplate或者其他服务结果一启动就null或者请求执行时直接NPE。根源通常在于有些Filter是通过new Filter()的方式注册的并没有交给Spring管理或者虽然写了Component但又被ServletComponentScan以Servlet方式注册实际运行的可能不是同一个实例。可靠的解法是让Filter同时成为Spring Bean用构造器注入依赖再通过FilterRegistrationBean注册Configuration public class FilterConfig { Bean public FilterRegistrationBeanRedisTokenFilter redisTokenFilter(RedisTemplateString, Object redisTemplate) { FilterRegistrationBeanRedisTokenFilter bean new FilterRegistrationBean(); bean.setFilter(new RedisTokenFilter(redisTemplate)); bean.addUrlPatterns(/*); bean.setOrder(1); return bean; } }如果项目里已经有Filter类上写着Component那就改成构造器注入别在Filter里用字段注入。因为一旦Filter被容器当成普通Servlet组件处理字段注入的时机就不归Spring管了。实在没法改注册方式时可以用ApplicationContextAware做一个静态上下文工具在Filter里手动取Bean。这是一种兜底方案不建议作为默认。5.2 拦截器读body导致接口报400用拦截器验证token或做签名校验时经常需要在拦截器里读请求体参数。但HttpServletRequest.getInputStream只能读一次后面Controller再用RequestBody解析时就会读不到body导致接口直接报400或参数缺失。这个坑的根因和“执行顺序”强相关拦截器在Controller前面执行它先把body消费掉了。解决思路是用ContentCachingRequestWrapper包装请求把原始body缓存起来或者把读取结果放到request attribute中让后续继续使用。Spring MVC对一些内置场景已经做了包装但自定义读body时还是要自己兜底。理解了“Interceptor在Controller之前”后就应该主动想到“我在拦截器里动了请求对象Controller怎么办”。5.3 Aspect切不到Controller、切了两次另一种常见问题是切面定义在Controller上却不生效或者明明只定义一个切面却执行两次。前者通常是Spring AOP默认基于代理目标类实现了接口且没有强制使用CGLIB代理方式不匹配后者则往往是同一个切面类被多个匹配规则命中或者Controller本身被代理切面又写在RestControllerAdvice之类的复合组件上造成多次代理。如果确实需要切Controller方法建议这么做确保切点表达式准确例如execution(public * com.example.controller..*.*(..))使用Spring Boot默认的CGLIB代理直接对目标类做子类代理如果发现切面执行两次检查切点表达式是否重复覆盖同一个方法或者是否存在多个代理对象叠加。5.4 异步请求、转发和异常场景的顺序变化如果接口用了Callable、DeferredResult或WebFlux顺序会发生变化。比如Callable异步接口中preHandle先执行Controller方法返回Callable后postHandle不会等异步任务跑完再执行而是由Spring MVC在异步任务完成后再触发一次afterCompletion。Filter则要看DispatcherTypeOncePerRequestFilter默认会覆盖REQUEST和ASYNC等多个阶段要注意别在异步阶段重复执行重逻辑。异常也一样如果Controller方法抛出异常AfterThrowing通知会先处理异常然后postHandle可能不会执行但afterCompletion无论如何都会执行再回到Filter的后续逻辑。如果异常被ControllerAdvice里的ExceptionHandler处理还要看异常是从哪一层抛出的这决定哪一层能感知到异常。想用Filter做统一异常包装时必须明白Filter的finally块相当于try-finally的出口它永远有机会在响应交给容器前做最后处理。5.5 XSS过滤这类请求清洗Filter要注意什么Filter还有一个常见场景是XSS过滤。很多项目会用Filter把request.getParameter和body里的特殊字符转义掉。这里有一个隐藏顺序坑如果XSS Filter的order太靠后前面已经有人把请求参数读取进缓存或者埋入日志就可能在不该出现原始参数的地方留下未清洗数据。所以通用清洗Filter通常应该放在最前order设为最小值。反过来如果把XSS Filter和登录态Filter的order搞反登录用户信息读取用的还是原始参数清洗规则完全没有达到统一入口的效果。这类问题不报错但会直接影响安全审计和日志合规排查起来很费劲。6. 利用执行顺序来设计项目规范6.1 每一层该干什么活在项目规范层面我一般这样分配职责层适合干的活不适合干的活Filter编码设置、CORS、XSS清洗、日志追踪ID、登录态解析放很重的业务规则InterceptorURL级别的登录鉴权、权限校验、接口幂等前处理、数据埋点依赖第三方库里的通用协议处理Aspect方法级操作日志、数据权限、重试、限流、缓存、事务注入不适合做与外层容器强相关的事顺序本身不是目的把功能放到正确的层才是目的。我看到很多初学项目把所有逻辑都堆在Controller里然后在Aspect里重复做登录判断结果调用顺序一混乱整个授权体系就很难维护。把职责定清楚以后即使Filter、Interceptor、Aspect的顺序有变更影响面也被限制在对应层内部不会全局失控。6.2 调整顺序的实操清单如果想调整三层组件在同一个请求中的相对位置有下面几个抓手Filter之间排序用FilterRegistrationBean#setOrder数字小的先执行Interceptor之间排序在InterceptorRegistry注册时设置orderpreHandle按数字从小到大Aspect之间排序切面类加Order数字小的先执行前置逻辑想让Filter覆盖所有请求保证FilterRegistrationBean的URL匹配为/*order设在所有过滤链最前想让某些Controller方法不走某个拦截器或切面用excludePathPatterns或更精确的切点表达式而不是试图打乱三层整体顺序。需要特别注意Filter、Interceptor、Aspect的order是各层内部各自比较的Spring Boot没有一个统一的数字能在三层之间跨越排序。Filter的order只在过滤器链内比较Interceptor的order只在拦截器数组中比较Aspect的order只在切面链中比较。所以“Filter与Interceptor谁先”由容器结构决定不能靠一个数字跨层调整。网上很多说法把几个order混为一谈最容易误导人。6.3 我的一个日常习惯跨层请求标识最后说一个我一直在用的实操习惯在Filter最开始把请求ID放入MDC这样在Interceptor、Aspect、Controller里都能直接打印同一个请求ID。这个习惯依赖的正是我们前面理清的调用链顺序——Filter是请求进入的第一站也有机会在最后清理MDC。无论后面怎么嵌套日志都能串起来。Filter里大概这样写try { MDC.put(reqId, UUID.randomUUID().toString().replace(-, )); chain.doFilter(request, response); } finally { MDC.remove(reqId); }排查执行顺序问题时只需要打开日志按reqId过滤就能看到某一次请求在Filter、Interceptor、Aspect、Controller四个位置的真实轨迹。这比临时在代码里加断点、到处打印要高效得多。实际项目中凡是涉及过滤器、拦截器、切面并存的场景我都会留一份这样的调用轨迹日志后续别人接手时也能少走弯路。