1. 先别慌,这个“重新打包就404”到底是什么问题
先说结论:SpringBoot项目从war包方式部署到Tomcat,这个路线本身没毛病,但“之前好用、重新打包就404”这个现象,九成不是代码逻辑变了,而是部署环境的上下文、打包产物结构、容器版本兼容性这三者之间出现了错位。我处理过不少这类问题,基本每次都能在半小时内定位到根因。
先明确一下这个场景:你有一个SpringBoot项目,之前用war包部署到服务器上的Tomcat,一切正常。后来因为改了功能,重新执行了package,把新的war包丢到Tomcat的webapps目录,重启Tomcat,结果访问路径直接404。页面没有任何页面级的报错,只是Tomcat返回的经典404,或者更糟——静态资源能访问、接口全挂,又或者整个应用都进不去。
这个问题的价值不在于“怎么修”,而在于“为什么之前好用的东西,重打一次包就裂了”。把这条链路彻底理清楚,以后你再遇到war部署问题,就能像条件反射一样快速定位。
先说一个常见的误区:很多人一看到404就去看controller、去查路由配置,这是走偏了。真正要排查的是war包部署的完整生命周期——打包阶段、容器识别阶段、上下文路径解析阶段、应用内部路由阶段。每一层都有可能产生404,但它们的症状细节完全不同。
下面我按实际排查顺序,把这四个阶段逐层拆开,每一层写清楚原理、验证方法、坑点,最后给一个可以直接照着用的排查清单。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 打包层面的核心细节:war包不是“打出来”就完事的
2.1 pom.xml里最容易被忽略的三个配置
如果你用的是SpringBoot的Maven插件打包war,那么pom.xml里这几处配置直接决定war包能不能被外部Tomcat正确识别。
第一处是打包方式,必须是你需要确认的第一件事:
xml复制<packaging>war</packaging>
如果这里写着jar,那你打出来的还是jar包,哪怕你把扩展名改成war,Tomcat也不会按web应用的标准去解析它。更隐蔽的情况是——你同时存在多个module,父pom或者某个子module的packaging被覆盖成了jar,导致整体打包产物异常。
第二处是SpringBoot Maven插件里的mainClass配置。这个不是必须的,但如果你在主类上做了特殊处理,比如继承了SpringBootServletInitializer但类名和默认扫描规则不一致,那就要显式指定:
xml复制<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.example.Application</mainClass>
</configuration>
</plugin>
第三处是依赖scope的处理。如果你的项目里显式依赖了tomcat-embed-core(通常不会,但有些项目为了做内嵌调试会加),那么打包时要把scope设成provided:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
这里面的原理其实不复杂:SpringBoot应用有两种运行模式。一种是自己直接java -jar跑,用的是项目内嵌的Tomcat;另一种是打包成war丢进外部Tomcat,此时应用本身的内嵌Tomcat必须“让位”,外部Tomcat会用自己的容器来加载应用。如果内嵌Tomcat的依赖也被打进了war,轻则类冲突,重则启动失败或者Servlet容器初始化错乱,表现可能就是404甚至500。
2.2 启动类的继承关系对部署的影响
SpringBoot项目在war部署模式下,启动类有一个硬性要求:必须继承SpringBootServletInitializer,并重写configure方法。
java复制@SpringBootApplication
public class Application extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(Application.class);
}
}
这个类的作用是告诉外部Tomcat:“在容器启动我这个应用时,用SpringApplicationBuilder来引导Spring容器”。如果你没继承这个类,war包也能扔进Tomcat,Tomcat会尝试按传统Java Web应用的方式去加载WEB-INF下的内容。如果你的项目里还有传统的web.xml,可能还能凑合跑;但绝大多数SpringBoot项目没有web.xml,此时Tomcat加载不到Spring上下文,应用的任何请求都匹配不到DispatcherServlet,于是所有的URL请求都是404。
这里就出现了一个经典的“之前好用,重新打包就404”的场景:项目之前可能确实有正确的启动类继承,但某个版本迭代时,有人优化了包结构、把启动类移动了包路径,或者重命名了启动类。外部Tomcat加载应用时,不再按照之前的位置扫描到它,Spring容器没有被初始化,久而久之你访问任何路径都是404。
还有一个更隐蔽的情况:如果项目里同时存在多个类继承了SpringBootServletInitializer,Tomcat会启动报错或者随机选择一个。这种多启动类的情况通常出现在代码合并时没有清理掉旧的启动类,建议看一眼target目录下解压后的web应用里,WEB-INF/classes/xxx/Application.class是否存在且唯一。
2.3 从打包产物判断是不是“失效war包”
不要等扔到Tomcat里才发现问题,本地直接解压war包检查是最快的验证方式。war包本质上是一个zip压缩包,直接把它后缀改成.zip解压,或者用解压工具打开看。里面正常情况下必须有这些东西:
text复制WEB-INF/
classes/
com/example/Application.class
application.yml
lib/
spring-boot-xxx.jar
spring-webmvc-xxx.jar
...
如果打开war包发现没有WEB-INF目录,或者WEB-INF下没有classes目录,只有一堆零散的class文件和静态资源,那基本可以断定这个war包构造有问题。最常见的原因是用SpringBoot自带的spring-boot-maven-plugin打包时,配置不对导致产出了一个“可执行的war包”但缺少标准Web应用的结构。
SpringBoot有一个特性叫“可执行war包”(executable war),就是war包也能通过java -jar来运行。这个特性在打包时会在war包根目录生成一个org/springframework/boot/loader目录,并且把内嵌Tomcat的依赖也打进去。这样的war包扔进外部Tomcat时,Tomcat会按照标准web应用去加载它,而它又自带了SpringBoot的Launcher逻辑。在大多数情况下这个不会出问题,但如果你同时改了SpringBoot版本或者Tomcat版本,很容易因为Launcher和外部容器的兼容性差异产生各种诡异问题,404就是其中之一。
如果确认是这种“可执行war包”导致的问题,最简单的处理方式就是在pom.xml里配置一下插件参数:
xml复制<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<executable>false</executable>
</configuration>
</plugin>
这样打出来的war包就是纯正的、标准结构的war包,可以最大限度降低外部容器加载时的干扰因素。
3. Tomcat部署层的经典踩坑:版本、路径、依赖冲突
3.1 Tomcat版本与SpringBoot版本的适配关系
这个坑相当隐蔽,因为报错不会很直接,Tomcat日志可能只是正常启动、正常加载应用,但访问任何URL都404。
SpringBoot版本和外部Tomcat版本之间没有硬性的绑定关系,但存在兼容范围的软性约束。比如SpringBoot 2.x系列是基于Servlet 3.1+特性的,如果你的Tomcat是老版本(比如7.x),Servlet API版本偏低,Spring MVC的某些匹配规则和路径解析就会不正常。最典型的表现就是:应用能启动、页面能加载首页,但接口路由全部404,或者说POST请求404、GET正常。
反过来也有问题:Tomcat版本太新(比如Tomcat 10.x),它默认采用的是Jakarta EE 9+的命名空间(javax.servlet变成了jakarta.servlet),而SpringBoot 2.x使用的是javax命名空间,这种情况下不是404的问题了,直接ClassNotFoundError,应用根本起不来。
这里有一个实用的匹配建议:
| SpringBoot版本 | 推荐外部Tomcat版本 | 说明 |
|---|---|---|
| 2.3.x及以下 | Tomcat 8.5/9.0 | javax命名空间,稳妥 |
| 2.4.x - 2.7.x | Tomcat 9.0 | 建议最低8.5+ |
| 3.0.x及以上 | Tomcat 10.1+ | 必须Jakarta命名空间 |
你如果之前用的是SpringBoot 2.3.x + Tomcat 8.5,一切正常。后来升级了项目里的SpringBoot到2.7.x,重新打包丢到原来的Tomcat 8.5上,这就是一个很经典的隐形坑。虽然Tomcat 8.5理论上还能跑,但SpringBoot 2.7里很多默认行为已经变了,特别是路径匹配策略。
我实际遇到过这样一个案例:SpringBoot从2.4版本开始,默认的路径匹配策略从AntPathMatcher切换成了PathPatternParser。如果你的代码里自定义了拦截器、过滤器,或者覆盖了WebMvcConfigurer里关于路径匹配的配置,新旧策略的差异就会导致某些路由匹配不上,表现就是老的接口404。解决方案是显式改回旧策略:
yaml复制spring:
mvc:
pathmatch:
matching-strategy: ant_path_matcher
这个配置在SpringBoot 2.6+以后尤其重要,因为默认值已经切换。很多老项目在升级后只改了依赖版本,没有加这一行,就会遇到接口404的问题。
3.2 context path与部署路径不匹配导致的404
这个应该是war部署404里占比最高的原因,而且特征非常明显:你访问Tomcat的根路径,比如http://ip:8080/,能看到Tomcat默认的欢迎页,但访问你应用的名字,比如http://ip:8080/myapp/,404。或者反过来,你访问http://ip:8080/xxx/能打开,但内部资源路径全挂了。
原理其实很简单:Tomcat部署war包时,默认会以war包的文件名作为应用的上下文路径(context path)。比如你的war包叫myapp.war,那部署后的访问根路径就是http://ip:8080/myapp/。
但SpringBoot应用本身在配置里可能有一个server.servlet.context-path属性。假设你的SpringBoot应用里配置了:
yaml复制server:
servlet:
context-path: /myapp
那么应用内部的所有路由都会带上这个前缀。如果同时你又把war包命名为myapp.war,Tomcat的部署路径也是/myapp,最终你访问的URL就变成了http://ip:8080/myapp/myapp/,这时候如果你访问的是外层的/myapp/,看起来就是404,或者能打开但内部资源全乱。
这个问题的坑点在于——之前好用的项目,很可能没有显式配置context-path,一切依赖Tomcat默认的部署路径。后来某次发布时,有人为了区分环境,在application.yml里加了context-path配置,或者改了war包的文件名。这两个动作只改了其中一个,另一个没跟着改,404马上就冒出来了。
解决思路很简单,二选一:要么把war包文件名改成和context-path一致,要么把application.yml里的context-path去掉。我建议优先去掉应用内的context-path配置,让war包的文件名成为唯一的路径来源,这样最直观、最好排查。
还有一个变种:如果你不想用war包文件名作为访问路径,想让应用直接部署在根路径(http://ip:8080/),可以把war包改名为ROOT.war扔进webapps,然后注意清理掉webapps下面原本的ROOT目录,否则会被覆盖或者冲突。这个方案在前后端分离的场景很常用,因为部署在根路径方便做反向代理的路径转换。
3.3 外部Tomcat的lib与war内嵌依赖冲突
这个坑的表现是:应用能启动,但某些功能404,控制台或日志中出现NoSuchMethodError、NoClassDefFoundError、ClassCastException之类的错误。
原理是这样的:外部Tomcat在加载web应用时,类加载器采用的是“父优先”模式还是“web应用优先”模式,取决于Tomcat的配置。默认情况下,Tomcat 9的WebappClassLoader会优先加载WEB-INF/lib下的类,但某些场景下如果Tomcat的lib目录里本身就存在同名同版本的jar,就会走父加载器加载,两边的类不是同一个ClassLoader加载的,Spring的组件扫描或者反射就会出问题。
最常见的冲突jar包括:servlet-api(这个不能出现在WEB-INF/lib下)、jsp-api、tomcat相关的内置jar。如果你的war包里意外包含了servlet-api.jar,而Tomcat的lib下也有一个,就会出现“一个类由两个ClassLoader加载”的情况,Spring容器初始化时扫描注解可能一切正常,但实际请求进来时,DispatcherServlet去匹配HandlerMapping的过程中出现类型判断异常,最终直接404。
验证方法很简单:解压war包,看WEB-INF/lib下有没有servlet-api.jar、jakarta.servlet-api.jar这类文件名。如果有,去掉这些依赖再重新打包。这类依赖通常是因为pom里显式引入了,或者某个传递依赖带进来的。
还有一个容易踩的坑是WebSocket相关依赖。外部Tomcat对WebSocket的初始化有自己的方式,如果你打包时把Tomcat的内嵌WebSocket jar也打进去了,启动阶段可能不报错,但后续某些请求会404或500,而且日志特别隐蔽。
3.4 多个war包共存导致的资源占用问题
这种情况在开发服务器上特别常见:webapps目录下不仅有你的项目war包,还有其他历史项目的war包。Tomcat启动时会按字母顺序或者文件修改时间顺序去部署这些应用。
如果你的应用依赖了某些第三方库,而这些库在另一个应用里也存在,并且两个应用用的版本不一致,就可能出现“先启动的应用把老版本的类加载了,后启动的应用加载到的还是老版本类”。这通常表现为启动无异常,但某些新版本才有的API调用直接NoSuchMethodError,从而路由匹配不上返回404。
遇到这种场景,我通常建议把webapps目录下的无关war包先移走,单独只留当前项目再测一次。如果单独部署没问题,说明是多应用共存导致的类加载冲突;如果单独部署还是404,那就回到前面的步骤继续排查。这个操作虽然简单,但能快速缩小排查范围,值得先试。
4. 静态资源、接口路由与前端页面的404细分
4.1 静态资源404:路径前缀与资源映射双双出错
如果你的应用能打开首页(比如index.html),但css、js、图片全挂,那么问题大概率出在静态资源路径上。
SpringBoot的静态资源默认放在classpath:/static/目录下。在war部署模式下,这个目录对应的是war包里的WEB-INF/classes/static/。如果你把静态资源放在了src/main/webapp/下,它们会被打包到war包的根目录,此时访问路径不含classpath前缀,直接是http://ip:8080/app/js/app.js这样的路径。
两种方式都能用,但它们在路径解析上有差异。如果项目之前是第一种方式(resources/static),后来有人把静态资源挪到了webapp目录,或者反过来,就会导致一部分资源正常、一部分404。这个问题的排查最快的方式是解压war包,看非WEB-INF目录下有没有js/css文件,以及WEB-INF/classes/static下有没有对应的文件,对照一下代码里引用的路径,基本一眼就能看出差异。
另外SpringBoot有一个坑:如果你的Controller里定义了一个@RequestMapping("/"),它会覆盖掉静态资源的默认映射,导致所有静态资源访问返回404。这种情况常见于新增了首页跳转逻辑时,代码如下:
java复制@Controller
public class IndexController {
@RequestMapping("/")
public String index() {
return "index";
}
}
如果你的项目里原本没有这样的Controller,后来加了一个,那么Spring MVC的默认静态资源处理(ResourceHttpRequestHandler)就失效了,静态资源全会404。解决办法是不写这个映射,或者显式放行静态资源:
java复制@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/");
}
4.2 REST接口404:反斜杠、大小写与路径匹配策略
接口404和页面404不一样,页面404可能只是找不到资源,接口404通常意味着路由匹配失败。这里有几个高频原因:
第一,HTTP方法不匹配。你定义了@GetMapping("/user"),请求用的是POST,Spring MVC返回405,但有些时候浏览器或者代理会吞掉405,直接表现为404。排查时可以先用curl带上方法测试:
bash复制curl -X GET http://ip:8080/app/user
curl -X POST http://ip:8080/app/user
第二,路径变量类型不匹配。比如@PathVariable("id") Long id,你传了一个非数字的字符串,Spring MVC在类型转换失败时返回400,但如果路由配置和实际路径差异过大,会直接落到404。这个在SpringBoot 2.6以后的PathPatternParser策略下表现得更明显,因为新的匹配器对斜杠的处理更严格。
第三,URL末尾斜杠的差异。默认情况下,Spring MVC对/home和/home/会当成不同路径处理。如果你在从前台发请求时加了末尾斜杠,而后端路由没有配置斜杠匹配,就会404。老项目遇到这种问题,可以全局配置忽略末尾斜杠:
yaml复制spring:
mvc:
pathmatch:
use-suffix-pattern: false
这个配置和use-trailing-slash-match没有直接关系,在较新的版本里,你可以通过WebMvcConfigurer覆盖configurePathMatch来实现:
java复制@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.setUseTrailingSlashMatch(true);
}
4.3 前后端分离项目里的SPA路由404
也许你的项目不是那种MVC渲染页面的传统项目,而是前后端分离的:后端提供REST接口,前端构建产物(dist目录)由SpringBoot托管,或者由Nginx托管。如果前端是Vue/React这类单页应用,并且开启了history模式路由,那么刷新某个子路径时,服务器上找不到对应的物理文件,就会返回404。
这个问题的典型场景是:你部署上去后,访问首页没问题,点击路由跳转也正常,但只要手动刷新页面或者从地址栏直接访问http://ip:8080/app/user/list,马上404。
原因很简单:前端路由是JS在浏览器端管理的,服务器上根本没有/user/list这个物理路径。Tomcat收到请求后,发现映射不到任何Controller,也没有对应文件,就返回404。
SpringBoot托管前端静态资源时,解决方法是配置一个404转发到index.html。可以加一个Controller:
java复制@Controller
public class SpaRedirectController {
@RequestMapping(value = {"/**/{path:[^\\.]*}", "/{path:^(?!api$).*$}/**/{path:[^\\.]*}"})
public String redirect() {
return "forward:/index.html";
}
}
但这个写法要小心,不能把所有路径都转发了,否则/api/xxx接口404时也会返回index.html,导致前端页面打出来了但接口报错,排查起来很迷惑。建议配合一个全局异常处理器或者拦截器,对/api前缀、静态资源后缀(js/css/png等)做排除。
如果前端是部署在Nginx上的,那问题更常见——Nginx的try_files配置没有包含history模式的回退。最典型的Nginx配置是这样的:
nginx复制location / {
try_files $uri $uri/ /index.html;
}
如果你用的是前后端分离部署,并且Nginx把/api反向代理到后端Tomcat,那么后端Tomcat层面通常不会涉及这个问题。但如果你把前端dist直接放在了SpringBoot的static目录下,就要按上面的方法处理。
4.4 热词里提到的“若依框架前端部署到nginx里面f5刷新会404”
顺带提一句,热词里有一条“若依框架的前端部署到nginx里面f5刷新会404”,这个问题本质就是我上面说的SPA路由刷新404。若依前端是Vue写的,用history模式路由。如果你把它部署到Nginx,只配了一个简单的root + index配置,刷新就会404。解决办法就是上面那个try_files回退到index.html。
额外提醒一点:如果用了CDN或反向代理,还需要确保缓存策略正确。否则刷新时如果缓存了旧的index.html或者旧的资源路径,也可能出现404,这个和SpringBoot本身无关,但排查时容易混淆。
5. 快速定位:从日志、命令和文件结构三个方向同时入手
5.1 Tomcat日志里怎么看关键信息
遇到404,第一反应不应该是改代码,而是去看日志。Tomcat的日志主要分布在两个位置:一是$CATALINA_HOME/logs目录,二是你的应用本身的日志输出位置。
logs目录下最关键的文件是catalina.out(或catalina.日期.log)和localhost.日期.log。localhost日志记录了每个web应用的部署细节,如果应用启动失败或者Servlet初始化失败,这里一定会有异常堆栈。
还有一个很隐蔽的细节:每次重新部署war包,Tomcat默认会先解压war包到同名的目录。如果你在webapps下看到旧目录没有被清掉,而新war包解压失败(比如war包损坏、磁盘空间不足),Tomcat会继续加载旧的解压目录。此时你访问的是老代码,当然可能404,因为新代码里应用路径变了或者context-path变了。这种情况的解法是:停Tomcat,手动删掉webapps下的旧目录和同名war包,再重新放新war包启动。
查看当前生效的解压目录,可以直接用命令:
bash复制ls -lt $CATALINA_HOME/webapps/
看目录的修改时间和war包的时间是否一致。如果不一致,大概率加载的是旧目录。
5.2 直接请求接口,绕过前端干扰来定位
在排查404时,我强烈建议不要只依赖浏览器。浏览器有缓存、有SPA路由干扰、有反向代理的一堆逻辑,很容易误导你。用curl直接请求后端接口,是最快的方式。
bash复制# 直接请求应用上下文根路径
curl -I http://ip:8080/myapp/
# 请求某个具体接口
curl -I http://ip:8080/myapp/api/v1/user
# 带上Host头,模拟域名访问
curl -I -H "Host: www.example.com" http://127.0.0.1:8080/myapp/api/v1/user
如果curl返回404,说明问题在后端Tomcat或应用内部;如果curl返回200,但浏览器访问404,说明问题在浏览器、Nginx或DNS层。这个区分能帮你把排查范围缩小一大半。
另外,观察404响应体的内容也有讲究。Tomcat自带的404页面是一个简单的猫脸logo页面,上面写着HTTP Status 404 – Not Found。SpringBoot自带的404响应则通常是一段JSON或者白标签错误页(Whitelabel Error Page)。如果你看到的是白标签错误页,说明请求已经进入了Spring容器,但没匹配到任何Handler;如果你看到的是Tomcat猫脸页面,说明请求压根没进到Spring容器。这个细节极其重要,简直就是分界线——前者查路由配置,后者查部署结构。
5.3 war包反编译与直接查看包结构的实用经验
热词里提到了“war包反编译工具”,这个在实际排查中确实很有用,尤其当你手上只有服务器上的war包,没有源码,或者源码和实际部署的版本对不上时。
排查步骤通常是:把war包拷贝到本地,解压,重点检查这几个位置:
- WEB-INF/classes/application.yml或application.properties:看关键配置,比如context-path、server.port是否和预期一致。
- WEB-INF/classes/com/xxx/Application.class:确认启动类存在,而且类名没有变化。
- WEB-INF/lib下的spring-boot-starter-tomcat相关jar:确认是否有内嵌Tomcat依赖被打进来。如果有,说明打包时scope没有设为provided。
- META-INF/MANIFEST.MF:看Main-Class属性,如果显示的是org.springframework.boot.loader.JarLauncher或WarLauncher,说明这是可执行war。
如果你需要反编译class文件来确认代码逻辑,常见的工具是JD-GUI、Luyten,或者在命令行里用javap:
bash复制javap -c -p WEB-INF/classes/com/example/Application.class
对于查询某个类有没有某个方法,javap就够了,不一定非要完整反编译成Java源码。
5.4 热词里“springboot jdk1.8打包到docker desktop”的关联
热词里还有一条“springboot jdk1.8打包到docker desktop”,这个和war部署404也有隐含关联:你在本地开发环境用的是JDK 8,打包时如果用了更高版本的JDK(比如本机装了JDK 17,Maven默认用它编译),而服务器上跑Tomcat的JRE是JDK 8,那么编译后的字节码版本对不上,应用加载时可能会出ClassFormatError或者UnsupportedClassVersionError,严重情况下直接跳过Servlet初始化,最终404。
检查方法很简单,看MANIFEST.MF或者用javap看一下class文件的major version:
bash复制javap -v WEB-INF/classes/com/example/Application.class | grep "major"
如果major version是61(JDK 17),而服务器是JDK 8,就不能跑。Java 8对应major version 52。这种情况解决方式是统一Maven编译JDK版本,在pom.xml里显式指定:
xml复制<properties>
<java.version>1.8</java.version>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
至于Docker Desktop部署,本质是一样的,只是Docker镜像里的JDK版本可能和宿主机不一致。排查方式就是进容器里执行java -version确认一下。
6. 常见问题速查表
整理一份可以直接对着查的表,这些是我在war部署404排查中遇到最多的场景:
| 症状 | 优先排查方向 | 解决思路 |
|---|---|---|
| 访问根路径显示Tomcat欢迎页,应用完全进不去 | war包是否成功部署、启动类是否继承SpringBootServletInitializer | 查日志确认deploy是否成功,检查启动类结构 |
| 能打开首页,但静态资源全挂 | 是否新增了@Controller映射"/"路径 | 显式配置ResourceHandler放行静态资源 |
| 接口404,但页面能打开 | 路径匹配策略、context-path前缀不一致 | 检查application.yml的context-path和实际部署路径 |
| 刷新页面才404,点击跳转正常 | SPA路由History模式未支持 | Nginx加try_files回退,或SpringBoot转发到index.html |
| 启动无报错,但之前还能用的接口全404 | SpringBoot版本升级后路径匹配策略变化 | 显式设置spring.mvc.pathmatch.matching-strategy=ant_path_matcher |
| 重启后第一次访问404,刷新后正常 | 缓存的旧页面或反向代理缓存 | 清浏览器缓存、Nginx缓存,检查Cache-Control头 |
| 页面404但接口正常 | 静态资源打包位置不对 | 确认静态资源是放在resources/static还是webapp |
7. 一套完整的、按顺序执行的排查流程
如果你现在正被这个问题卡着,我建议按下面的顺序走,不要跳步,每一步都有明确的目的:
第一步,确认部署状态和访问路径。先用curl请求应用的根路径,观察返回的是Tomcat猫脸404还是Whitelabel 404,同时打开localhost日志看部署是否成功。这一步看的是“请求有没有进入Spring容器”。
第二步,解压war包,检查包结构。重点看WEB-INF目录是否存在、classes下启动类是否存在、lib下有没有内嵌Tomcat依赖。这一步排查“包到底是不是一个标准war”。
第三步,核对配置文件。打开WEB-INF/classes下的application.yml,检查context-path、server.port配置,和当前的war包文件名、Tomcat端口对一下。这一步排查“URL前缀是否对应”。
第四步,检查版本兼容性。确认SpringBoot版本、Tomcat版本、JDK版本三者是否在兼容区间内,尤其是SpringBoot 2.4+路径匹配策略变化、Tomcat 10+的Jakarta命名空间问题。这一步排查“容器层面的隐性不兼容”。
第五步,隔离测试。把webapps下其他无关war包移走,只部署当前项目。如果恢复正常,就是多应用类加载冲突;如果仍然404,继续下一步。
第六步,单独测一个简单接口。在项目里写一个最基础的Controller,比如@GetMapping("/ping")返回字符串,重新打包部署,请求/ping。如果/ping也404,问题定位在部署结构或容器加载层面;如果/ping正常,问题定位在具体路由或业务代码层面。
这六步走完,九成以上的404都能水落石出。
8. 一个真实排查记录:从“突发404”到根因只用20分钟
分享一个我实际处理过的案例,读者可以参考整个过程。
当时的情况是:客户有一套基于SpringBoot 2.2.5的老系统,一直用war包部署在Tomcat 8.5上,稳定运行了快一年。某天开发人员加了一个新功能,本地打包测试正常,部署到测试服务器后,发现整个系统无法访问,全是404。
我先做了第一步排查:curl访问根路径,返回的是Tomcat猫脸404,不是Whitelabel页面。这个信息很关键——请求根本没进到Spring容器。于是跳过路由排查,直接看Tomcat日志。
日志里有一行不起眼的警告:
text复制The web application [ROOT] is still processing a request...
这个警告本身不是致命性的,但继续往下翻,发现部署阶段有一个异常被吞掉了,提示一个类初始化失败。顺着堆栈开始查,发现是一个第三方jar包里的类,在Servlet容器初始化时触发了一个静态代码块,而那个静态代码块引用了某个不存在的系统属性,抛出了NullPointerException。虽然这个异常被Tomcat捕获了,但它导致Spring的DispatcherServlet没有被注册成功。
继续溯源:这个第三方jar一直都在,为什么这次突然出问题?对比了新旧war包的差异后,发现新功能引入了一个新的依赖,这个依赖和旧jar里的类重名了,类加载器加载到了新版本的类,但这个新版本的类里用了更高版本的API,而服务器上Tomcat lib里另一个老版本jar先被加载了,导致NoSuchMethodError。
最终解法很简单:在新功能依赖里排除掉那个冲突jar,或者在Tomcat的conf/catalina.properties里配置tomcat.util.scan.DefaultJarScanner,跳过对冲突jar的扫描。前后只花了大概20分钟,但如果没有curl返回类型这个关键信息,很容易被带偏到路由配置里去排查。
这个案例的启示是:404只是一个表象,它不代表“路径不存在”,只代表“在某个环节,请求没有被正确处理”。你能定位到“请求卡在哪一层”,问题就解决了一半。
9. 最后再分享几个部署上的小技巧
war包文件名和版本号:不要把版本号写进war包文件名,比如myapp-2.0.war。因为Tomcat会把这个名字直接作为context path,以后你升级版本,war包文件名变了,context path就变了。如果你在配置文件或者前端代码里写死了旧路径,升级后就会404。建议统一命名成myapp.war,版本信息放在路径内部或者用单独的说明文件管理。
部署前做一次“干净部署”:Tomcat解压war包后会在webapps下生成同名目录。如果你重复部署同一个war包,记得先删除旧目录,再放新war包。有些版本的Tomcat在war包修改时间比目录新时会有bug,解压新旧内容混合,引发莫名404。严格步骤是:
bash复制# 停服务
./catalina.sh stop
# 清理旧部署目录和war包
rm -rf $CATALINA_HOME/webapps/myapp*
rm -rf $CATALINA_HOME/work/Catalina/localhost/myapp
# 放入新war包
cp myapp.war $CATALINA_HOME/webapps/
# 启服务
./catalina.sh start
其中work目录里的缓存也要清理,jsp编译缓存和session序列化缓存都在那里,是很多隐蔽问题的来源。
另一个技巧是查看Tomcat实际加载的类路径。如果怀疑类冲突,可以在启动参数里加上:
bash复制-Dtomcat.util.scan.StandardJarScanFilter.jarsToScan=*
或者在应用里打印出类的实际加载位置:
java复制System.out.println(SomeClass.class.getProtectionDomain().getCodeSource().getLocation());
这条信息能直接告诉你当前类是从哪个jar加载的,配合类冲突排查非常有效。
关于404,其实最怕的不是问题本身,而是排查思路乱。只要遵循“先定层、再定位、后修复”的原则,90%的问题都能在半小时内解决。希望这篇文章能帮你把war部署这条链路真正吃透,下一次再看到404的时候,能条件反射地想到“哦,这是哪一层出问题了”。
