SpringBoot项目里换Tomcat版本这事,看着是改个版本号,实际上坑不少。很多同学第一次遇到是因为接到了安全扫描报告,说内嵌Tomcat某个版本有漏洞必须升级;还有人是自己项目里用了某个新特性,默认Tomcat版本不支持;更常见的是公司内部规范锁死了一个Tomcat版本,新项目必须对上号。不管哪种场景,核心就一句话:SpringBoot内嵌Tomcat到底怎么换成我指定的版本。这篇文章我就把这套机制和实操方案完整拆开讲清楚,含金量足够覆盖从SpringBoot 2.x到3.x的绝大多数项目,也顺便把替换容器的通用思路一起聊透。
1. 为什么会有“替换Tomcat版本”这种需求
先说点实在的,遇到的真实需求无非就这几种,看完你大概能判断自己属于哪一类。
1.1 安全漏洞通告带来的被动升级
这应该是触发频率最高的情况。Tomcat作为Apache基金会的顶级项目,版本迭代过程中会不断发现CVE漏洞,比如反序列化、请求走私、信息泄露这类问题。安全团队通常会给出一份清单,“你项目里的Tomcat必须在某个版本之上,否则不许上线”。而SpringBoot每个版本内部锁定的Tomcat版本是固定的,比如SpringBoot 2.7.13默认内嵌的是Tomcat 9.0.73,如果安全通告要求必须升级到9.0.80以上,你就得手动覆盖版本号。
我之前遇到过更麻烦的情况:安全通告给的版本要求和当前SpringBoot默认版本差异过大,直接覆盖版本号之后,项目启动直接抛NoSuchMethodError。后面细讲,这里先记住一个原则:版本能覆盖,但覆盖范围超出当前SpringBoot的兼容边界,就会出问题。
1.2 新特性需求和Tomcat版本强绑定
Tomcat 10之后,Servlet API从javax.servlet迁移到了jakarta.servlet,这是Java EE交给Eclipse基金会后最直观的变化。如果你项目里用了某个只在新版Tomcat里才有的特性,或者反过来,你的公共库还是老Servlet规范,强行升级Tomcat就会炸掉。
另外有些框架对Tomcat版本也有隐性要求,比如WebSocket、HTTP/2协议的某些增强功能,老版本Tomcat就是没有。这类需求不会写在报错里,但API调用时会发现莫名其妙缺方法或行为不对。
1.3 公司内部统一规范与外部环境限制
大型公司或者甲方对中间件版本往往有白名单管控,尤其金融、政务类项目,内部可能明确写了“只允许使用某几个Tomcat版本”。这种规定有时候很拧巴,SpringBoot版本早就升上去了,但Tomcat必须停在某个老版本上。这时候也要靠版本覆盖来实现。
还有一种情况是部署环境里已经装了独立的Tomcat,运维那边要求你必须以war包形式扔到他们的Tomcat里跑,而不是用SpringBoot内嵌Tomcat直接启动。这就涉及外置Tomcat部署方案,后面一并讲。
2. SpringBoot内嵌Tomcat的版本管理机制
想安全地替换版本,先得搞清楚SpringBoot是怎么选定Tomcat版本的。不然你会看到一种奇怪现象:我明明在pom.xml里写了Tomcat版本号,启动日志里显示的版本却还是原来的。
2.1 BOM锁定版本的底层逻辑
SpringBoot所有依赖版本号的“最高权威”都在spring-boot-dependencies这个BOM里。你引入spring-boot-starter-parent作为父工程时,这个BOM会被自动加载,里面通过<dependencyManagement>声明了一整套经过兼容性测试的依赖版本。
Tomcat相关依赖的坐标是org.apache.tomcat.embed组下的几个artifact,核心有:
tomcat-embed-core:内嵌Tomcat主程序tomcat-embed-el:表达式语言支持tomcat-embed-jasper:JSP支持
在BOM里,这些依赖的版本号不是直接写死的,而是引用了tomcat.version这个属性。
如果你用的是spring-boot-starter-parent,打开它的pom.xml就能看到类似这样的定义:
<properties> <tomcat.version>9.0.73</tomcat.version> </properties>然后在这个BOM里,Tomcat依赖版本都写成${tomcat.version}。这就给了我们一个口子:只要覆盖掉tomcat.version这个属性,BOM里所有Tomcat依赖的版本就会跟着变。
2.2 同一个Tomcat版本号,坐标却在变
有一点很多人第一次没注意,tomcat.version这个属性直接覆盖只适用于“依赖坐标不变”的情况。什么意思?SpringBoot 2.x默认用的Tomcat依赖是org.apache.tomcat.embed:tomcat-embed-core,主版本号是9;SpringBoot 3.x默认用的是org.apache.tomcat.embed:tomcat-embed-core,主版本号是10.1。
但Tomcat 10和Tomcat 9虽然artifactId都一样,Servlet API从javax换成了jakarta,所以SpringBoot 2.x和3.x里对应的一组依赖,本质上是两条不同的技术线。你在SpringBoot 2.x项目里把tomcat.version改成10.1.25,通常跑不起来,因为SpringBoot 2.x自身编译时用的还是javax.servlet,直接和Tomcat 10的jakarta.servlet冲突。
所以具体选哪条方案,关键要看目标Tomcat版本和当前SpringBoot版本的兼容边界,而不是光看版本号大小。
2.3 版本到底听谁的
SpringBoot选择内嵌Tomcat版本,遵循的是Maven依赖仲裁规则。正常情况下,spring-boot-starter-web传递依赖了spring-boot-starter-tomcat,后者传递依赖了tomcat-embed-core等。版本号因为BOM里声明了dependencyManagement,于是被锁定成${tomcat.version}对应的值。
如果你在pom.xml里手动声明了tomcat-embed-core并显式写了版本号,Maven会优先使用你声明的版本。但项目里还存在其他传递依赖,比如tomcat-embed-el、tomcat-embed-jasper,你不一定全部显式声明了。光是覆盖一个tomcat-embed-core,其他模块可能还是用BOM里的老版本,这就会造成版本碎片化,运行时各种诡异的NoClassDefFoundError。
处理这个问题的标准姿势,要么统一覆盖tomcat.version属性,要么把几个核心模块全部显式声明成同一个版本,千万不要只改一个。
3. 替换指定版本Tomcat的四种实操方案
从最简单的到最复杂的,按场景选择。
3.1 最省事:properties属性覆盖版本号
这是最常规的做法,适合在同一个大版本内升级,比如从Tomcat 9.0.70升到9.0.83。
如果你用的是spring-boot-starter-parent作为父工程,直接在pom.xml的properties里加一行:
<properties> <java.version>1.8</java.version> <tomcat.version>9.0.83</tomcat.version> </properties>重新刷新Maven,再看依赖树,Tomcat相关模块应该都变成了9.0.83。
如果项目没用spring-boot-starter-parent,而是自己声明了spring-boot-dependencies的import,老套路不生效,得在properties标签里同样加这个属性,然后保证spring-boot-dependencies是在dependencyManagement里以import方式导入的。比如:
<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-dependencies</artifactId> <version>2.7.13</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>这种情况下,父工程不是你控制的,但properties属性是在你当前工程里定义的,Maven在解析BOM里的${tomcat.version}占位符时,会从当前工程继承的属性体系里取值,所以同样能覆盖。
对应Gradle项目,写法也类似:
ext['tomcat.version'] = '9.0.83'注意,Gradle里是ext,别写到别的地方去。
3.2 跨大版本:排除默认依赖,显式引坐标
如果你想跨大版本,比如SpringBoot 2.x下强行用Tomcat 10.1.x,或者SpringBoot 3.x下退回Tomcat 9.0.x,靠覆盖tomcat.version一般是不行的,因为坐标的命名空间都变了。这时候要动手排除spring-boot-starter-web自带的spring-boot-starter-tomcat,再显式引入你想要的Tomcat依赖。
举个例子,SpringBoot 2.7项目里想用Tomcat 10.1.25:
<properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency> <!-- 显式引入Tomcat 10.1.25 --> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-core</artifactId> <version>10.1.25</version> </dependency> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-el</artifactId> <version>10.1.25</version> </dependency> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> <version>10.1.25</version> </dependency> </dependencies>这个方案技术上能做,但我不推荐在SpringBoot 2.x上这么用。原因前面说过,SpringBoot 2.x整条链路的Servlet API还停留在javax.servlet,而Tomcat 10.1.x只认jakarta.servlet。就算依赖替换成功,项目里所有用了HttpServletRequest的代码大概率编译不过去,除非再做一层兼容处理。如果你是想降级,比如SpringBoot 3.x项目被迫用Tomcat 9,也类似,但要注意SpringBoot 3.x本身可能调用了Tomcat 10才有的内部API。
跨大版本替换,核心工作不在“换坐标”,而在判断你的SpringBoot版本对Servlet规范的适配能力。3.x配10.x,2.x配9.x,这是最稳的组合。
3.3 干脆换容器:Jetty/Undertow以及Servlet容器替换思路
有时候你真正要解决的不是Tomcat版本问题,而是“项目里别用Tomcat”,这和替换指定版本Tomcat是同一套底层逻辑。因为SpringBoot不只支持内嵌Tomcat,还支持Jetty、Undertow,甚至Undertow还分为Servlet和非Servlet两种模式。
替换容器的操作本质上就是排除掉spring-boot-starter-tomcat,然后引入对应的starter。比如换Jetty:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <exclusions> <exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jetty</artifactId> </dependency>换成Undertow同理,把spring-boot-starter-jetty换成spring-boot-starter-undertow。
聊到这里顺便说一句,热词里经常看到“内嵌宝兰德替换Tomcat”这种说法。宝兰德或者国内其他商业中间件,本质上就是一个支持Servlet规范和SpringBoot内嵌集成的Servlet容器实现,替换思路和换Jetty/Undertow完全一致。关键就看这个容器有没有提供和SpringBoot对应的starter或者Spring Boot AutoConfiguration接入方式。如果有,就排除Tomcat引进去;如果没有,就只能走外置部署路线。
这种“换容器”的玩法,最大的收益是内存占用和启动时间的优化。Undertow在高并发场景下的表现也不错,Jetty胜在轻量。但代价也明显:很多基于Tomcat的底层配置要重调,比如访问日志格式、连接器参数、线程池命名都不一样。如果没有硬性要求,别随便换。
3.4 外置Tomcat部署:war包方案
还有一种需求,不是“内嵌Tomcat换版本”,而是干脆不用内嵌Tomcat,用独立安装的Tomcat跑SpringBoot项目。比如没有对应版本的SpringBoot BOM可以覆盖,或者运维铁了心要用他们那一套Tomcat管理平台。
实现要点有三个:
第一步,把spring-boot-starter-web自带的spring-boot-starter-tomcat排除掉,或者把打包方式改成war。通常两步都做:
<packaging>war</packaging>第二步,让启动类继承SpringBootServletInitializer并重写configure方法:
@SpringBootApplication public class DemoApplication extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder application) { return application.sources(DemoApplication.class); } public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }第三步,pom.xml里的内嵌Tomcat依赖标记为provided,这样它只参与编译,不会打进war包的lib目录:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <scope>provided</scope> </dependency>之后把生成的war包丢到外置Tomcat的webapps目录下,启动Tomcat就能访问了。
这里有个易踩的坑:很多人在.properties或.yml里配置了server.port,外置Tomcat部署时会发现端口不生效。因为server.port是内嵌服务器的配置项,外置部署模式下真正生效的是Tomcat本身的server.xml配置,项目里的配置会被忽略。同理,server.servlet.context-path如果要在外置Tomcat下生效,需要配置Tomcat的Context,或者直接用war包名字当路径。
4. 版本兼容性边界与常见问题排查
替换版本不是改完配置就万事大吉,很多问题跑起来才暴露,这里把常见坑和排查思路一次说透。
4.1 Tomcat 9与10的命名空间差异
这是很多项目升级后瞬间翻车的第一现场。Tomcat 9及之前,Servlet API包名是javax.servlet.*;从Tomcat 10开始,统一改为jakarta.servlet.*。这个改动是JCP和Eclipse基金会移交时定的,属于釜底抽薪式的兼容性中断。
所以检查项目时要先看两点:
- 项目依赖里有没有直接引用了
javax.servlet的第三方库,如果有,且这个库没有发布新版本,那Tomcat 10基本不用想了。 - 项目代码里有没有
import javax.servlet.*的痕迹,有的话也需要全部改成jakarta.servlet.*。
SpringBoot 2.x默认是在javax生态下编译的,所以换Tomcat 10要非常谨慎;SpringBoot 3.x已经全面迁移到jakarta命名空间,默认配Tomcat 10.1,不要试图降回9。
4.2 Spring Boot各版本与Tomcat版本对照
很多时候你不知道该选什么版本,花两分钟看一下表格,先判断自己处在哪条技术线上。
| Spring Boot 大版本 | 默认内嵌 Tomcat 版本 | 对应 Servlet 命名空间 |
|---|---|---|
| 2.1.x | 9.0.x | javax.servlet |
| 2.2.x | 9.0.x | javax.servlet |
| 2.3.x | 9.0.x | javax.servlet |
| 2.4.x | 9.0.x | javax.servlet |
| 2.5.x | 9.0.x | javax.servlet |
| 2.6.x | 9.0.x | javax.servlet |
| 2.7.x | 9.0.x | javax.servlet |
| 3.0.x | 10.1.x | jakarta.servlet |
| 3.1.x | 10.1.x | jakarta.servlet |
| 3.2.x | 10.1.x | jakarta.servlet |
所以,SpringBoot 2.x内部默认Tomcat 9,覆盖版本时最好在9.0.x这个区间里选;SpringBoot 3.x内部默认Tomcat 10.1,覆盖版本时最好在10.1.x这个区间里选。跨区间操作不是不可能,但成本极高。
另外,SpringBoot 3.2.x开始默认Tomcat版本跳到10.1.x的较高版本,内部实现里已经用了一些10.1才有的API,所以SpringBoot 3.2想退回Tomcat 10.0.x都很容易碰到NoSuchMethodError。
4.3 启动报错排查清单速查
替换版本后,最常见的报错和对应处理方式,我直接列成表格:
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
启动报错NoClassDefFoundError: javax/servlet/... | Tomcat 10+ 但代码还在用 javax | 要么退回Tomcat 9,要么把依赖和代码迁到 jakarta |
启动报错NoSuchMethodError或ClassNotFoundException | Tomcat 版本与 SpringBoot 内部 API 不兼容 | 检查 SpringBoot 版本对应区间,改回兼容版本 |
| 启动正常,但 /error 页面或静态资源404 | 内嵌 Tomcat 的默认 Servlet 映射问题 | 确认 tomcat-embed-core 与 tomcat-embed-jasper 版本一致 |
| Http 请求卡住或连接池异常 | Tomcat 连接器参数被改动过 | 查看server.tomcat.*配置是否对新版参数生效 |
| JSP 无法编译 | 缺 tomcat-embed-jasper | 显式引入 jasper 依赖,并保证与 core 版本一致 |
| 项目启动慢 | Tomcat 版本升级后线程初始化方式变化 | 检查server.tomcat.threads.max配置,适当调大 |
以上这些坑,多数都和“只改了一个依赖版本号,其它保持一致”这种半吊子操作有关。所以排查第一原则:先跑一遍mvn dependency:tree,把Tomcat相关模块的版本号对齐看清楚,很多时候版本的碎片化就是问题根源。
4.4 验证替换是否真的生效
有人改完pom.xml,启动日志里看到的还是该打补丁的老版本,这时候别怀疑人生,十有八九是以下情况:
第一,BOM里的tomcat.version没有被覆盖成功。检查你当前的parent是不是spring-boot-starter-parent,如果不是,需要在properties里显式加上tomcat.version,并确认没有别的地方二次覆盖了这个属性。
第二,Maven依赖仲裁结果被另一个spring-boot-dependencies覆盖了。有些模块会自己import一份spring-boot-dependencies,导致你当前工程里配置的tomcat.version根本不生效。
验证命令很简单:
mvn dependency:tree -Dincludes=org.apache.tomcat.embed看到的结果里,tomcat-embed-core、tomcat-embed-el、tomcat-embed-jasper的版本应该完全一致并且是你指定的版本。如果你在IDE里启动,也可以在启动日志里找到类似Tomcat initialized with port(s): 8080 (http)的行,但最直观的确认方式是在代码里打印版本:
System.out.println(System.getProperty("org.apache.tomcat.version")); System.out.println(org.apache.catalina.util.ServerInfo.getServerInfo());二选一,直接显示Tomcat版本字符串,比看日志靠谱。
5. 一些实操心得和后续扩展思路
我在实际项目里踩过几次坑之后,总结出几条对大家有帮助的经验。
第一,换版本前先确认自己的SpringBoot版本是否在正常支持周期内。老项目如果SpringBoot还停在2.0或2.1,硬换新Tomcat不如先升级SpringBoot,因为老SpringBoot对高版本Tomcat的适配非常有限,很多改动会变成纯粹打补丁,治标不治本。
第二,别只改Tomcat版本号就觉得自己完事儿了。如果项目里用了JSP,记得把tomcat-embed-jasper的版本也对齐;用了WebSocket,要确认Tomcat版本对应的WebSocket实现有没有变化。这类隐藏依赖最容易出幺蛾子。
第三,如果替换Tomcat是为了满足安全扫描,记得改动后回归一遍关键接口,尤其是HTTPS、WebSocket、大文件上传这类和Servlet容器强相关的功能。安全扫描的版本通过了,功能却歪了,这种事情也不少。
后面如果再往深了做,可以研究一下内嵌Tomcat的线程池调优,比如server.tomcat.threads.max、server.tomcat.accept-count这些参数在不同版本下的默认行为差异;也可以看下怎么用TomcatConnectorCustomizer和TomcatProtocolHandlerCustomizer做内嵌Tomcat的细粒度定制,真正做到“内嵌但可控”。
替换Tomcat版本这件事,核心原理不算复杂,难点全在“知其所以然”。搞懂了SpringBoot的BOM机制、版本坐标变化、兼容边界,剩下的步骤其实都是体力活。希望这篇能帮你少走一点弯路,项目跑得更稳。