说到 Spring MVC 拦截器很多写了几年 Java 的开发第一反应是不就 implements HandlerInterceptor 然后注册一下就完事吗。但等真到线上排查问题时你会发现不少争论恰恰来自这个“完事”——同一个拦截器换个 URL 就失效同一个 preHandle在返回 false 后部分 afterCompletion 不执行同一个请求在拦截器里读了一次流Controller 里 RequestBody 就拿到空值。这篇文章想讲的不是怎么抄一段配置而是把 Spring MVC 拦截器这套机制从“能跑”讲到“知道为什么它这么跑”。所以这次我打算换个角度不直接从“如何注册一个拦截器”开始而是先带着你把一个请求在 Spring MVC 里的完整旅程走一遍。只有搞清楚了拦截器卡在链路中的哪一环后面遇到那些奇奇怪怪的问题你才能有方向去查而不是靠猜。1. 先搞清楚拦截器卡在请求链路的哪一环再谈怎么用1.1 从 DispatcherServlet 到 HandlerExecutionChain 的一次分发当浏览器发出/api/order/list这样一个请求时大多数人以为 Spring MVC 会直接找到 OrderController 里对应的方法。实际上请求先到达 DispatcherServlet而 DispatcherServlet 第一步要做的是 getHandler(processedRequest)。这一阶段会根据请求路径去问所有注册到容器中的 HandlerMapping其中 RequestMappingHandlerMapping 负责根据 RequestMapping 注解找具体的 Controller 方法。关键点在于HandlerMapping 找到之后返回给 DispatcherServlet 的并不是一个裸的 Controller 方法而是一个 HandlerExecutionChain。HandlerExecutionChain 这个名字已经说明了一切它不是单个 handler而是一条链。链上至少包含两部分最终要真正执行的 handler通常是一个 HandlerMethod 对象以及一批 HandlerInterceptor 拦截器。你可以把 HandlerMapping 理解成机场的安检通道Controller 方法是那个要登机的乘客拦截器就是通道里一道一道的安检员。只有当所有安检员都放行乘客才能走到登机口。而且这条安检通道并不是由 Servlet 容器直接创建的它是在 DispatcherServlet 内部被组装出来的。也就是说一个请求在到达 Spring MVC 之前其实还有一层 Servlet Filter 在起作用Filter 属于 Servlet 容器拦截器属于 Spring MVC 容器这两者层级不同这也是后文要单独对比的原因。1.2 三个回调方法 preHandle、postHandle、afterCompletion 的时序真相HandlerInterceptor 接口定义了三个方法很多同学只用了 preHandle另外两个从来不关心。但恰恰是后两个方法决定了拦截器在请求异常或提前返回时的行为差异。preHandle 在 Controller 方法之前调用返回 boolean。返回 true 表示放行返回 false 表示直接中断Spring 不会继续调用后续拦截器的 preHandle也不会执行 Controller 方法。postHandle 在 Controller 方法执行完之后、DispatcherServlet 渲染视图之前调用。如果 Controller 直接返回 ResponseBody 结果视图渲染这一步很轻但这个方法依然会被执行。afterCompletion 则在请求结束阶段调用正常返回或异常抛出都可以走时机上类似 try-finally 里的 finally。一个容易忽略的细节是如果 Controller 抛出异常postHandle 不会执行但 afterCompletion 会执行。如果 preHandle 返回 false情况更复杂。假设有三个拦截器 A、B、CA 和 B 的 preHandle 都返回 trueC 返回 false此时 A 和 B 的 afterCompletion 仍会执行但 C 自身的 afterCompletion 不会执行。如果第一个拦截器就返回 false那么不会有任何 afterCompletion 被触发。这个细节不看源码光靠用很难真正掌握后面第五章会展开讲。1.3 拦截器、Filter、AOP三兄弟别互相背锅很多人把 Filter 和拦截器混为一谈觉得都是“请求前后做点事”但在排查问题时混用往往是最危险的。对比维度FilterSpring MVC 拦截器Spring AOP归属层级Servlet 容器Spring MVC 容器Spring 容器生效时机进入 DispatcherServlet 前Handler 执行前后被代理 Bean 方法调用前后能否拿到 HandlerMethod不能能且能拿到方法注解能通过 MethodSignature 拿到方法信息能拦截的范围所有 Servlet 请求DispatcherServlet 分发的处理请求仅 Spring Bean 方法调用典型场景字符编码、跨域、请求日志登录鉴权、权限校验、幂等控制事务管理、缓存、埋点这里最值得说的是“能否拿到 HandlerMethod”。Filter 是 Servlet 规范里的东西它不知道也不关心某个 URL 最终会由哪个 Controller 方法处理。而拦截器是 Spring MVC 内部机制DispatcherServlet 在调用 Controller 方法之前手上已经攥着 HandlerMethod因此拦截器可以很自然地读取方法上的注解、方法参数、类上的注解。这一特性是做精细化权限控制的关键。AOP 则更下沉它作用于 Bean 方法级别但和 HTTP 请求、URL 映射没有直接关系。真正做登录鉴权优先考虑拦截器做跨域和编码用 Filter做业务逻辑增强才轮到 AOP。2. 注册拦截器时最容易被默认值坑到的地方2.1 实现 WebMvcConfigurer 而不是继承 WebMvcConfigurationSupport配置拦截器最标准的写法是定义一个 Configuration 配置类实现 WebMvcConfigurer 接口然后重写 addInterceptors 方法。比如这样Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/login, /css/**, /js/**, /favicon.ico); } }这个写法在 Spring Boot 和传统 Spring MVC 中都能正常工作。让我意外的是很多老项目会写成继承 WebMvcConfigurationSupport再重写 addInterceptors。表面看也能跑但后果很严重一旦继承 WebMvcConfigurationSupportSpring Boot 对 WebMvc 的自动配置会被整体屏蔽包括静态资源映射、消息转换器、默认的 converter 等都会失效。换句话说你可能只是加了一个拦截器结果静态文件全部 404前端拿到的 JSON 时间格式也变了。那有人会问官方早期例子不就是继承 WebMvcConfigurationSupport 吗早期 Spring 时代确实常见但 Spring Boot 出现后推荐方式是实现 WebMvcConfigurer。你可以把 WebMvcConfigurer 理解成“在默认配置基础上做定制”而继承 WebMvcConfigurationSupport 等于“完全接管 WebMvc 配置”。除非你很清楚自己在干什么否则不要轻易走第二条路。2.2 路径匹配/**、/*和 Spring Boot 2.6 之后的匹配策略变化addPathPatterns 和 excludePathPatterns 用的是路径匹配规则不是正则。这里面最常见的一个误伤是/*和/**的区别。/api/*只匹配 /api 下面的一层路径比如 /api/user但匹配不到 /api/v1/user/api/**则可以匹配任意层级包括 /api/v1/user/detail。另外要注意 Spring Boot 2.6 开始路径匹配默认使用 PathPatternParser替代了老的 AntPathMatcher。大多数常用写法比如/api/**、/static/**在两个策略下都兼容。但如果你在拦截器配置里写过一些比较边缘的表达式比如/api/*/list.*这种带后缀的写法就可能在升级之后悄悄失效。排查时最有效的办法是确认当前 Spring Boot 版本然后用最朴素的/api/**、/user/**这种写法别搞花活。还有一个很容易踩的坑是把 context-path 也算进去。假如应用配置了server.servlet.context-path/app前端访问的是/app/api/login但在 addPathPatterns 里写/app/api/**是不对的路径匹配基于应用内部路径应该写成/api/**。这个和拦截器没关系但确实是配置时极容易造成“明明加了但没生效”错觉的地方。2.3 多个拦截器的 order 排序以及打印出来的执行顺序为什么是倒的当项目里有多个拦截器时很多人以为后注册的拦截器就排在后边其实不完全对。每个 InterceptorRegistration 都有 order 属性数字越小优先级越高。比如 AccessLogInterceptor 先注册AuthInterceptor 后注册但 AuthInterceptor 的 order 设成了 1AccessLogInterceptor 设成了 2最终执行时依然是先 Auth 后 AccessLog。Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AccessLogInterceptor()) .addPathPatterns(/**) .order(2); registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/**) .order(1); } }这里有一个挺反直觉的现象如果两个拦截器都注册成功请求进入时 preHandle 按 order 从小到大执行也就是 Auth 在前但请求出去时 postHandle 和 afterCompletion 的执行顺序是反过来的AccessLog 反而先执行。原因在于 Spring 在回调时采用“进栈出栈”的顺序先把所有 preHandle 顺序压栈结束时再逆序弹栈保证外层的拦截器最后清理。想验证的话在三个拦截器里各打一条日志跑一次请求就能看得很清楚。3. 用拦截器做登录鉴权和权限校验时我推荐的落地写法3.1 用 HandlerMethod 拿到方法注解避免无意义拦截早年间很多人写登录拦截器习惯用 URL 前缀判断哪些接口要鉴权。但这种写法太脆一旦 URL 调整权限配置就全乱了。我更推荐的做法是配合自定义注解在拦截器里通过 HandlerMethod 检查方法上有没有标注解标了才做校验。public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod (HandlerMethod) handler; LoginRequired required handlerMethod.getMethodAnnotation(LoginRequired.class); if (required null) { required handlerMethod.getBeanType().getAnnotation(LoginRequired.class); } if (required null) { return true; } String token request.getHeader(Authorization); Long userId parseAndValidateToken(token); if (userId null) { writeJson(response, 401, 未登录或登录已过期); return false; } request.setAttribute(currentUserId, userId); return true; } }注意一个细节handler 不一定永远是 HandlerMethod。访问静态资源时它可能是 ResourceHttpRequestHandler。此时如果不做 instanceof 判断直接强转会发生 ClassCastException然后整个静态资源访问就挂了。所以前置判断是必须的。还有一个容易漏的地方是注解可能放在类级别所以要同时查 method 上的注解和 beanType 上的注解。3.2 白名单与静态资源放行包括 OPTIONS 预检的坑如果拦截器加的是/**那么所有请求都会进来包括静态资源。虽然可以直接 excludePathPatterns(/static/**)但我更建议在 addPathPatterns 阶段就限定范围比如只拦截/api/**。如果一定要匹配全部那么静态资源和 favicon.ico 的排除是必须的。另一个隐藏很深的坑是 CORS 预检请求。前后端分离项目里前端发起带 Authorization 头的请求时浏览器通常会先发一个 OPTIONS 请求做预检。如果你的拦截器把 OPTIONS 也拦下来并且直接返回 401跨域就失败了。你会在控制台看到 CORS error但不知道问题出在拦截器。处理方式有两种要么在 excludePathPatterns 里排除掉预检路径要么在拦截器里对 OPTIONS 请求直接放行并在响应头里把允许的 Headers 写清楚。if (OPTIONS.equalsIgnoreCase(request.getMethod())) { response.setStatus(HttpServletResponse.SC_OK); return true; }这里有个取舍如果项目里已经用 Spring Security 或独立 CORS Filter 处理跨域那拦截器里不需要重复设置 CORS 头避免两边互相覆盖。如果没有统一的安全框架那么拦截器里直接放行 OPTIONS 再补全 CORS 头是一种简单可靠的方案。3.3 前后端分离下拦截器直接回 JSON而不是重定向到登录页传统服务端渲染项目里未登录时拦截器通常重定向到/login。但前后端分离项目里后端只提供接口前端收到未认证响应后自己决定跳转到哪个页面。所以拦截器里最合适的动作是设置 HTTP 状态码为 401再写一段结构化 JSON。private void writeJson(HttpServletResponse response, int code, String message) throws IOException { response.setStatus(code); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\: code , \message\:\ message \}); }更规范的做法是用 ObjectMapper 序列化 Result 对象。同时要注意响应头里的 CORS 字段。如果你在前面 3.2 中没有处理 CORS那么当前端收到 401 时浏览器可能因为响应头缺失而无法读取错误信息前端拿到的永远是网络错误。这块虽然不完全是拦截器的职责但既然在拦截器里直接写响应就要把这些边界条件一起考虑进去。4. 一次耗时很久的 bug拦截器里读请求体Controller 却收到空的 RequestBody4.1 现象复现登录参数第一次能读第二次就为空有一年我们做接口参数签名校验方案是在拦截器里读取请求体然后对签名做一些校验。刚开始本地开发一切正常但联调时发现非常诡异的现象某些接口调用一次能成功再调用一次就提示参数缺失同一个接口换个请求工具又不一定复现。查了很久最后才发现根本原因是请求体被拦截器读了一次。先看一个典型的错误代码Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String body StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8); // 用 body 做签名校验... return true; }这行代码看起来没有任何问题但它会改变请求体的状态。真正的问题在于Servlet 规范里 HttpServletRequest 的 getInputStream 返回的是一个一次性流读完就到底了没有重置机制。4.2 根因定位ServletInputStream 不可重复读要理解为什么 Controller 收不到参数可以想象一卷电影票存根第一次检票时撕掉了一角第二次检票员看到的就只是一张废票。Servlet 的请求流更像一张写满内容的纸一旦被读取到最后后续读者再拿到的就是一片空白。Spring MVC 处理 RequestBody 时底层通过 HttpMessageConverter 读取请求输入流。如果拦截器提前把流读完了消息转换器再读的时候发现流已经处于 EOF自然会得到一个空对象或者直接抛 HttpMessageNotReadableException。这个问题的隐蔽性在于如果拦截器只读取了一部分字节Controller 能拿到残缺数据这就比完全为空更加难以排查。更迷惑的是某些中间服务器或代理可能会缓存请求体导致问题在某些环境正常、某些环境复现这也是它消耗我们很久的原因。4.3 用 ContentCachingRequestWrapper 或其他思路绕过我当时查到资料后第一反应是使用 Spring 自带的 ContentCachingRequestWrapper。但要提醒一句它对“记录日志”有效对“让 Controller 再次读到 body”并没有直接帮助。ContentCachingRequestWrapper 只是把读取过的内容缓存到了内存里方便你事后取出来看它并不会重置流的状态。换句话说拦截器读完之后消息转换器去读包装请求的流依然读不到内容。要让请求体可重复读必须在前置 Filter 阶段把原始请求包装成一个可重复读取的自定义请求对象。下面是我用的思路。public class RepeatableReadRequestWrapper extends HttpServletRequestWrapper { private final byte[] body; public RepeatableReadRequestWrapper(HttpServletRequest request) throws IOException { super(request); this.body request.getInputStream().readAllBytes(); } Override public ServletInputStream getInputStream() { ByteArrayInputStream bais new ByteArrayInputStream(body); return new ServletInputStream() { Override public int read() { return bais.read(); } Override public boolean isFinished() { return bais.available() 0; } Override public boolean isReady() { return true; } Override public void setReadListener(ReadListener readListener) { // 不需要处理异步监听 } }; } Override public BufferedReader getReader() { return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); } public byte[] getBody() { return body; } }然后用一个 Filter 在 DispatcherServlet 之前对请求做包装public class RequestBodyCachingFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String method request.getMethod(); if (POST.equalsIgnoreCase(method) || PUT.equalsIgnoreCase(method) || PATCH.equalsIgnoreCase(method)) { filterChain.doFilter(new RepeatableReadRequestWrapper(request), response); } else { filterChain.doFilter(request, response); } } }再用 FilterRegistrationBean 注册这个 Filter。要注意两点第一不要缓存 multipart/form-data 类型的请求上传文件动辄几十上百 MB直接用字节数组缓存在内存里风险很高第二要设置一个体积阈值超过阈值就放弃包装避免大请求体把内存打爆。因为这个问题我们在业务里还额外加了一条规则能用拦截器读 Header 解决的就不要读 body非要读 body就尽早考虑可重复读包装。5. 源码层面看 HandlerExecutionChain彻底理解 preHandle false 之后的事5.1 doDispatch 里三个关键调用点DispatcherServlet 的核心方法 doDispatch 中有三个关键调用点顺着它们看就能理解拦截器的生命周期。try { mappedHandler getHandler(processedRequest); if (mappedHandler null) { noHandlerFound(processedRequest, response); return; } if (!mappedHandler.applyPreHandle(processedRequest, response)) { return; } mv ha.handle(processedRequest, response, mappedHandler.getHandler()); mappedHandler.applyPostHandle(processedRequest, response, mv); } catch (Exception ex) { dispatchException ex; } finally { mappedHandler.triggerAfterCompletion(processedRequest, response, ex); }这里 applyPreHandle 执行成功才继续调用 handlerapplyPostHandle 在 handler 返回 ModelAndView 之后执行finally 里的 triggerAfterCompletion 则保证请求最终都能走到清理逻辑。注意 applyPreHandle 返回 false 时会直接 return不再进入 Controller这个 return 是 doDispatch 的 return并不是请求被立即终结而是拦截器终止了请求继续向 Controller 传。5.2 preHandle 返回 false后续回调们是怎么被触发的HandlerExecutionChain 内部维护了一个 interceptorIndex它记录当前执行到第几个拦截器。applyPreHandle 的简化逻辑大致如下boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) throws Exception { for (int i 0; i this.interceptorList.size(); i) { HandlerInterceptor interceptor this.interceptorList.get(i); if (!interceptor.preHandle(request, response, this.handler)) { triggerAfterCompletion(request, response, null); return false; } this.interceptorIndex i; } return true; }interceptorIndex 更新发生在某个拦截器 preHandle 返回 true 之后。当第 i 个拦截器返回 false 时这个索引不会更新到当前拦截器而是停留在前一个通过拦截器的位置。triggerAfterCompletion 会从 interceptorIndex 开始逆序调用 already executed interceptors 的 afterCompletion。所以返回 false 的那个拦截器自己不会执行 afterCompletion前面已经通过的拦截器反而会执行清理。这个机制在资源清理时非常重要。比如你有一个关闭 ThreadLocal 的拦截器如果它自身 preHandle 返回 false内部释放 ThreadLocal 的逻辑写在 afterCompletion 里是不生效的。正确做法是在 preHandle 返回 false 之前先做好清理或者用一个专门的拦截器负责清理。5.3 异步请求下的拦截器回调变化Spring 5.3 后尤其注意如果 Controller 返回 Callable 或 DeferredResult请求会进入异步处理流程。此时拦截器的回调时序和同步场景差异很大。同步请求里 postHandle 在业务方法返回后立刻执行但在异步场景下Controller 方法已经返回到 DispatcherServlet 时真正的异步结果可能还没算完。Spring 通过 WebAsyncManager 来管理后续回调这时候 postHandle 的调用时机和调用线程都不再稳定。Spring 4.1 引入了 AsyncHandlerInterceptor其中有一个 afterConcurrentHandlingStarted 方法在请求开始异步处理时被调用。Spring 5.3 之后异步处理模型又有了调整afterCompletion 会在异步结果返回后、再次进入容器分发时被触发。如果你在异步线程里继续使用 ThreadLocal 保存用户信息而后在 afterCompletion 里处理别默认它一定和 preHandle 在同一个线程。更稳妥的做法是需要线程绑定的数据不要依赖拦截器的生命周期而是显式在业务代码里传递上下文对象或者使用 RequestContextHolder 自带的线程绑定机制并在结束点手动清理。6. 压测之后才明白的几条拦截器使用纪律6.1 preHandle 别做重活它占用的是 Tomcat 工作线程Tomcat 的默认工作线程数是有限的假设是 200。当 200 个请求同时进来如果每个请求都在 preHandle 里去查一次数据库、调一次远程服务那么这些工作线程会全部阻塞在 Controller 入口之前后面的请求只能排队。我之前做过一个模拟压测在 preHandle 里加一次平均 50ms 的 Redis 读取结果 TPS 直接掉了三成多。如果这台机器同时还在处理支付回调简直是一场灾难。登录鉴权确实需要查用户信息但可以拆成两级先从 Header 解析 JWT本地验签验签通过就把用户基础信息放到请求上下文里只有对权限要求极高的接口才在业务方法内部做细粒度查询。拦截器里只做轻量、通用、能快速决定放行与否的操作这是第一条纪律。6.2 拦截器是单例成员变量别乱放用户信息Spring 容器默认情况下 Bean 是单例拦截器也不例外。所有请求共用同一个拦截器实例这是一个很容易被忽略的线程安全问题。考虑下面的错误示范public class AuthInterceptor implements HandlerInterceptor { private Long currentUserId; // 严重错误 Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { this.currentUserId parseToken(request.getHeader(Authorization)); return true; } }并发请求 A 和请求 B 同时进来A 把 currentUserId 设为 1001B 把 currentUserId 设为 1002随后 A 在业务方法里读取 currentUserId可能读到的是 1002。解决方式很简单用户信息通过 request.setAttribute 传递或者用 ThreadLocal但使用 ThreadLocal 时必须保证在 afterCompletion 中 remove否则 Tomcat 线程池复用时上一次的线程局部变量会残留在同一个线程上下一次复用线程时拿到上次用户信息造成严重的数据串号。6.3 记录耗时和异常建议放 afterCompletion 而不是 postHandle我在压测之后养成的一个习惯是全程耗时统计放在 afterCompletion 里做。postHandle 只会在 Controller 正常返回后被调用一旦业务方法抛异常postHandle 就没有机会执行而 afterCompletion 可以。这与 try-finally 的语义类似无论正常还是异常都能拿到最终的耗时和异常对象。Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { long start (long) request.getAttribute(startTime); long cost System.currentTimeMillis() - start; log.info({} {} cost {}ms, request.getMethod(), request.getRequestURI(), cost); if (ex ! null) { log.error(request error, ex); } }在 preHandle 里把开始时间放到 request attribute 里afterCompletion 再取出来做计算既避免了成员变量线程安全问题又能覆盖绝大多数场景。有一点要提醒如果响应已经提交在 afterCompletion 里再设置状态码或响应头会抛 IllegalStateException所以不要在这里做“根据异常改响应”的事。统一异常响应应该交给 ControllerAdvice拦截器只负责记录和清理。我在实际项目中踩过不少这样的教训后来给自己定了一条规矩拦截器只做轻量、通用、能快速决定放行与否的事遇到需要更细粒度控制或更复杂资源管理的逻辑要么提前到 Filter要么下沉到业务代码别把拦截器当成万能口袋。这条规矩看起来简单但真的能帮你在下一次排查问题时少掉几根头发。