
开篇先亮观点Java拦截器这个东西很多人从实习写到跳槽写来写去还是同一个模板——实现HandlerInterceptor在preHandle里判断一下Session有没有登录没有就redirect到登录页。如果你拿这套东西去面2025年的Java岗位大概率会被追问到下不来台。因为拦截器真正的价值从来不是登录校验那个Hello World而是它作为Web层横切机制的完整能力执行顺序、异常调用链、异步请求、与Filter和AOP的边界划分这些才是区分用过和会设计的分水岭。这篇文章我会尽量把Java拦截器从spring-webmvc的底层调用机制到实战落地讲透。内容覆盖拦截器的注册与执行顺序、三个生命周期方法的内部逻辑、登录鉴权/接口防刷/多租户/幂等等高频场景的成熟写法以及拦截器不生效时我从源码层面做的完整排查。适合刚接触Spring MVC的同学也适合准备Java面试、想在团队里有点技术话语权的工程师。注意这里说的卷不是让你在代码里炫技而是把别人讲不清楚的东西搞明白写出来的东西能上线、能扛并发、能经得起面试官连问三个为什么。1. 先搞清楚这件事Java Web里到底有几道关卡1.1 从Tomcat到Controller请求要过几道横切逻辑很多同学把拦截器、过滤器、AOP混为一谈开口就是拦截器就是过滤器。这个理解在面试里是硬伤在项目里是隐患。实际上一笔请求从浏览器出发打到Controller要穿越三道横切面分别由三种机制负责Servlet Filter属于Servlet规范在请求进入DispatcherServlet之前执行。它能拿到原始的ServletRequest和ServletResponse但完全不知道Spring MVC里这次请求会由哪个Controller处理。Spring MVC Interceptor属于spring-webmvc框架的能力在HandlerMapping找到对应的HandlerMethod之后、Controller方法执行之前介入。它能看到即将执行哪个类的哪个方法。Spring AOPSpring容器级的能力可以作用于任意Bean的任意方法通过动态代理在方法调用前后织入增强逻辑。用一个生活化类比Filter像机场安检口所有人在进候机区之前就要被扫一遍它不关心你是去北京还是去上海Interceptor像登机口的检票员你已经买好了票、走到了正确的登机口他再核对一次人、票、航班是否一致AOP则像是登机后的空乘服务可以对每个乘客的服务流程做精细化增强比如给头等舱乘客多发一条毛毯。这三者不是替代关系而是层次关系。实际项目里最常见的架构是Filter负责全局编码、跨域、请求日志的粗粒度处理Interceptor负责Web层的业务横切——登录校验、权限判断、限流、多租户解析AOP负责Service层的事务、日志、缓存等能力。乱用才会出事各司其职就是好架构。1.2 Filter和Interceptor的差异不该只背执行时机不同面试问Filter和Interceptor区别时只会答一个容器级一个框架级是不够的。需要落到具体行为上我这里列一份实践向对比对比维度Servlet FilterSpring MVC Interceptor标准归属Servlet规范与框架无关spring-webmvc脱离Spring MVC无法使用触发位置DispatcherServlet之前HandlerMapping定位Handler之后、执行Handler之前能否拿到Handler方法拿不到只有ServletRequest/Response能拿到HandlerMethod进而拿到方法、参数、类上的注解能否改ModelAndView改不了postHandle中可以修改异常处理抛出的异常不经过RestControllerAdvice需自行处理可以由Spring MVC统一异常机制接管取决于调用链典型场景字符编码、跨域CORS、压缩、XSS过滤登录鉴权、接口权限、幂等、限流、多租户、操作日志数量配置通过WebFilter或FilterRegistrationBean配置通过WebMvcConfigurer#addInterceptors配置这里有个非常容易踩的坑Filter和Interceptor的执行顺序不能混为一谈。Filter按照FilterRegistrationBean的order排序Order(1)在Order(2)之前执行而多个拦截器的执行顺序是注册顺序决定preHandle顺序但postHandle和afterCompletion是倒序的。稍后我会专门讲拦截器链的顺序问题这是很多人栽过跟头的地方。1.3 那AOP呢拦截器和AOP在Controller层谁更合适AOP的优缺点都很明显它的粒度细能环绕、能织入任何Spring管理的Bean但用在Controller层时有几个尴尬点。第一Controller方法通常public化AOP通过代理生效如果类内部自调用this.method()增强会失效第二AOP难以直接感知HTTP请求上下文中的细节比如HttpServletRequest的Header、URI、请求参数需要额外引入RequestContextHolder第三AOP里拿到的方法参数是对象级无法像拦截器那样直接操作ModelAndView和响应流。拦截器则天然处在HTTP链路内接口鉴权、防刷、统一参数校验这类每个接口都要、且都基于请求上下文的能力放拦截器里是最合适的。我的判断标准很简单逻辑和服务方法本身耦合度高用AOP逻辑属于进Controller之前/之后的HTTP横切用拦截器。比如所有以/api/开头的写操作都需要校验幂等key这类需求写在拦截器里比写AOP注解清爽得多。2. 环境与注册Spring Boot 3.x时代搭一个能上线的拦截器2.1 最小依赖别再为拦截器额外引包了2025年做Java后端基本都是Spring Boot 3.x起步。spring-boot-starter-web里已经包含了spring-webmvcHandlerInterceptor和WebMvcConfigurer都在这个包里不需要任何额外依赖。如果你用的是Spring Boot 2.x逻辑也完全一致只是底层路径匹配器不同后面会讲到。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency就这么简单。很多人一搜拦截器网上教程让引各种乱七八糟的包完全没必要。2.2 注册拦截器的标准三段式写法写一个拦截器类实现HandlerInterceptor再写一个WebMvcConfigurer配置类重写addInterceptors方法。我见过的最干净的写法是这样Component public class AuthInterceptor implements HandlerInterceptor { private static final Logger log LoggerFactory.getLogger(AuthInterceptor.class); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 只拦截Controller方法放行静态资源和错误转发 if (!(handler instanceof HandlerMethod)) { return true; } log.info(拦截请求: {} {}, request.getMethod(), request.getRequestURI()); return true; } }Configuration public class WebMvcConfig implements WebMvcConfigurer { private final AuthInterceptor authInterceptor; public WebMvcConfig(AuthInterceptor authInterceptor) { this.authInterceptor authInterceptor; } Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/register, /error); } }几个关键点handler instanceof HandlerMethod这个判断很重要。一次请求可能因为静态资源、错误转发等原因被拦截器调用此时handler不是HandlerMethod不做判断直接强转会抛异常。excludePathPatterns里一定要放行/error。很多项目Controller抛出异常后会由Spring转发到/error如果拦截器在这个路径上还做登录校验异常响应会被二次拦截导致全局异常处理器写的JSON没效果。addPathPatterns(/api/**)是精确声明拦截范围不写addPathPatterns不代表所有路径都拦。这个细节在Spring Boot 2.6版本里面有变化网上资料说法不一后面排查章节会展开。2.3 路径匹配的细节/*和/**、Ant风格、PathPatternParser拦截器路径匹配用的是Spring自己的模式匹配不是正则表达式。最常见的三个坑/*只匹配一级路径。/api/user匹配/api/user/1不匹配。/**匹配多级路径。/api/user/1匹配/api/user/1/orders/123也匹配。?匹配单个字符/api/??匹配/api/a1但不匹配/api/a1b。Spring Boot 3.x默认使用PathPatternParser而Spring Boot 2.x默认使用AntPathMatcher3.x也可以切换。两者对/**等模式的核心语义一致但在一些边缘写法上不同比如PathPatternParser不允许Pattern出现在路径中段。实际项目里我用得最多的是/api/**配合excludePathPatterns够用且不容易出错。2.4 注册顺序不等于执行顺序order到底怎么算addInterceptor的调用顺序决定的是preHandle的默认执行顺序但如果你想让某个拦截器强制最先或最后执行还是得显式指定order。Spring Boot里拦截器默认的order是Ordered.LOWEST_PRECEDENCE也就是Integer最大值附近。你调用registry.addInterceptor(a).order(1)再registry.addInterceptor(b).order(2)那么preHandle顺序是a先、b后。这个机制用排序解决了一个隐含问题不同拦截器可能由不同的配置类注册无法保证注册源码之间的先后用order才能跨配置类控制全局顺序。项目里建议统一规划order区间比如100TraceId/日志跟踪200多租户解析300登录鉴权400接口权限500限流防刷别等到线上排查拦截器顺序问题时才后悔没留间隔。你不给order将来加拦截器就只能靠调整代码顺序这种脆弱方案被坑过一次就长记性了。3. 生命周期三兄弟preHandle/postHandle/afterCompletion的底层机制3.1 preHandle返回false之后整个链路发生了什么preHandle返回true表示放行返回false表示短路。很多人只知道返回false就拦住了但不知道拦住之后框架做了什么。真实调用流程是这样的DispatcherServlet.doDispatch里的核心逻辑是mappedHandler.applyPreHandle(processedRequest, response)。这个方法会遍历所有拦截器按顺序执行preHandle。一旦某个拦截器返回falseapplyPreHandle会立即触发triggerAfterCompletion把从第一个到当前这个所有拦截器的afterCompletion都执行一遍然后直接return不再走后续的Controller方法。这里有个容易被忽略的细节如果拦截器链是A、B、CB的preHandle返回false那么C的preHandle根本不会被调用但A和B的afterCompletion会被调用。这个机制意味着你如果在preHandle里占用了资源比如往ThreadLocal里放了东西、打开了一个流必须保证即使后面拦截器不放行资源也能被清理。好的做法是afterCompletion里清理而不是只等正常链路结束。另一个大坑preHandle里返回false后请求不会经过postHandleSpring MVC也不会渲染视图。你想给客户端返回JSON必须在preHandle里自己往response里写。这是我的实测经验写一个response.setStatus(401)然后response.getWriter().write(json)再return false。Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token request.getHeader(Authorization); if (token null || token.isEmpty()) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录或token失效\}); return false; } return true; }3.2 postHandle在视图渲染前调用但这个时机比你想象得早postHandle是在Controller方法执行完成、ModelAndView已经生成但视图还没有渲染的时候调用。它的最大价值是往ModelAndView里塞公共数据比如页面标题、面包屑导航、页脚信息。这个能力对传统服务端渲染Thymeleaf、JSP非常有用。但2025年的项目基本都是前后端分离ResponseBody返回JSON时postHandle里再去修改ModelAndView是不会生效的。原因在于ResponseBody接口的返回值会由HttpMessageConverter比如MappingJackson2HttpMessageConverter在postHandle之前就已经写入了response输出流。实测中你会发现在postHandle里改了ModelAndView前端收到的JSON一点没变。所以对JSON接口的数据修改应该走ResponseBodyAdvice比如统一包装返回结构而不是拦截器的postHandle。这也是很多八股文里藏着的一层深水面试官问你postHandle可以做什么你要能说出对传统MVC可以改ModelAndView但对ResponseBody接口改不了。3.3 afterCompletion无论正常还是异常都像finally一样兜底afterCompletion在整个请求链路结束之后调用即使Controller抛出了异常也会执行。它的语义和Java的finally对齐最适合做清理工作删除ThreadLocal、记录接口耗时、发送审计日志、关闭资源。Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserContext.clear(); if (ex ! null) { log.error(接口 {} 执行异常: {}, request.getRequestURI(), ex.getMessage()); } }注意一个细节异常信息会通过afterCompletion的ex参数传过来但如果异常被RestControllerAdvice捕获处理了这个ex依然是非空的只是响应已经是友好JSON了。所以别在afterCompletion里重复写错误响应只做记录和清理就够。3.4 完整生命周期的一张表关键时刻翻出来看为了直观我把正常、短路、异常三条路径汇总一下这也是我项目里贴在最显眼处的备忘场景preHandle调用情况postHandle调用情况afterCompletion调用情况全部放行正常返回全部执行按order正序全部执行按order倒序全部执行按order倒序某拦截器preHandle返回false已执行到该拦截器为止不执行仅已执行preHandle的拦截器会执行Controller抛出异常全部执行不执行全部已执行preHandle的拦截器会执行postHandle本身抛异常全部执行已执行的按倒序中断全部已执行preHandle的拦截器会执行这几个case不是靠背的是靠断点一眼一眼看出来的。有一次我在排查线上问题时发现一个拦截器明明preHandle没打印日志但afterCompletion的日志却出现了一查源码才明白是上一个拦截器短路了但这已经足够让我记住这条链路了。4. 真实战场的五个落地方案登录鉴权、防刷限流、多租户、幂等、操作日志4.1 基于JWT Token的登录鉴权拦截器2025年的登录鉴权主流方案是前端传Authorization: Bearer token后端解析JWT。拦截器负责三件事校验Token是否存在、解析Token并拿到用户信息、把用户信息放入ThreadLocal供后续Controller使用。Component public class LoginInterceptor implements HandlerInterceptor { private final TokenService tokenService; public LoginInterceptor(TokenService tokenService) { this.tokenService tokenService; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); } try { Long userId tokenService.parseToken(token); UserContext.set(userId); return true; } catch (Exception e) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\认证失败请重新登录\}); return false; } } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserContext.clear(); } }这里有个老生常谈但每次都要说的点preHandle里拿到userId放到ThreadLocalafterCompletion必须清掉。原因很简单Tomcat的工作线程是复用的核心线程池里的线程处理完请求A之后不会销毁会被分配去处理请求B。如果线程里的ThreadLocal不清空请求B在没有登录的情况下也能读到请求A残留的userId这就是越权事故的温床。我接手过一个老项目就有这个bug线上出现用户串号查了两天才定位到这个原因。另外建议在preHandle里不要直接抛异常而是捕获后自己写Response并返回false。因为你抛出去的异常会走Spring的异常处理链路而HandlerInterceptor.preHandle的异常处理器行为在异常场景下不是所有版本都一样可控。自己写Response行为最可控。当然如果你用了统一的异常处理器也可以抛业务异常让RestControllerAdvice接管两种方式看项目规范。我的实测结论在拦截器里直接return false 手写JSON响应是最不会被中间层劫持的方式。4.2 接口级防刷限流拦截器是天然的限流位置为什么限流放在拦截器而不是AOP因为限流的语义是在进入Controller之前对请求频率做判断拦截器正好卡在这个位置。一个简化的滑动窗口限流器可以这样写Component public class RateLimitInterceptor implements HandlerInterceptor { private final CacheString, DequeLong windowCache CacheBuilder.newBuilder() .expireAfterWrite(60, TimeUnit.SECONDS) .build(); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String key request.getRemoteAddr() : request.getRequestURI(); long now System.currentTimeMillis(); DequeLong window windowCache.get(key, k - new ArrayDeque()); synchronized (window) { // 移除窗口外的时间戳窗口为1秒 while (!window.isEmpty() now - window.peekFirst() 1000) { window.pollFirst(); } if (window.size() 10) { response.setStatus(429); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:429,\msg\:\请求过于频繁请稍后再试\}); return false; } window.addLast(now); } return true; } }上面这个写法是本地内存版单机部署够用。但你要是部署了多实例这个方案就会失效——每个实例都有自己独立的计数器5个节点相当于把限流阈值放大了5倍。生产环境推荐直接用Redis实现滑动窗口或者用Redisson的RRateLimiter基于令牌桶。拦截器里做限流的一个好处是preHandle返回false之后请求根本不会打到Controller也就不会占用业务线程和数据库连接。在高并发场景下这个提前拦截能省出大量资源给正常请求。这个考量才是线上限流为什么放拦截器的底层答案而不是因为网上教程都这么写。4.3 多租户解析把租户ID注入ThreadLocalSaaS后台项目最常见的横切逻辑就是解析当前请求属于哪个租户。Header里通常带一个X-Tenant-Id拦截器把它解析出来放到ThreadLocal业务层就能通过TenantContext.getTenantId()在SQL层面做数据隔离。这个例子的核心不是代码而是两个隐患异步线程丢失上下文。如果Controller里用了Async方法或者提交了线程池任务子线程里TenantContext是空的Mapper层拼接SQL时可能拼出没有租户条件的危险SQL。稳妥方案是用TransmittableThreadLocal替代ThreadLocal并在提交线程池时做上下文传递但要注意引入后的兼容性。拦截器链的执行顺序必须是租户解析早于登录鉴权。因为很多系统的用户表和租户表是关联的解析Token里的userId后还要按当前租户校验用户是否属于该租户。如果登录鉴权先执行、租户解析还没做权限校验根本没法查。所以多租户和登录鉴权这两个拦截器order设计要留好。我项目里的习惯是租户解析100登录鉴权200。4.4 接口幂等防重写接口的护城河这个需求现在很常见用户点击提交订单按钮前端重复发起请求后端不能创建两笔订单。用拦截器做幂等的思路是前端每次提交时生成一个Idempotent-Key拦截器检查Redis里这个key是否已存在第一次请求key不存在放入Redis带过期时间放行。第二次请求key已存在直接返回重复提交。Component public class IdempotentInterceptor implements HandlerInterceptor { private final StringRedisTemplate redisTemplate; public IdempotentInterceptor(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } if (!POST.equalsIgnoreCase(request.getMethod()) !PUT.equalsIgnoreCase(request.getMethod())) { return true; } String key request.getHeader(Idempotent-Key); if (StringUtils.hasText(key)) { Boolean first redisTemplate.opsForValue() .setIfAbsent(idempotent: key, 1, Duration.ofSeconds(10)); if (Boolean.FALSE.equals(first)) { response.setStatus(200); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:429,\msg\:\请勿重复提交\}); return false; } } return true; } }这个方案有前置条件前端必须保证同一个业务操作使用同一个Idempotent-Key而且提交失败重试时不能重新生成key。另外setIfAbsent和过期时间必须原子操作setnx单独expire会有key永久残留的风险一定要用setIfAbsent(key, value, timeout)这个带过期时间的API。4.5 操作日志只在真正需要的地方记很多团队做操作日志把controller方法执行前后所有参数全量打印结果一张表存几个T查询时慢得要死。我建议操作日志收集的范围收敛到所有写操作 关键读操作并且不要把请求体和响应体全部存库存关键字段就够了。拦截器里记录操作日志的姿势是preHandle里记录开始时间和请求摘要afterCompletion里计算耗时并落库异步写入。Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { request.setAttribute(_startTime, System.currentTimeMillis()); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { long cost System.currentTimeMillis() - (long) request.getAttribute(_startTime); log.info(操作日志: method{}, uri{}, cost{}ms, request.getMethod(), request.getRequestURI(), cost); }5. 拦截器不生效的排查链从现象到源码级定位5.1 现象描述与前置检查清单最常见的现象是日志不打印、接口照常放行或者只有一部分接口被拦截。排查时先按下面清单过一遍大概能解决80%的问题拦截器类是否被Spring容器管理有没有加Component配置类是否被Spring扫描有没有加Configuration配置类所在包是否在SpringBootApplication的扫描路径内addPathPatterns的路径匹配是否真的覆盖了目标接口项目是否显式加了EnableWebMvc这个注解会让Spring Boot的WebMvc自动配置失效。是否存在多个WebMvcConfigurer其中一个覆盖了拦截器注册请求是不是根本没走到Spring MVC被Servlet级别Filter提前返回、被安全框架拦截这个清单看着简单但每一项都是线上真实踩出来的。5.2 第一个高危嫌疑WebMvcConfigurer实现类没被Spring管理我见过最离谱的bug是同事把拦截器注册类放在了SpringBootApplication所在包的兄弟包外面结果整个配置类都没被扫描到。Spring Boot默认扫描主类所在包及其子包放在外面的普通包Configuration形同虚设。还有一个隐蔽变体配置类和拦截器类都写了但项目里同时有多个WebMvcConfigurer实现且有人继承WebMvcConfigurationSupport。注意一旦项目里出现继承WebMvcConfigurationSupport的类比如为了自定义MessageConverterSpring Boot的WebMvcAutoConfiguration会自动失效之前所有靠自动配置注册的拦截器、静态资源映射、EnableWebMvc的默认行为全部失效。这个坑的排查特点是你只看到拦截器不生效但静态资源也404了这时候就要优先检查是不是引入了WebMvcConfigurationSupport的继承类。5.3 第二个高危嫌疑路径匹配模式与实际请求对不上再提一遍/*和/**的区别。有个案例需求是要拦截/api/user/1配置写的是addPathPatterns(/api/*)结果日志死活不打印。因为/api/*只匹配/api/user不匹配/api/user/1。改成/api/**后立刻生效。另一个隐蔽问题是PathPatternParser下的模式匹配。Spring Boot 3.x里如果你自定义配置里写了一些AntPathMatcher时代的特殊写法比如在模式中带{spring:[a-z]}这类正则表达式语法PathPatternParser会直接报错或者不匹配。我的建议是保持简单只用/api/**和/public/*这种基础模式正则匹配留给拦截器代码里做别在路径配置里玩花活。5.4 第三个高危嫌疑请求被上游Filter或异常转发吃掉有时候拦截器配置了路径也对但请求没经过拦截器。排查时要思考请求是不是还没到DispatcherServlet就被Filter拦截了比如Spring Security的FilterChainProxy、自己写的WebFilter、跨域CORS的CorsFilter都可能提前返回响应。还有一个特别容易被忽略的Controller里抛了异常RestControllerAdvice处理但Spring根据ErrorController配置可能会forward到/error。如果你的异常处理是通过BasicErrorController完成的这个forward请求会带着原始请求的DispatcherType为ERROR走的是DispatcherServlet的error分发。如果你的拦截器路径配置里没有包含/error恰好又不做HandlerMethod判断可能在error转发时二次执行拦截逻辑轻则日志重复重则把错误响应也给拦成JSON。所以我在前面特意强调HandlerMethod判断和excludePathPatterns(/error)是拦截器的基本修养。5.5 验证与修复的有效手段排查路径过一遍之后定位到问题做修复再验证是否生效。我的验证手段有三层由快到慢看日志在preHandle第一行加log.info触发接口看是否打印。看响应特征在preHandle里故意response.setHeader(X-Interceptor-Test, 1)curl -I看响应头。看源码调用链在DispatcherServlet#doDispatch的mappedHandler.applyPreHandle处打断点看mappedHandler里到底装了几个拦截器、路径模式有没有匹配上。第三层其实才是终极方案。因为HandlerExecutionChain里保存的是经过路径匹配筛选后的拦截器列表和Handler你一眼就能看出我被注册了但被路径匹配淘汰了还是根本没被注册。这个方法能帮你把问题定位精确到哪一行匹配逻辑出了问题而不是靠猜。6. 面试视角与进阶2025年拦截器还能聊出什么花6.1 从源码看拦截器是怎么被调起来的面试官问Spring MVC拦截器原理八成是想听你说出这条调用链请求进入DispatcherServlet#doDispatch。getHandler(processedRequest)通过HandlerMapping找到对应的HandlerExecutionChain这个Chain内部就持有当前请求匹配到的所有拦截器List以及Handler。mappedHandler.applyPreHandle(processedRequest, response)按顺序执行所有拦截器的preHandle。如果preHandle全部返回true调用mappedHandler.handle执行Controller方法得到ModelAndView。mappedHandler.applyPostHandle(processedRequest, response, mv)按逆序执行postHandle。视图渲染或ResponseBody写出后mappedHandler.triggerAfterCompletion(request, response, null)按逆序执行afterCompletion。很多人能背出几个方法名但讲不清HandlerExecutionChain的存在。其实这个类才是核心它既是当前请求匹配到了哪些拦截器的载体又是preHandle/postHandle/afterCompletion这些方法按什么顺序调用的调度者。建议看源码时重点看HandlerExecutionChain#applyPreHandle里面的这一段for (int i 0; i interceptors.length; i) { HandlerInterceptor interceptor interceptors[i]; if (!interceptor.preHandle(request, response, this.handler)) { triggerAfterCompletion(request, response, null); return false; } this.interceptorIndex i; }这个interceptorIndex很关键。它记录已经成功通过preHandle的最后一个拦截器索引。一旦某个拦截器返回falsetriggerAfterCompletion只会从0执行到interceptorIndex从而保证已经做过前置增强的拦截器一定有对应的afterCompletion清理。看懂这个就理解了为什么返回false时只有上游拦截器会执行afterCompletion。6.2 高频面试题与破题思路直接拿去用我整理了2025年Java岗位面试里关于拦截器的高频考题每个给解题思路全是我实打实准备的套路1. Filter和Interceptor的区别破题思路先说归属Servlet规范 vs Spring MVC再说触发时机DispatcherServlet前 vs Handler找到后最后落到能力差异能否拿HandlerMethod、能否改ModelAndView、异常处理链路不同。别忘了提一句Filter管全局粗粒度Interceptor管Web层业务横切。2. 多个拦截器的preHandle、postHandle、afterCompletion执行顺序破题思路preHandle按order正序postHandle按order倒序afterCompletion按order倒序。加分项解释HandlerExecutionChain内部用interceptorIndex控制短路时的afterCompletion范围。3. preHandle返回false之后后续会执行吗破题思路会执行已通过preHandle的拦截器的afterCompletion不会执行postHandle也不会执行Controller。加分项指出自己在preHandle里用try-with-resources或手动写Response保证响应合理。4. postHandle能修改ResponseBody的返回值吗破题思路不能。因为HttpMessageConverter在postHandle之前已完成响应体写出。要统一包装JSON响应应该用ResponseBodyAdvice。加分项能顺着讲出postHandle真正适合的场景是传统MVC的ModelAndView扩展。5. 拦截器如何拿到Controller方法上的自定义注解破题思路handler强转为HandlerMethod调用getMethodAnnotation(YourAnnotation.class)。加一个实际例子自定义RequirePermission注解 拦截器做接口权限控制。6. 拦截器、AOP、Filter三者的适用边界破题思路先画层次Filter最外层、Interceptor在MVC Handler前后、AOP在Bean方法级。再说选型原则HTTP横切用拦截器或过滤器业务横切用AOP。加分项举例说明AOP在Controller层的局限自调用失效、难以感知HttpServletRequest、无法直接改Response流。7. ThreadLocal在拦截器里怎么正确使用破题思路preHandle里setafterCompletion里remove原因讲清Tomcat线程池复用ThreadLocal不清理会导致数据串线。加分项提到Async场景下ThreadLocal的传递问题。8. Spring Security和自定义拦截器的关系破题思路Spring Security在Servlet Filter链层工作负责认证和授权框架自定义拦截器在Spring MVC层做业务级校验。用了Security的项目不要重复在拦截器里做认证会两套逻辑打架。权限校验位置Filter链负责你是不是合法用户Interceptor负责你能不能访问这个接口。6.3 进阶实操自定义注解 拦截器实现声明式接口权限这个方案在很多中大型项目里是标配写出来可以让你的代码比同龄人高一个档次。核心思路是定义RequirePermission注解标记在Controller方法或类上拦截器在preHandle里读取注解值和当前用户的权限列表比对。Target({ElementType.TYPE, ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface RequirePermission { String value(); }Component public class PermissionInterceptor implements HandlerInterceptor { private final UserPermissionService permissionService; public PermissionInterceptor(UserPermissionService permissionService) { this.permissionService permissionService; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod handlerMethod)) { return true; } // 方法上的注解优先于类上的注解 RequirePermission requirePermission handlerMethod.getMethodAnnotation(RequirePermission.class); if (requirePermission null) { requirePermission AnnotatedElementUtils.findMergedAnnotation( handlerMethod.getBeanType(), RequirePermission.class); } if (requirePermission null) { return true; } Long userId UserContext.getUserId(); if (!permissionService.hasPermission(userId, requirePermission.value())) { response.setStatus(403); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:403,\msg\:\无权限访问\}); return false; } return true; } }Controller里就能写得很干净RestController RequestMapping(/api/order) public class OrderController { PostMapping(/create) RequirePermission(order:create) public Result createOrder(RequestBody OrderCreateRequest request) { // 业务逻辑 return Result.ok(); } }这个方案比在每个Controller方法里手动判断权限优雅得多也方便测试和审计。配合Spring AOP做方法级日志、拦截器做Web层横切三层能力分得清清楚楚。6.4 进阶设计写拦截器前先想清楚我要在哪一层横切写了这么多年我最大的体会是拦截器技术本身不复杂复杂的是设计边界。很多人把AOP、Filter、Interceptor、Spring Security混着用最后项目里同一件事被四层逻辑各做了一遍排查问题要从最外层日志翻到最内层累得半死。我的建议是落地前先画一张横切矩阵横切能力推荐放置层原因TraceId/日志链路Filter必须最早生效能捕捉所有请求包括拦截器本身的问题CORS跨域Filter请求在进入Spring MVC之前就需要加上跨域响应头租户解析Interceptororder100需要HandlerMethod配合业务参数且要早于鉴权登录认证Interceptororder200基于Token需要在租户解析后执行接口权限Interceptororder300基于登录后的用户信息防刷限流Interceptororder400需要提前短路减少Controller层压力方法级日志/缓存AOP属于Service层业务横切与HTTP无关数据库事务Spring事务管理不要手动写在拦截器里这张表不是标准答案但它能帮你规避同一个逻辑在两个层里写了三遍的混乱局面。遇到新需求第一反应不是写拦截器而是这个问题属于哪一层然后再动手。最后分享一个我个人的习惯每次新建拦截器我都会在类注释里写上它解决什么问题、为什么放这一层、被哪些拦截器包裹这三条信息。一年以后你回头维护代码时会觉得当初这三行注释比整段逻辑都值钱。拦截器的难点从来不是API而是链路上的每个决策——这篇文章把决策背后的原因尽量摊开讲了希望你能在自己的项目里真正用上这些细节。