我们平时聊 Spring Boot 项目,看起来最不起眼、但排查起来最折腾的报错,说实话就是 404。别小看这个状态码,它不是“页面不存在”那么简单,在前后端分离、网关转发、Spring Security 过滤链这些场景凑到一起的时候,一个 404 背后可能是好几种原因叠在一起。这篇文章我想把 Spring Boot 里 404 的来龙去脉讲透,从根因、影响到处理策略,把我自己踩过的坑也一并整理出来。
1. 404 在 Spring Boot 里的真实含义:不只是“找不到页面”
很多人第一次遇到 404 时,第一反应是“是不是路径写错了”。这话没毛病,但在 Spring Boot 的项目里,404 的含义远不止“URI 不对”这么简单。它背后是DispatcherServlet 的映射机制、容器的默认错误页、Spring Security 的拦截规则多套逻辑叠加之后的一个最终结果。
1.1 Servlet 容器收到请求后的路由链条
我们得先理清一件事:Spring Boot 应用跑起来以后,所有的 HTTP 请求先进的是内嵌的 Tomcat(或者是 Jetty、Undertow),然后才轮到 Spring MVC 的 DispatcherServlet。
Tomcat 根据 Servlet 的映射规则,把请求分发给对应的 Servlet。Spring Boot 启动时默认把 DispatcherServlet 映射到/,所以绝大多数请求都会进入 Spring MVC 的分发流程。如果 DispatcherServlet 找不到对应的@RequestMapping处理方法,它不会直接抛异常,而是抛出NoHandlerFoundException,最终由 Spring Boot 的默认错误机制处理,返回 404 状态。
所以,“404 = 路径不存在”是结果,不是原因。真正的原因可能是:
@RequestMapping里的 value 和请求的 URI 对不上;- Controller 没有被 Spring 容器扫描到;
@RestController和@Controller用错,导致返回体异常;- 静态资源访问路径被拦截;
- 请求方法不匹配(比如 GET 请求打到了只允许 POST 的接口上)。
1.2 Spring Boot 默认错误处理机制是怎么兜底的
Spring Boot 提供了一个BasicErrorController,默认在/error路径上处理所有异常。当 DispatcherServlet 找不到处理器时,NoHandlerFoundException会被DefaultHandlerExceptionResolver处理,把响应状态设为 404,然后转发到/error,由BasicErrorController生成 JSON 或者错误页面。
这意味着,如果你看到一个 404,实际的响应体可能是一个 JSON:
{ "timestamp": "2025-01-11T10:15:30.000+00:00", "status": 404, "error": "Not Found", "path": "/api/orders/12345" }这个 JSON 本身非常有价值,它告诉你两件事:第一,请求确实到了 Spring Boot 应用;第二,Spring 的处理链已经跑到了错误兜底这一步。如果连这个 JSON 都看不到,那问题多半出在更外层——比如网关、Nginx 或者浏览器缓存。
提示:排查 404 时,先确认你看到的是应用返回的 404,还是 Nginx/Tomcat 直接返回的 404。后者通常连 Spring Boot 的日志都不会留下。
1.3 404 和 405 的区别容易被忽略
还有一个很常见的混淆点:请求方法不对,Spring MVC 返回的是 405 Method Not Allowed,而不是 404。只有当你访问的 URI 完全匹配不到任何一个@RequestMapping时,才会走到 404。如果你把@PostMapping的接口用 GET 请求去访问,返回的是 405;但如果你在网关层先把方法改写或者转发错了,405 也会被包装成 404,这种“假 404”在微服务架构里特别折腾人。
所以接到一个 404 报错,先别急着改代码,先弄清楚请求到底到没到 Spring Boot 应用,这是整个排查过程的起点。
2. 404 的影响范围:比你想的更伤用户体验
404 看起来只是“访问个不存在的路径”,但在真实业务里,404 的影响往往被低估。尤其在现代 Web 应用里,404 不仅仅是“用户点了一个坏链接”,它可能意味着核心功能不可用、链路中断、数据提交失败,甚至引发用户投诉。
2.1 前端路由与后端接口的耦合问题
现在的前后端分离项目,前端路由通常由 Vue Router 或 React Router 管理。用户在浏览器里直接刷新一个/user/profile这样的页面,如果后端没有做视图回退,Spring Boot 就会返回 404。你可能会说“那是我后端没配这个路由啊”,但对用户来说,他看到的不是“技术性错误”,而是“网站打不开”。
这种情况下,常见做法是后端加一个“非 API 路径回退到 index.html”的处理。但这里有个矛盾:如果你把/的匹配范围放得太宽,那些真正不存在的 API 请求也会被吞到前端路由里,导致接口 404 变成“前端页面正常但数据加载不出来”的怪现象。
2.2 搜索引擎与爬虫的收录影响
如果网站存在大量 404 链接,搜索引擎的爬虫在抓取时会依次记录这些状态码。短时间内大量 404 会导致爬虫对站点整体质量的负向评估,影响收录和排名。很多人只关注首页和详情页的 SEO,忽略了那些因为商品下架、文章删除而遗留的旧链接,这些链接常年返回 404,对站点权重的消耗是持续性的。
从这个角度看,404 处理策略不仅是技术问题,还是业务问题:哪些旧链接需要 301 到新页面,哪些直接返回 410(Gone)表示资源永久删除,哪些保留 404,都需要认真规划。
2.3 微服务场景下 404 的放大效应
在微服务架构里,任何一个服务的 404 都会顺着调用链传递。比如订单服务调用户服务查询用户信息,用户服务返回 404 代表“用户不存在”,订单服务如果直接把 404 透传给前端,前端可能提示“系统错误”,但用户根本不知道到底是“用户没了”还是“接口坏了”。更麻烦的是,如果网关层对 404 做了统一包装,不同服务返回的 404 响应结构可能不一致,前端就得写多种解析逻辑。
所以,处理 404 时最好在网关层做一次标准化,把 Upstream 的 404 映射成统一的响应结构。这样下游服务的 404 语义可以保留,前端的处理逻辑也简单。
注意:不是所有的 404 都需要“修复”。如果业务语义就是“资源不存在”,那么 404 本身就是正确响应。不要为了消灭 404 而把所有路径都匹配到一个空页面上,那会让问题更难暴露。
3. 排查 404 的五个关键切入点
等一个 404 摆在你面前,怎么快速定位?我平时会按下面的顺序来查,能省下不少时间。这几个切入点适用性很强,从单体应用到微服务项目基本都能覆盖。
3.1 第一步:看日志,确认请求是否进入 Spring 容器
排查 404 的第一件事不是看代码,而是看日志。Spring Boot 默认的日志级别下,DispatcherServlet 的请求映射日志未必会打开,但你可以在application.yml里临时调一下:
logging: level: org.springframework.web.servlet.DispatcherServlet: DEBUG调完之后,每次请求进来都会打印类似这样的日志:
DEBUG o.s.web.servlet.DispatcherServlet - GET "/api/orders/123", parameters={} DEBUG o.s.web.servlet.DispatcherServlet - Completed 404 NOT_FOUND如果连第一条日志都没看到,说明请求压根没到 Spring Boot,问题在 Nginx、网关、防火墙或者容器路由上。如果第二条日志显示Completed 404 NOT_FOUND,那问题就在 Spring MVC 内部的映射匹配上。
3.2 第二步:检查 Controller 是否被扫描到
Spring Boot 项目里,主启动类用@SpringBootApplication注解,默认扫描的是启动类所在包及其子包。如果你的 Controller 放在了启动类包之外,比如某个 module 根包路径不一致,Spring 容器根本不会加载这个 Bean,路径自然匹配不上。
检查方法很简单,启动时加--debug参数或者在测试里注入:
@SpringBootTest class ApplicationTests { @Autowired private ApplicationContext context; @Test void checkControllerBeans() { String[] beanNames = context.getBeanNamesForAnnotation(RestController.class); System.out.println(Arrays.toString(beanNames)); } }如果这个数组里没有你预期的 Controller,那问题就出在包扫描上。解决办法可以是给启动类加scanBasePackages参数,或者调整目录结构,让所有需要被扫描的组件都落在启动类所在包的子包下。
3.3 第三步:核对请求方法与路径拼接
路径拼接出问题的情况非常多见。比如 Controller 类上写着@RequestMapping("/api/orders"),方法上是@GetMapping("/details"),那完整路径就是/api/orders/details。如果你在 Feign 客户端或者前端代码里写了/api/orders/+details/,多了一个斜杠,虽然 Spring 的路径匹配规则会自动忽略末尾斜杠,但如果中间路径是//details,在某些版本里就会匹配失败。
参数拼错也会导致 404。比如使用@PathVariable时,路径变量的名称要和{}里的名称一致:
@GetMapping("/orders/{orderId}") public Order getOrder(@PathVariable("orderId") Long orderId) { // ... }如果你写的是@PathVariable Long id,而路径是{orderId},Spring 会因为没法把orderId绑定到参数id上报错,这个 500 错误在某些场景下会被网关统一处理成 404,对排查造成干扰。
3.4 第四步:排查 Spring Security 是否拦截了请求
Spring Security 的过滤器链执行在 DispatcherServlet 之前。如果你的接口路径在 Security 配置里被permitAll或authenticated规则覆盖,通常情况下是返回 401 或 403,但有一种情况很特殊:如果HttpSecurity配置里对某些路径做了permitAll,但对应的 Controller 不存在,请求最终还是走 404。
反过来,如果 Security 配置里有一个路径拦截器没有配置,请求被放行了,但 Controller 也没匹配上,那依然是 404。更让人困惑的是,在 Spring Security 6 的配置迁移过程中,antMatchers被替换为requestMatchers,写法写错会导致请求方法不匹配或路径匹配失效,进而出现 404 假象。
我建议在排查 404 时,把 Spring Security 的日志级别也临时调低:
logging: level: org.springframework.security: DEBUG顺便看一眼请求是否有经过 Security 过滤器链,以及最终是哪个 Filter 决定放行或拒绝。
3.5 第五步:确认路径前缀是否被全局配置修改过
还有一个隐藏很深的坑:server.servlet.context-path。如果你在application.yml里配置了:
server: servlet: context-path: /app那么所有接口的实际访问前缀都会变成/app/api/...。你在本地测试时如果直接用/api/...去访问,就会得到 404。这种问题特别容易出现在多人协作时——本地没配 context-path,但测试环境或网关层重写了路径前缀,导致本地能跑、环境上就 404。
4. 处理 404 的五种策略与落地配置
定位到了 404 根因之后,怎么处理?这取决于业务场景。我把常见的处理策略分成五类,你可以根据实际情况灵活用。
4.1 策略一:自定义全局 404 响应体
默认的BasicErrorController返回的 JSON 结构比较简单,但很多团队希望格式统一,方便前端全局处理。这时可以自定义错误响应结构。
最直接的办法是继承BasicErrorController或者实现ErrorController,不过现在更推荐用@RestControllerAdvice配合ErrorAttributes来做。实际项目中,我一般直接实现ErrorController,简单也够用:
@RestController public class CustomErrorController implements ErrorController { private final ErrorAttributes errorAttributes; public CustomErrorController(ErrorAttributes errorAttributes) { this.errorAttributes = errorAttributes; } @RequestMapping("/error") public ResponseEntity<ErrorResponse> handleError(HttpServletRequest request) { WebRequest webRequest = new ServletWebRequest(request); Map<String, Object> attrs = errorAttributes.getErrorAttributes(webRequest, ErrorAttributeOptions.defaults()); int status = (Integer) attrs.getOrDefault("status", 500); String path = (String) attrs.getOrDefault("path", ""); String message = (String) attrs.getOrDefault("error", "Internal Server Error"); ErrorResponse response = new ErrorResponse(status, message, path, System.currentTimeMillis()); return ResponseEntity.status(status).body(response); } }这样前端在任何情况下拿到的 404 响应体都是统一结构,不用为上架、网关、应用层分别写解析逻辑。
4.2 策略二:开放静态资源路径避免 404
如果你的项目需要直接访问static目录下的图片、JS、CSS 文件,Spring Boot 默认会从classpath:/static/去映射资源。但你如果在 Security 配置里拦了/static/**,又没有放行,那就会返回 404。很多人以为是静态资源路径配错了,其实是被安全过滤器拦截了。
解决办法是在 Security 配置里放行静态资源:
http.authorizeHttpRequests(auth -> auth .requestMatchers("/static/**", "/favicon.ico", "/error").permitAll() .anyRequest().authenticated() );另外,如果使用了自定义拦截器(HandlerInterceptor),也要注意excludePathPatterns是否覆盖到静态资源路径,否则静态资源的请求虽然进了 Spring MVC,但会被拦截器直接返回 404。
4.3 策略三:前端路由回退的坑与正确做法
单页应用刷新 404 的问题前面提到过,这里给出一个相对稳妥的方案。核心思路是:如果请求的路径不是 API 或静态资源,就回退到index.html。但要注意,回退不能干扰到/error路径和/api路径。
可以分两步:
第一步,配置资源映射,把前端构建产物放在static目录下;第二步,在 Spring MVC 中配置一个 ViewController 做回退:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/{spring:[^\\.]*}") .setViewName("forward:/index.html"); } }但用这个写法要注意:/{spring:[^\\.]*}这个正则匹配的是不含点的路径,如果访问的是/api/orders这种路径,它也会匹配上,导致 API 请求被转发到index.html。所以更稳妥的做法是在这个 ViewController 上面再挂一个PathMatchConfigurer的规则,或者干脆在网关 / Nginx 层做 try_files 回退,比在 Spring 里硬凑正则简单很多。
4.4 策略四:注册 Filter 包装 404 响应
有些时候,404 是在 Filter 阶段被返回的,还没有走到 Spring MVC 的异常处理器。这种情况下,@RestControllerAdvice是无法捕获的。你需要在 Filter 里对HttpServletResponse的状态码做检查,或者在容器层配置自定义错误页面。
不过说实话,如果不是特别复杂的场景,不太建议在 Filter 层做太多包装。因为 Filter 层拿到的信息有限,处理不好反而会掩盖问题。我更倾向于在网关层做响应标准化。
4.5 策略五:区分 404 和业务上的“资源不存在”
这一点我觉得最有必要单独说。业务上“资源不存在”应该返回 200 还是 404?其实要看接口语义。如果你写的是查询类接口,比如根据订单 ID 查询订单,订单不存在时,我通常会选择返回 404,因为 REST 语义就是这样。但如果你的接口是“批量查询订单列表”,其中某些订单不存在,那不应该整个请求都返回 404,而应该在响应体里逐条标记哪些存在、哪些不存在。
很多团队为了避免前端处理麻烦,所有业务异常都返回 200,然后把错误码放在响应体里。这种做法短期省事,长期会让接口语义混乱,也让监控报警没法通过状态码快速区分问题类型。我的建议是:对外 API 遵循 REST 语义,该 404 就 404;内部服务之间可以根据约定使用 200 + 业务码,但一定要在文档里写清楚。
5. 一个实战案例:请求被全局异常处理吞掉的 404
说一个我自己遇到的比较典型的场景。某次一个同事说,他的接口明明存在,但前后端联调时一直报 404。我第一反应是路径不对,结果我打开 Swagger 文档,接口就在那里,文档里的路径也完全一致。
然后我打开了 DispatcherServlet 的 DEBUG 日志,发现请求压根没进 Spring MVC。继续查,发现 Nginx 把/api/orders/query这个路径按前缀匹配转发到了另一个老服务上。老服务里没有这个接口,所以返回了它自己的 404 页面。也就是说,这个 404 根本不是我们新服务的响应,而是旧服务的。
这类问题在微服务改造过程中特别常见:网关路由配置旧,服务拆分后路径没有及时更新,或者新旧服务并行共存时路由规则冲突。排查这类问题,光盯代码没用,最好是在网关层打印每次转发的目标地址,或者从入口开始逐跳抓包确认。
还有一次,同事遇到 404,查了半天发现是他在 Controller 里写了@RequestMapping(value = "/order"),但方法上又写了@GetMapping("/order/detail"),完整路径是/order/order/detail。前端代码里写的是/order/detail,请求自然匹配不上。这种“拼接路径时没注意类上的前缀”的失误,新手容易犯,老手偶尔也会手滑。
个人经验:写 Controller 时,先养成分层设计路径的习惯。类上的
@RequestMapping用复数名词,比如/users、/orders,方法上的路径用动作或资源子类型,比如/batch、/{id}/details。这样层次清楚,不容易拼出重复路径。
6. 常见问题速查表
下面这个表基本上是我这几年排查 404 的总结,你可以直接存下来当参考。遇到 404 时对着这个表一条条过,大多数情况都能定位。
| 现象 | 可能原因 | 排查建议 |
|---|---|---|
| 接口文档有路径,但访问返回 404 | Nginx/网关路由未更新或转发到旧服务 | 检查网关日志,确认请求最终落到哪个服务 |
| 本地正常,测试环境 404 | context-path 不一致或环境变量覆盖 | 对比各环境application.yml,确认 context-path |
| 所有接口都 404,静态资源也 404 | DispatcherServlet 没有被映射 | 检查启动类配置和 Servlet 容器初始化 |
| 特定某个接口 404,其他正常 | 路径拼接错误或 Controller 未被扫描 | 使用 DEBUG 日志查看请求映射,检查包扫描范围 |
| 刷新前端页面 404 | 后端没有做 SPA 路由回退 | 配置 ViewController 或 Nginx try_files |
| 带点参数的路径 404 | 正则匹配或后缀问题 | 检查PathMatchConfigurer和后缀匹配设置 |
| Feign 调用服务返回 404 | 调用方和服务方路径不一致 | 对比 Feign 注解中的服务名、路径前缀和实际接口路径 |
| Spring Security 开启后 404 | Security 过滤链路径放行异常 | 打开 Security 日志,检查请求经过的过滤器链 |
| Swagger 能调用但前端 404 | 前端请求路径带额外前缀 | 检查前端代理配置或网关重写规则 |
7. 最后的实操建议
处理 404 这件事,说到底不是“改一个状态码”那么简单,它考验的是你对整个请求链路的理解。我个人的体会是:遇到 404 时,先往后看一层,不要急着改代码。从入口到出口,把整个链路在脑子里过一遍,确认请求最终落在哪个节点返回的 404,再动手定位问题,往往比盲目调代码高效得多。
另外一个很实用的习惯:在项目里加一个/health接口,并且保证它永远不被安全策略拦截、不被网关路由改写。这样当你怀疑某个环境有问题时,先用 curl 打这个接口,确定应用还活着、网关还通着,再继续查其他 404 原因。这个小技巧不值钱,但能省很多时间。
最后再分享一个小技巧:如果项目里已经定了统一的 404 响应结构,可以在前端全局拦截器里对 404 做统一引导,比如“页面不存在或已下线”,并附上回首页的按钮。这样用户至少不会一脸懵,反馈问题的概率也会低很多。404 不是洪水猛兽,把它的语义处理清楚,反而能让系统更稳定。