1. 部署前的基础认知:Tomcat 与前后端分离项目
前后端分离项目本地部署这件事,说简单也简单,说麻烦是真麻烦。很多人第一次接触时,脑子里默认“前后端分离”就是把前端打包后丢进 Tomcat 的 webapps 里,再把后端接口地址指过去——结果启动后访问前端页面发现一堆资源 404,或者接口全部跨域报错,半天排查不出头绪。
先把这个项目到底要解决什么问题说清楚:这是一个基于 Tomcat 的本地部署实践,核心目标有两个,一是让 Spring Boot 后端以 war 包形式跑在外部 Tomcat 上,二是把前端构建产物(比如 Vue 打包后的 dist 目录)以一种合理的方式交给 Tomcat 托管,最终实现前后端在本地环境里的联通访问。适合谁来参考?如果你是刚接触前后端分离开发、想把本地联调环境弄得更贴近生产环境的人,或者你所在团队的生产服务器还在用传统 Servlet 容器而非内嵌 Tomcat,这篇内容就是写给你的。
我之所以强调“用外部 Tomcat 而非 Spring Boot 内嵌 Tomcat”,是因为很多人在本地开发时直接java -jar app.jar就能跑,但一旦涉及部署到老一批服务器、或需要运维用统一脚本管理多个 Web 应用时,外部 Tomcat 依然是绕不开的环节。而且外部 Tomcat 的坑——端口冲突、编码乱码、内存溢出、war 包热更新不生效——基本是固定那么几个,踩过一次就记住了。
在动手之前,你得先想清楚一个问题:你的前后端分离项目,准备用哪种方案放进 Tomcat?这事不提前想清楚,后面全是返工。
2. 三种部署思路,先选好再动手
2.1 方案一:纯净前后端分离,各自独立部署
后端 Spring Boot 项目打成 war 包,扔进 Tomcat 的webapps目录;前端 Vue/React 项目构建出的 dist 目录,也丢进webapps下的另一个目录(比如webapps/ROOT或webapps/front)。前端通过绝对路径请求后端接口,比如http://localhost:8080/api/user/list。
这个方案的优点是结构清晰,前后端互不干扰,后端升级时只需替换 war 包,前端改版时只替换静态文件。缺点是必须解决跨域问题,因为前端页面地址是http://localhost:8080/front/index.html,后端接口地址是http://localhost:8080/api/...,虽然同一个端口,但如果前端静态资源和后端接口不在同一个 Servlet 上下文里,浏览器依然视其为跨域请求。
2.2 方案二:前端打入后端 war 包,单包部署
把前端构建后的 dist 目录里的文件,复制到后端项目的src/main/resources/static目录下,然后整个后端打成 war 包。这样 Tomcat 只需部署一个 war,前端页面和后端接口同源,没有跨域问题。
优点是部署最简单、本地联调最省心;缺点是前后端耦合,前端每次改动都要重新进后端项目里复制文件、重新打包,开发效率低。适合小项目、演示项目或临时联调。
2.3 方案三:后端 war 包 + 前端通过 Tomcat 虚拟目录映射
Tomcat 支持在conf/server.xml里通过<Context>标签配置虚拟目录,把磁盘上的前端 dist 目录直接映射为一个访问路径。这样做的好处是前端构建产物不用复制进 webapps,构建完刷新就能生效;缺点是 server.xml 是全局配置,不同团队协作时容易各改各的、冲突不断。
我个人在本地部署时最常用的是方案一,理由很简单:它最接近真实生产环境的前后端分离架构。生产环境里前端往往用 Nginx 托管,后端用 Tomcat,本地用方案一模拟的是“前端静态服务 + 后端接口服务”的分离状态,后续迁移生产时遇到的坑会更少。方案二适合赶时间的时候用,方案三我基本只在调试静态资源路径问题时临时启用。
想清楚用哪个方案之后,下一步就是搭建一个可靠的本地 Tomcat 环境。
3. 本地 Tomcat 环境准备:下载、目录与配置
3.1 下载与版本选择
Tomcat 下载地址是官方站点,注意别去第三方下载站。选择版本时有个基本原则:Tomcat 的主版本要和 JDK 版本匹配。比如 JDK 8 配 Tomcat 8.5/9.0 都没问题;JDK 11 建议 Tomcat 9.0+;JDK 17 则建议 Tomcat 10.1+,因为 Tomcat 10 开始用 Jakarta EE 命名空间,javax.servlet变成了jakarta.servlet,Spring Boot 2.x 用的还是javax,直接扔到 Tomcat 10 里会启动失败。
这里有个很多人踩过的坑:Spring Boot 2.x 项目打包 war 后,部署到 Tomcat 10 会报ClassNotFoundException: javax.servlet.Filter。解决办法要么升级到 Spring Boot 3.x(它已经切换为jakarta.servlet),要么老老实实用 Tomcat 9。所以选版本前,先确认你项目的 Spring Boot 大版本,再反推 Tomcat 版本,顺序千万别反了。
Windows 用户下载 zip 包,解压即用;macOS/Linux 用户下载 tar.gz,解压后把目录放到固定位置,比如~/dev/tomcat/apache-tomcat-9.0.98。解压完成第一步就是把 bin 目录下的catalina.bat(Windows)或catalina.sh(Linux)打开,检查JAVA_HOME是否指向 JDK 路径,如果没设置,启动时会直接报错。
3.2 核心目录结构速览
刚接触 Tomcat 的人容易把文件到处乱放,其实它的目录结构非常固定:
bin:启动、关闭脚本(startup.sh/startup.bat、shutdown.sh/shutdown.bat)conf:全局配置文件,重点是server.xml、web.xml、tomcat-users.xmllib:Tomcat 自身的 jar 包,不要把你的项目依赖 jar 扔进来logs:运行日志,排错的第一现场webapps:部署目录,war 包或解压后的应用放这里work:JSP 编译后的临时文件目录,遇到“改了代码不生效”可以先清这里
3.3 server.xml 里我最常改的三处
conf/server.xml是 Tomcat 的核心配置文件,但绝大多数情况下你只需要关注三个点:
端口配置。默认 HTTP 端口是 8080,如果被占用,把<Connector port="8080" protocol="HTTP/1.1" ... />里的端口号改掉。注意一次改三处:HTTP 端口、redirectPort、以及Server port="8005"(这是 Tomcat 的关闭指令端口)。只改 HTTP 端口不改Server port容易出幺蛾子。
编码配置。在 HTTP Connector 标签里加上URIEncoding="UTF-8"。本地部署前后端分离项目时,URL 参数里如果带中文——比如搜索关键词——不加这个配置,Tomcat 默认按 ISO-8859-1 解析请求参数,直接乱码。
部署目录配置。<Host name="localhost" appBase="webapps" ...>里的appBase指向 webapps 目录。如果你希望前端静态资源从外部磁盘目录加载,可以在这个 Host 节点下加<Context path="/front" docBase="/Users/me/project/front/dist" reloadable="true" />,这样访问http://localhost:8080/front/就是直接访问那个磁盘目录,不用复制文件。
3.4 启动脚本与 JVM 参数
Windows 下用startup.bat启动,macOS/Linux 下用startup.sh。但很多人的本地项目在 Tomcat 里跑起来后,控制台中文乱码,这大概率不是代码问题,而是 JVM 默认字符集跟终端不一致。可以在catalina.sh的JAVA_OPTS里加上:
JAVA_OPTS="-Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8"Windows 下还需要注意conf/logging.properties里java.util.logging.ConsoleHandler.encoding,默认是 UTF-8,但如果你的 Windows 控制台代码页是 GBK,日志照样乱码,把它改成GBK,或者把控制台代码页切到 UTF-8 都行。这块我后面在问题排查章节会展开讲。
4. IDEA 中配置 Tomcat:本地部署的第一步
4.1 关联本地 Tomcat
用 IDEA 做本地部署,我强烈建议先在 IDEA 里把 Tomcat 关联起来,这样调试时能直接看到堆栈信息,不用靠猜。
打开 IDEA,进入File -> Settings -> Build, Execution, Deployment -> Application Servers,点击+选择Tomcat Server,然后在Tomcat Home里选择你本地 Tomcat 解压目录,Tomcat base directory通常会自动识别,保持默认即可。这里有一点要注意:IDEA 必须使用和项目 JDK 匹配的 Tomcat 版本,否则部署时 IDEA 会提示版本不兼容。
4.2 创建 Artifact:war 还是 war exploded
Tomcat 部署项目前,IDEA 要求你指定一个 Artifact。对 war 包部署,有两种形态:
war:完整的 war 包,部署时 Tomcat 会解压,适合最终要扔到服务器上的场景war exploded:展开的目录结构,IDEA 直接把编译后的target/classes和静态资源映射给 Tomcat,适合本地开发调试
本地调试我用war exploded居多,因为它配合reloadable="true"能实现改代码后自动热部署,不用每次重启 Tomcat。IDEA 打开Project Structure -> Artifacts -> + -> Web Application: Exploded -> From Modules,选你对应的模块就行。
4.3 配置 Deployment 上下文路径
IDEA 的 Run/Debug Configurations 里,Tomcat Server 的Deployment选项卡,Application context默认是/,也就是访问http://localhost:8080/直接进项目根。先后端联调时,建议设置成有意义的上下文路径,比如/api,这样后端接口地址就是http://localhost:8080/api/xxx,前端代理或请求路径也更清晰。
设置上下文路径时,一定要和后端项目的server.servlet.context-path(Spring Boot 配置)保持一致,否则会出现“接口访问 404 但配置文件里明明写了路径”的诡异情况。我在实际项目里就见过有人 IDEA 的 Deployment 上下文写了/api,但 Spring Boot 的application.yml里也写了context-path: /api,结果访问http://localhost:8080/api/api/login才通——这就是双重前缀叠加,排查了半小时才反应过来。
4.4 通过 IDEA 启动 Tomcat 后看不到日志
IDEA 启动 Tomcat 后,底部Console窗口应该能看到清晰的启动日志。如果你点了启动按钮后,IDEA 面板里什么日志都没有,多半是Tomcat Server -> Logs里的日志级别或控制台配置问题。进入 Run 配置,找到Logs选项卡,勾选Show console when a message is printed to stdout,然后把Tomcat Localhost Log、Tomcat Catalina Log、Tomcat Manager Log点成可用的状态,日志就会正常输出了。
5. 后端改造:从 Spring Boot 内嵌容器到外部 Tomcat
5.1 改造步骤:jar 转 war 的三个关键点
如果你原本是java -jar启动,要改成部署到外部 Tomcat,Spring Boot 项目需要做三处改动,缺一不可。
第一处:打包方式改为 war。在pom.xml的<packaging>标签里,把jar改为war。
第二处:排除内嵌 Tomcat。Spring Boot 默认依赖内嵌 Tomcat,如果不排除,部署到外部 Tomcat 时同一个应用会有两个 Tomcat 在抢。在spring-boot-starter-web依赖里用<exclusions>排除spring-boot-starter-tomcat:
<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 后,本地用java -jar直接跑会报NoClassDefFoundError: ServletWebServerFactory,因为内嵌容器没了。解决办法是给provided作用域加上 Tomcat 依赖,让它只在编译时参与,运行时不打包进去:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-tomcat</artifactId> <scope>provided</scope> </dependency>第三处:修改启动类。主类要继承SpringBootServletInitializer并重写configure方法。这一步的底层逻辑是:外部 Tomcat 启动时不会像java -jar那样运行main方法,它需要通过SpringBootServletInitializer这个类来引导 Spring 容器启动。onStartup钩子会由容器调用,进而触发 Spring ApplicationContext 的初始化。注意这里继承的是哪个 Spring Boot 版本对应的类——Spring Boot 2.x 里这个类在org.springframework.boot.web.servlet.support包下,别误导成 1.x 的org.springframework.boot.web.support包。
@SpringBootApplication public class Application extends SpringBootServletInitializer { @Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(Application.class); } }5.2 外部 Tomcat 下配置文件的加载顺序
改成 war 包部署后,配置文件的加载规则会变化。java -jar启动时,application.yml在 classpath 根目录;war 包部署时,配置文件同样在WEB-INF/classes下,如果application.yml在src/main/resources里,打包后会在正确位置,所以大部分情况下无需额外处理。
但如果你部署到不同环境,要覆盖配置,优先级和 Java 进程启动不一样。外部 Tomcat 部署时,常见做法是把外置配置文件放在 Tomcat 的conf目录下,然后通过JAVA_OPTS指定:
JAVA_OPTS="-Dspring.config.additional-location=/opt/tomcat/conf/application-external.yml"这里的spring.config.additional-location是 Spring Boot 2.4+ 的用法,它会把外置配置追加到默认配置之后,优先级更高。如果你用的是更早版本,可以直接用--spring.config.location覆盖默认路径,但这样默认的application.yml就不会被加载了,容易把系统配置项弄丢。
5.3 数据库配置加密:本地部署的安全底线
本地部署前后端分离项目时,很多人习惯把数据库用户名密码明文写在application.yml里。本地自测无所谓,但如果这个项目要分享给同事或打包给其他人部署,明文密码就很不体面。
推荐的做法是用jasypt-spring-boot-starter做配置加密。在application.yml里用ENC(...)标识加密后的密码,然后在启动参数里传入解密密钥:
JAVA_OPTS="-Djasypt.encryptor.password=mySecretKey"这个工具的坑主要体现在两点:一是密钥必须和加密时使用的完全一致,否则启动直接报Decryption failed;二是某些 MySQL 驱动版本对连接串里的特殊字符敏感,加密时如果明文密码里有+、/、=这类字符,加密结果里通常是大写字母和数字,不会出问题,但要保证最终连接串本身没有非法转义。这个内容展开讲又是一篇文章,本地部署阶段只需记住:密钥永远不要提交进仓库,用.env或本机环境变量管理。
5.4 war 包名称与上下文路径的关系
当你把打好的 war 包放进webapps目录后,Tomcat 默认会把 war 包的文件名作为上下文路径。比如文件名是myapp.war,访问地址就是http://localhost:8080/myapp/。如果希望根路径直接访问,要么把 war 包命名为ROOT.war,要么把原来的ROOT目录删掉再替换。
这里有个很实际的建议:不要指望通过修改 server.xml 的path属性来改变上下文路径,虽然 Tomcat 在早期版本支持这么做,但 Tomcat 9 之后对<Context path>属性的约束越来越严格,部署在 Host 下的 Context 不允许指定path属性,否则启动时直接抛异常。正规做法就是让 war 包的名字决定路径。IDEA 里部署war exploded时,如果在 Deployment 配置里指定了 Application context,那么 IDEA 启动时通常会自动调整部署名称,这时候上下文路径由 IDEA 决定,跟 war 包名字无关。
6. 前端部署:构建产物如何交给 Tomcat
6.1 Vue/React 构建前的关键配置
前端项目在构建之前,必须处理接口请求的路径。以 Vue 项目为例,开发环境下用的代理配置.env.development里常常是VUE_APP_BASE_API = '/api',这个相对路径在部署后依然有效——只要前端静态资源和后端接口在同一个域下。
但如果是独立部署,前后端不同端口,构建时就得用绝对路径或完整 URL。这里我踩过一次很深的坑:前端项目构建时,publicPath如果配置成/,部署到 Tomcat 的/front/目录下,访问前端页面时所有 JS/CSS 资源都会从根路径加载,结果 404。解决办法是把publicPath设置为相对路径或者加上子路径。
以 Vue CLI 为例:
module.exports = { publicPath: process.env.NODE_ENV === 'production' ? '/front/' : '/' }如果是 Vite 项目,则是:
export default defineConfig({ base: process.env.NODE_ENV === 'production' ? '/front/' : '/' })这个配置的本质是:构建产物里的资源引用前缀必须和部署后的访问路径一致。你部署在/front/下,资源就写成/front/static/js/xxx.js,否则浏览器拿着/static/js/xxx.js去请求,Tomcat 里根本没有这个路径。
6.2 部署到 Tomcat 的三种形态对比
| 部署形态 | 操作方式 | 优点 | 缺点 |
|---|---|---|---|
| dist 内容复制到 webapps/ROOT | 每次构建后手动覆盖 ROOT 目录 | 访问路径简洁(根路径直接进) | 需要人工操作,易出错 |
| dist 整体复制为 webapps/front | 复制目录到 webapps 下 | 路径清晰,可多个前端并存 | 上下文路径需要统一管理 |
| server.xml 虚拟目录映射 | 修改 server.xml,docBase 指向磁盘目录 | 前端构建完直接生效 | 多人协作时配置易冲突 |
我本地最常用的是第二种:webapps/front。好处在于,Tomcat 本来就是多应用部署的,webapps/front和webapps/api可以同时存在,路径一眼就看出哪里是静态资源、哪里是后端接口。生产环境迁到 Nginx 时,也只需要把front目录原样搬到 Nginx 的 html 目录下。
6.3 前端跨域的三大解法
前后端分离部署到 Tomcat 后,跨域是最容易冒出来的问题。所谓跨域,本质是浏览器的同源策略在拦截,不是 Tomcat 在拦截。请求发出去了,服务器也收到了,也会正常返回,但浏览器发现响应头里缺少允许跨域的字段,直接就吞掉了响应,前端看到的就是blocked by CORS policy。
第一层解法:后端开启 CORS。Spring Boot 里写一个全局配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }第二层解法:前端通过代理转发。这个方案更适合在开发环境下使用,生产环境意义不大,因为代理转发的是开发服务器的地址,而部署到 Tomcat 后前端静态资源已经不在 Node 服务下了。
第三层解法:把前后端放在同源下。也就是方案二里说的,前端构建产物的 JS/CSS 走后端静态资源路径,或者用 Tomcat 的虚拟目录让前端页面和后端接口同端口同域名。这就是为什么很多人明明没配跨域,把前端打进 war 包后反而不报错了——不是跨域被解决了,而是请求本来就是同源的。
我的经验是:本地部署阶段,后端开 CORS 最省事;生产环境,优先想办法同源,别人家 Nginx 在外层做反代或转发。
7. 完整部署实操:从零到能访问
7.1 后端打包与上传
在项目根目录执行:
mvn clean package -DskipTests打包完成后,在target目录下会生成你的项目名.war。这个 war 包内部结构是标准的 Web 应用结构:WEB-INF/classes放着编译后的 class 和资源配置文件,WEB-INF/lib放着项目依赖的 jar 包。检查一下WEB-INF/classes/application.yml是否存在——如果这里没有配置文件,war 包启动后会使用默认配置,可能连数据库都连不上。
把 war 包复制到 Tomcat 的webapps目录,命名要谨慎。假设我想让它以/api路径访问,就把 war 包改名为api.war。接着启动 Tomcat:
cd bin ./startup.sh启动后观察logs/catalina.out日志文件,看到Deployed application at context path [/api]基本就说明后端部署成功。
7.2 前端构建与部署
以 Vue 项目为例:
npm install npm run build构建完成后,dist目录里是index.html、static(或assets)等静态文件。在 Tomcat 的webapps下新建front目录,把dist里的内容全部复制进去:
cp -R dist/* apache-tomcat-9.0.98/webapps/front/然后启动或重启 Tomcat,访问http://localhost:8080/front/,能看到前端页面。如果页面白屏但后端接口正常,先按 F12 看 Network 面板,确认 JS/CSS 资源的请求路径是不是 404,路径 404 就回到上一节讲到的publicPath问题。
7.3 联调验证三步走
部署完之后,不要急着到处点功能,按这个顺序验证:
- 先直接访问一个后端接口,确认后端接口本身可用。比如
curl "http://localhost:8080/api/user/list",看返回的 JSON 数据是否正常。 - 再访问前端页面,确认页面能正常渲染,资源不 404。
- 最后在页面上触发一个调用后端接口的操作,观察 Network 面板,看请求的 URL、请求方法、请求头是否符合预期。
第 3 步里最容易暴露问题的是OPTIONS预检请求。前端的 POST 请求如果带了Content-Type: application/json这种非简单请求头,浏览器会先发一个OPTIONS预检,如果你的后端接口没有正确处理OPTIONS,预检失败,后续真实请求根本不会发出,前端看到的报错是Failed to fetch而不是 403/500。这个坑特别隐蔽,因为有些后端的鉴权拦截器会对所有请求做认证,直接 401 掉OPTIONS预检,结果前端怎么调都失败,后端日志却只有一行OPTIONS /xxx 401。
7.4 用 Tomcat Manager 管理应用
如果你需要频繁更新 war 包,建议配置 Tomcat Manager 应用。编辑conf/tomcat-users.xml,加入:
<role rolename="manager-gui"/> <role rolename="manager-script"/> <user username="admin" password="admin123" roles="manager-gui,manager-script"/>然后访问http://localhost:8080/manager/html,可以可视化地在页面上启动、停止、重新部署应用,不用每次手动复制 war 包再重启 Tomcat。注意manager-gui权限只能从本机访问,manager-script允许脚本远程调用,如果只是本地用,两个都配到本机就够了。
多提一句:Tomcat Manager 的manager-script接口允许通过 HTTP 方式部署 war 包:
curl -u admin:admin123 "http://localhost:8080/manager/text/deploy?path=/api&update=true" --upload-file api.war这个命令可以在不重启 Tomcat 的情况下热更新 war 包,本地调试时比每次重启快得多。
8. 常见问题与排查技巧实录
把本地部署 Tomcat 时经常遇到、且网上答案往往只讲一半的问题整理在这。每个案例都是我实测过或亲眼见过的,有通用性。
8.1 启动后访问 404
Tomcat 启动后访问http://localhost:8080/xxx报 404,排查顺序要固定,不要瞎猜。
第一步,确认应用是否真的部署了,查看logs/catalina.out和logs/localhost.2025-xx-xx.log。后者专门记录应用部署情况,如果有Exception sending context initialized event之类的信息,说明应用启动中抛出异常,通常页面会显示 Servlet 容器报错页,而不会只是纯 404。
第二步,确认上下文路径。如果你的接口地址是http://localhost:8080/api/login,war 包却是myapp.war,那么实际路径是http://localhost:8080/myapp/login,这就是 404 的最常见原因。
第三步,检查静态资源配置。有时应用能访问/api/xxx,但页面或者静态资源请求 404,看看 Spring Boot 的spring.mvc.static-path-pattern配置是否覆盖到了前端资源路径。
8.2 Tomcat 乱码
乱码问题分成两类:启动日志乱码和页面响应乱码。
启动日志乱码,尤其是 Windows 下,主要原因是日志输出编码和控制台编码不一致。Tomcat 9 的conf/logging.properties里默认ConsoleHandler.encoding = UTF-8,而 Windows 控制台大多数情况是 GBK,双方不匹配就会出现中文注释或中文日志乱码。解决办法是改成:
java.util.logging.ConsoleHandler.encoding = GBK页面响应乱码,先检查是不是 HTTP 响应头没有指定 charset。Spring Boot 项目通常在application.yml里设置server.servlet.encoding.force=true,这样所有响应用 UTF-8 编码。如果还不行,用浏览器的开发者工具看 Network 面板里响应头的Content-Type: text/html;charset=UTF-8是否正确。
还有一个隐蔽的乱码点在 Tomcat 解析 URL 参数上。前面说过URIEncoding="UTF-8",这个配置必须加在 Connector 上,否则 GET 请求里的中文参数乱码是必现的。POST 请求体乱码则由CharacterEncodingFilter管理,Spring Boot 默认装配了这个过滤器,一般不会出问题。
8.3 端口被占用
启动时提示Address already in use: JVM_Bind或者<null>:8080,说明 8080 被其他程序占了。Windows 下用:
netstat -ano | findstr 8080Linux/macOS 下用:
lsof -i :8080找到占用进程的 PID,结束它,或者改 Tomcat 的端口。这里有个容易忽视的场景:上一次 Tomcat 没有正常关闭,残留了 java 进程。比如用 Ctrl+C 强制终止 IDEA 里的 Tomcat,另一个窗口里的 Tomcat 进程可能还活着,端口占着不放。这种情况直接kill -9残留进程就行。
8.4 内存溢出:PermGen 与 Metaspace
本地部署多项目后,Tomcat 经常会出现内存溢出。老的 JDK 8 之前是java.lang.OutOfMemoryError: PermGen space;JDK 8+ 用元空间,报的是Metaspace。
在catalina.sh的JAVA_OPTS里显式设置内存参数:
JAVA_OPTS="-Xms512m -Xmx1024m -XX:MaxMetaspaceSize=256m"-Xms是初始堆大小,-Xmx是最大堆大小,这两个值不相等时,JVM 会动态调整堆大小,在高并发下可能频繁触发 Full GC。本地调试时直接让两者相等,能减少 GC 停顿带来的干扰。MaxMetaspaceSize控制元空间上限,类加载过多时不够用就报溢出,设个 256m 基本够单项目跑。
8.5 修改了代码但不生效
这是最鬼的问题,看起来像“改了 Java 代码,重启 Tomcat 后还是老逻辑”。排查顺序:
第一,确认 IDE 是否执行了Build。IDEA 里的 Tomcat 跑的是编译后的 class,如果你改了代码没有触发编译,Tomcat 加载的还是旧 class。执行Build -> Rebuild Project再重启试试。
第二,清理 Tomcat 的work目录。JSP 编译缓存在这里,但 Spring Boot 项目的接口代码不走 JSP,所以问题一般不是缓存。真正可能缓存的是 IDE 部署时的临时副本。IDEA 部署war exploded时,如果 output directory 和你的 target 目录不一致,改代码后 IDEA 没有把新的编译结果同步过去,也会出现“没生效”的假象。检查 IDEA Structure 里 Artifact 的 Output directory,确认它指向的是你 Maven 编译的 target 目录。
第三,确认浏览器缓存。前端改了接口地址或代码,HTML/JS 被浏览器缓存,刷新页面加载的还是旧脚本,在开发者工具里勾选 Disable cache,或者用 Ctrl+F5 强制刷新。
8.6 查看 Tomcat 进程状态的实用命令
很多人在排错时喜欢一上来就改配置,我建议先确认运行时状态。Linux/macOS 下:
ps -ef | grep tomcat这个命令能看到 Tomcat 进程的启动参数,比如-Dcatalina.home指向哪个目录、-Dspring.config.location有没有生效。如果这些参数配置不对,配置了但没加载的排查就是白忙活。
如果在 macOS 上出现“Tomcat 启动后立即停止,没有任何日志”,大概率是$CATALINA_HOME环境变量没设。设置方式:
export CATALINA_HOME=/路径/到/apache-tomcat-9.0.98Windows 上则是系统变量里加CATALINA_HOME,并在Path中加入%CATALINA_HOME%\bin。
9. 几个一直想强调的细节
上面讲的都是操作层面的内容,最后再补充几个我反复踩过、但经常被人忽略的细节。
第一,war 包的运行环境是独立于 IDE 的。你在 IDEA 里跑得很顺的项目,打成 war 扔进 Tomcat 后,如果发现某些配置没生效,先在启动日志里确认 Tomcat 加载的到底是哪个目录下的配置文件。外部 Tomcat 启动时,user.dir属性指向的是 Tomcat 的bin目录,而不是你的项目目录,所以一切依赖相对路径的配置文件都会以 Tomcat 目录为基准。
第二,本地部署和生产部署的差异要提前摸底。Tomcat 本地部署倒是顺利,但生产环境往往有自己的运维规范——可能限定了 JVM 参数、可能要求用 systemd 服务管理,可能不允许改动 server.xml。我在本地部署时经常会提前把生产环境常用的启动参数、日志轮转配置一并测一遍,省得到生产环境再来回折腾。
第三,war 包部署后的热更新并不完全可靠。Tomcat Manager 的热部署在更新 war 包时会尝试重新加载应用的 ClassLoader,但如果你的应用里有用到第三方库的静态状态、或者自己写了ThreadLocal缓存数据,热部署后状态可能丢失或残留,出现“更新后第一次访问报错,再刷新就好了”这种诡异情况。本地调试没问题,生产环境更新 war 包时最好还是选择一个低峰期重启。
第四,前后端分离部署时的日志要能串起来。本地部署前后端分离项目,前端页面上报错,后端日志里不一定有对应记录,因为跨域请求的Origin头、前端报的 400/500 错误跟后端的异常栈不一定同时出现在同一个终端窗口。我习惯在本地部署时,给后端的 Controller 层加一个简单的请求日志切面,把每个请求的 URL、参数、耗时打出来,前端出了问题直接翻后端日志看有没有请求进来——这一招排查跨域或参数问题特别有效。
第五,Tomcat 10 和 Spring Boot 的版本矩阵,值得在项目开始前就定下来。如果你们项目是 Spring Boot 2.x,局部用 Tomcat 9;如果项目已经升级到 Spring Boot 3.x,那 Tomcat 10.1+ 没问题。这事别拖到部署阶段才想起来,因为发布包一旦打成 war,内部依赖的 Servlet API 版本跟 Tomcat 不完全匹配时,启动过程会报各种奇怪异常,紧赶慢赶再改版本很可能来不及。
本地部署前后端分离项目本身不難,难点在于把“能跑”变成“稳定地跑、可排查地跑”。你把这些基础工作做扎实了,无论后面是上生产还是交给同事维护,都会省心很多。