先说个很常见的场景Spring Boot 项目跑得好好的突然安全通告要求升级内嵌 Tomcat或者甲方运维强制要求使用指定版本的内嵌容器。你打开 pom.xml 一看spring-boot-starter-web 里根本看不到 Tomcat 的版本号完全由框架内部替你管理。这时候如果你直接往 pom 里硬塞一个 tomcat-embed-core 依赖并写上版本号十有八九会和框架自带的依赖打架项目可能直接起不来。这篇文章就围绕“Spring Boot 项目中替换指定版本 Tomcat”这个具体问题先讲清楚背后的版本仲裁机制再给出可直接抄的两种操作方案附带验证方法和排坑清单适合正在处理这类问题的 Java 后端开发者、运维同学以及所有用 Maven 或 Gradle 管理 Spring Boot 项目的人参考。1. 为什么要替换Tomcat版本——先理清需求再动手1.1 实际工作中哪些情况逼你动版本先说结论绝大多数情况下你不需要手动替换 Tomcat 版本Spring Boot 官方版本组合已经过大量测试。但工作中总有几个例外场景让你不得不动手。第一种是安全合规要求。内嵌 Tomcat 属于开源组件某个版本被爆出高危漏洞后安全团队会下发修复通知要求所有项目把 Tomcat 升级到修复版本。这时候你不可能等 Spring Boot 发布新版本只能在当前框架版本下单独把 Tomcat 顶上去。第二种是功能需求。比如某个新特性只有 Tomcat 9.0.31 之后的版本才支持或者客户指定要用 HTTP/2、要用某个 JSP 版本内置的旧版本满足不了。第三种是兼容性约束。公司内部有一套经过认证的组件版本清单清单里写死了 Tomcat 的版本范围新项目启动时就必须按清单走Spring Boot 默认版本反而不合规。还有一种是性能调优。某些 Tomcat 版本在特定场景下有性能回退或者在并发连接数上有明显差异压测后需要把容器版本调整到更合适的版本。1.2 动手前必须确认的三件事不要一上来就改 pom先花十分钟确认三件事能省掉后面大量排查时间。确认当前 Spring Boot 版本和内嵌 Tomcat 默认版本。可以用一条命令直接看mvn dependency:tree -Dincludesorg.apache.tomcat.embed:tomcat-embed-core执行后能看到当前默认的 Tomcat 版本号。知道基线版本你才能判断要替换到的新版本和基线差距多大是同一个大版本内的小版本升级还是跨大版本替换。确认目标版本与 Spring Boot 版本的兼容边界。这里有个最容易忽略的点Spring Boot 2.x 系列用的是 javax.servlet 命名空间对应的内嵌 Tomcat 是 9.0.xSpring Boot 3.x 系列已经迁移到 jakarta.servlet 命名空间对应的内嵌 Tomcat 是 10.1.x。如果直接把 Spring Boot 2.7 项目里的 Tomcat 换成 10.0.x启动时必然报 ClassNotFoundException因为 javax.servlet 下的类全没了。必须先搞清楚你手上的 Spring Boot 版本能承受哪个大版本的 Tomcat不要跨越命名空间边界。确认项目里是否用了 JSP、WebSocket 等特殊依赖。如果用了 JSP还需要额外引入 tomcat-embed-jasper它的版本必须和 tomcat-embed-core 保持一致。如果用了 WebSockettomcat-embed-websocket 的版本同样要跟着走。这些容易漏漏一个启动时就会出现缺类报错。2. 搞懂Spring Boot的Tomcat版本机制——它为什么能自动管理2.1 Maven的dependencyManagement与父POM很多刚接触 Spring Boot 的开发者以为内嵌 Tomcat 是 starter 里写死版本号的。实际上不是这样。spring-boot-starter-web 依赖了 spring-boot-starter-tomcat而 spring-boot-starter-tomcat 又依赖 tomcat-embed-core、tomcat-embed-el、tomcat-embed-websocket 等组件。这些组件的版本号都不写在 starter 的 pom 里而是统一由 spring-boot-dependencies 这个 BOMBill of Materials通过 dependencyManagement 集中管理。如果你的项目使用 spring-boot-starter-parent 作为父工程它会以“继承”方式引入 spring-boot-dependencies 中的版本管理。这就是为什么 pom 里只需要写parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent然后所有 Spring Boot 相关依赖都不需要写版本号Maven 会自动从父 POM 继承到的 dependencyManagement 里找匹配版本。如果项目是多模块架构不方便继承 starter-parent常见的做法是在聚合 POM 里用 import 方式引入 BOMdependencyManagement dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-dependencies/artifactId version2.7.18/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement两种方式最后都会让 Tomcat 版本被“仲裁”到一个默认值。这个仲裁机制是 Maven 的核心行为项目里直接声明的依赖版本优先于 dependencyManagement 中声明的版本在 properties 中覆盖属性值也能间接影响 BOM 中通过属性引用的版本。2.2 内嵌Tomcat在依赖树里的位置为了看清依赖关系建议实际执行一次依赖树查看命令mvn dependency:tree -Dincludesorg.apache.tomcat.embed输出大概是这样的结构spring-boot-starter-web └── spring-boot-starter-tomcat ├── org.apache.tomcat.embed:tomcat-embed-core:9.0.83 ├── org.apache.tomcat.embed:tomcat-embed-el:9.0.83 └── org.apache.tomcat.embed:tomcat-embed-websocket:9.0.83从这里能清楚看到Spring Boot 内嵌的 Tomcat 有四个可能出现的组件核心引擎 tomcat-embed-core、表达式语言解析器 tomcat-embed-el、WebSocket 支持 tomcat-embed-websocket、JSP 支持 tomcat-embed-jasper。默认情况下 jasper 不会被引入除非你在 pom 里专门加了 spring-boot-starter-tomcat 的 jasper 依赖或其它 JSP 支持依赖。替换版本时这几个组件的版本必须保持一致否则会出现同一项目的类路径里同时存在两个 Tomcat 版本的问题。这是实际项目中最常见的坑。2.3 替换版本的本质覆盖属性与依赖仲裁理解了版本管理机制后“替换版本”这件事的本质就很清楚了你要做的不是新增一个版本号冲突依赖去和框架对抗而是让 Maven 在解析 tomcat-embed-core 的版本时使用你指定的版本。实现方式有两种思路。第一种是覆盖版本属性。spring-boot-dependencies 内部的管理方式是这样的它在 properties 里定义了一个 tomcat.version 属性然后在 dependencyManagement 里通过形如${tomcat.version}的占位符引用这个属性。如果你的项目继承了 spring-boot-starter-parent就可以在自己项目的 properties 里重新定义 tomcat.version 属性。子项目定义的属性会覆盖父 POM 中的同名属性最终依赖解析时就用到了新版本。第二种思路是彻底绕开框架内置的 starter-tomcat。把 spring-boot-starter-tomcat 从 spring-boot-starter-web 里排除掉然后手动引入指定版本的内嵌 Tomcat 依赖。这种方式的控制力度最直接是把“框架替你管”变成“我自己管”。3. 实操两种替换指定版本Tomcat的可靠写法3.1 方式一用tomcat.version属性覆盖这是首选方案前提是你的项目使用了 spring-boot-starter-parent 作为父工程或者你复用了 spring-boot-dependencies 的属性体系。操作很简洁在 pom.xml 的 properties 区域里加一行即可properties java.version1.8/java.version tomcat.version9.0.87/tomcat.version /properties就这么简单。Maven 解析依赖时spring-boot-dependencies 里管理的 tomcat-embed-core、tomcat-embed-el、tomcat-embed-websocket 都会引用这个属性值三个组件统一切换到 9.0.87。为什么要用这种方式因为改动最小只动一个属性所有相关依赖的版本联动都由框架机制保证。Tomcat 小版本升级通常只修 bug 和安全问题API 完全兼容不会影响业务代码。这种情况下用属性覆盖是效率最高的做法。不过要注意属性覆盖只对同一个主版本有效。如果你在 Spring Boot 2.7 项目里把 tomcat.version 设置成 10.1.0Maven 确实能把版本解析成 Tomcat 10.1.0但项目里使用的仍是 javax.servlet API而 Tomcat 10 已经切换到 jakarta.servlet 了启动时必然出问题。所以这个方案适合在大版本不变的前提下做版本升级。3.2 方式二排除starter-tomcat后手动引入指定版本如果你的项目没有使用 spring-boot-starter-parent而是通过 import 方式引入的 BOM或者你想跨越 Tomcat 大版本做升级那就需要第二种方式。先排除默认的 starter-tomcat再手动声明内嵌 Tomcat 依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId exclusions exclusion groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-tomcat/artifactId /exclusion /exclusions /dependency dependency groupIdorg.apache.tomcat.embed/groupId artifactIdtomcat-embed-core/artifactId version9.0.87/version /dependency dependency groupIdorg.apache.tomcat.embed/groupId artifactIdtomcat-embed-el/artifactId version9.0.87/version /dependency dependency groupIdorg.apache.tomcat.embed/groupId artifactIdtomcat-embed-websocket/artifactId version9.0.87/version /dependency这里有个关键问题为什么要显式引入三个组件而不是只引一个 core因为 spring-boot-starter-tomcat 同时提供了 el 和 websocket排除掉 starter 之后这两个组件也一起消失了。而 Spring Boot 的自动配置类在启动时会检查这些类是否存在缺少 el 通常会在解析表达式时报 NoClassDefFoundError缺少 websocket 会在 WebSocket 相关功能初始化时报错。所以手工引入时必须把三个组件凑齐。如果项目用了 JSP还需要额外加上dependency groupIdorg.apache.tomcat.embed/groupId artifactIdtomcat-embed-jasper/artifactId version9.0.87/version /dependency我在实际项目中见过只加了 core 和 jasper没加 el 和 websocket 的情况应用启动时日志里报了一堆类找不到排查了半天最后发现是组件引入不全。写到这里顺便提醒几个组件的版本值一定写成同一个常量别一个一个手输数字手输容易抄错。3.3 两种方式的选型对比对比维度属性覆盖方式排除后手动引入方式配置改动量只需改一行属性需要同时改排除和新增多组依赖版本联动一致性框架自动保证三个组件版本一致手动保证容易漏组件或版本不一致适用范围适合继承了 spring-boot-starter-parent 的项目适合 import BOM 的项目或跨大版本场景跨命名空间升级不推荐容易引入 API 不兼容问题可以配合重写代码实现但改动量大排查复杂度依赖树简洁问题少需仔细核对类路径组件漏引时问题隐蔽我个人的选择标准很简单小版本升级用方式一大版本变更或者项目结构特殊需要用方式二。实际上在多数真实项目里最后落到你头上的都是安全漏洞修复也就是同大版本内的版本升级方式一就足够应对了。如果哪天遇到必须换大版本的情况建议先和架构组确认兼容性方案而不是单独一个人闷头改因为那会涉及 Servlet API 的命名空间调整。4. 替换后的验证步骤——改完别急着上线4.1 版本替换成功的验证清单改完代码先验证别把问题带到测试环境甚至生产环境。按照以下顺序逐项确认。第一步执行依赖树查看确认版本号和组件齐全。mvn dependency:tree -Dincludesorg.apache.tomcat.embed正常结果里tomcat-embed-core、tomcat-embed-el、tomcat-embed-websocket 都显示你指定的版本。如果某些依赖仍然显示旧版本说明 pom 里有其它声明影响了仲裁需要继续排查。这里有两条排查思路。第一种是看是否有其它 starter 也传递引入了 starter-tomcat比如 spring-boot-starter-web 和 spring-boot-starter-websocket 同时存在时如果你只排除了其中一个依赖路径的 starter-tomcat另一条路径可能又把旧版本带回来了。此时需要在所有相关 starter 上都加排除声明。第二种是检查模块间依赖关系如果是多模块项目某个模块直接依赖了另一个模块而那个模块的 pom 里没有做排除操作旧版本会被传递到当前模块。第二步启动应用观察日志中的 Tomcat 版本行。正常情况下启动日志里会打印Starting Servlet engine: Apache Tomcat/9.0.87看到这一行就说明启动阶段使用的已经是替换后的版本。第三步执行基础功能验证。应用能正常启动不代表没有问题需要实际调用几个接口确认 HTTP 请求能正常处理、Session 能正常创建、WebSocket 能正常握手。很多人忽略第三步结果到了测试环境才发现 WebSocket 连不上回头定位才发现是 websocket 组件版本不一致导致的。第四步通过 actuator 查看运行时状态。如果项目开启了 Spring Boot Actuator访问/actuator/configprops或/actuator/env时在 ServletWebServerFactoryConfiguration 相关的配置项中能看到 Servlet 容器版本信息这也是辅助校验手段之一。4.2 启动时的关键日志与依赖树核对替换 Tomcat 版本后最常见的一个隐藏问题是版本被 Maven 仲裁“偷偷改回去”。比如你在自己的 pom 里声明了 9.0.87但项目某个间接依赖的模块又传递引入了 spring-boot-starter-tomcat而那个传递引入路径上没有排除Maven 会采用最近的依赖声明版本。最终启动日志里 Tomcat 版本还是老的。遇到这种情况执行依赖树命令的完整输出能看到冲突来源。依赖树里tomcat-embed-core:9.0.87和tomcat-embed-core:9.0.63同时出现说明项目里存在两条相互独立的依赖路径。处理思路是沿着传递路径逐个排除最终只保留一条版本为指定的依赖链。另一个需要核对的地方是类路径中是否存在两个版本的 servlet-api。Tomcat 的 embed-core 里自带 Servlet API 类如果项目里另外显式引用了 javax.servlet-api或者某个第三方 jar 传递引入了 servlet-api而它的版本和内嵌 Tomcat 自带的版本不一致会出现很难定位校验异常。判断标准是运行时类路径里只能有一个 servlet-api 来源。排查命令mvn dependency:tree -Dincludesjavax.servlet看到 servlet-api 出现在多级依赖里时建议把显式声明的 servlet-api 依赖去掉让内嵌 Tomcat 提供 Servlet API避免出现版本冲突。5. 常见问题速查与避坑记录5.1 常见问题速查表现象可能原因处理办法依赖树仍显示旧版本其它依赖路径传递引入了 starter-tomcat检查所有传递路径并逐一排除启动报 ClassNotFoundException: javax.servlet.ServletContext替换到了 Tomcat 10命名空间已变更回到 Tomcat 9.x或迁移到 Spring Boot 3.x启动报 NoClassDefFoundError: o.a.tomcat.util.modeler.Registrytomcat-embed-el 或 websocket 未引入补齐全部 embed 组件依赖JSP 页面无法编译缺少 tomcat-embed-jasper添加 jasper 依赖并保持版本一致修改后项目无法启动端口被占用内嵌 Tomcat 重复加载检查是否残留多个 Tomcat Starter 依赖路径替换后 WebSocket 握手失败websocket 组件版本与 core 不一致用同一版本常量统一三个组件版本反射访问内部类报 IllegalAccessErrorTomcat 大版本之间内部类结构变化替换后重点回归反射和字节码相关功能上面的表格基本覆盖了我在实际项目里遇到过的所有典型问题其中前两类出现的频率最高。5.2 最容易踩的几个坑第一个坑是直接把老项目里自定义的 Tomcat 配置类照搬过来。这些类往往依赖特定 Tomcat 内部包路径比如 org.apache.tomcat.util.http.Rfc6265CookieProcessor。小版本升级时这些类一般没问题但跨大版本时包名或类名可能变了轻则编译失败重则启动时 NoClassDefFoundError。替换版本后凡是涉及 Tomcat 内部 API 的自定义配置代码都要重新编译验证一遍。第二个坑是只改了本地 pom没改父子模块中的其它模块。多模块项目中依赖关系复杂某个模块在 bom 或父 pom 里统一定义了 Tomcat 版本你只改了自己负责的子模块其它模块还是旧版本。上线后整体应用运行行为不一致这类问题尤其在聚合部署时很隐蔽。正确做法是先确认哪个模块是最终的 war 包或 jar 包装配入口在入口处统一管理。第三个坑是改动后没有清理本地 Maven 仓库。Maven 解析的是本地仓库里已有的工件如果你之前构建时下载过旧版本的 Tomcat 相关 jar改完 pom 后重新构建时有时候 IDEA 或 ci 缓存会干扰。虽然这不是常规情况但遇到“明明改了版本但日志还是旧的”这种问题先执行mvn clean必要时mvn dependency:purge-local-repository清理再重新拉取。第四个坑是在 Spring Boot 2.1 及更早版本上使用属性覆盖时版本变量名不完全一致。早期版本的 spring-boot-dependencies 中Tomcat 版本条件变量就叫 tomcat.version后来的版本也是这样但需要注意不同 Boot 父版本中该属性是否真正被解析。如果你的 Boot 版本很老比如 1.5.x建议先确认清楚版本管理机制再改不要盲套高版本博客里的写法。5.3 不同构建工具的等价操作如果项目用的是 Gradle 而不是 Maven属性覆盖的写法是ext[tomcat.version] 9.0.87注意是 ext 里的 tomcat.version 属性不是依赖上的 version 配置。排除方式则是implementation(org.springframework.boot:spring-boot-starter-web) { exclude group: org.springframework.boot, module: spring-boot-starter-tomcat } implementation org.apache.tomcat.embed:tomcat-embed-core:9.0.87 implementation org.apache.tomcat.embed:tomcat-embed-el:9.0.87 implementation org.apache.tomcat.embed:tomcat-embed-websocket:9.0.87Gradle 的依赖仲裁规则和 Maven 略有不同但思路一致要么覆盖版本属性要么排除后手动引入。如果项目同时使用 Gradle 和 Maven 两套构建文件改一处容易忘另一处建议把版本号统一放在根目录的 gradle.properties 和 pom 的 properties 区域中保持双端一致。6. 实操心得与后续扩展6.1 我的个人经验做了这么多年 Java 后端我碰到 Tomcat 版本替换需求最多的场景是安全修复其次是公司内部组件清单治理。这两类场景下的需求其实很一致在尽量不改代码的前提下把组件版本升到指定版本。所以我的默认方案永远是先试 tomcat.version 属性只有当这个方案受阻时才考虑排除 starter 的方式。这两个方案我都真刀真枪地调试过属性覆盖的方式省事且不易出错排除的方式则适合那些没法继承 parent 的项目架构。另外想分享的一点是改版本并不是“一劳永逸”的。比如你把 Tomcat 升到 9.0.87 之后如果项目里某个第三方库是用旧版本 Tomcat 编译的它可能在运行时反射调用 Tomcat 的内部类升级后这些内部类结构变化会导致反射调用失败业务功能莫名报错。这种情况只能回归测试才能发现。所以做完替换后一定要做一轮完整的接口回归特别是关注依赖 Tomcat 能力的模块比如文件上传、WebSocket、JSP 渲染等。6.2 替换之后还可以做什么版本替换工作的收尾不只是修改 pom、验证启动。如果你所在团队有多个 Spring Boot 项目我建议做两件事一是把替换后的配置和验证步骤沉淀成团队内部的升级操作手册以后遇到同样需求直接按手册走二是考虑把 Tomcat 版本管控纳入项目构建的依赖检查规则中用 Maven Enforcer 插件做版本约束防止未来有人无意中又引入旧版本。还可以在项目的 README 或架构文档里加一节“依赖版本管理策略”说明当前内置 Tomcat 的基准版本、覆盖属性的修改方式、替换时需要回归的功能列表。这种文档的实用价值在几个月后再次触发漏洞修复时会立刻体现出来新接手项目的成员也能快速知道该怎么改。最后分享一个小技巧替换版本后除了看启动日志建议生产环境把 Tomcat 的访问日志格式里加上协议的版本字段这样抓包时能快速定位是不是容器版本行为变化导致的问题。这个习惯帮我排查过不止一次线上异常。先改版本再补日志最后做回归这套流程完整走下来Spring Boot 项目里的 Tomcat 版本替换基本就不会有意外。