ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JDK+Tomcat部署实战:从版本选型到问题排查全攻略

JDK+Tomcat部署实战:从版本选型到问题排查全攻略

搞后端开发的,几乎没人敢说自己没碰过 JDK 和 Tomcat 这套组合。哪怕现在微服务、容器化喊得震天响,Spring Boot 内嵌容器成了默认选择,但大量存量系统、中小团队的项目、课程设计里的作业,仍然跑在 "JDK + Tomcat" 这套最经典的传统架构上。这篇文章就围绕后端开发里 JDK 和 Tomcat 的完整使用链路,从版本选型、环境安装、项目部署、参数配置到常见问题排查,把我这些年实际踩过的坑和验证过的方案一次性捋清楚。

想系统掌握这套技术栈的,可以当一份可抄作业的实操手册用;正在被旧项目部署问题折磨的,可以直接跳到问题排查部分找答案。无论你是刚入行的开发新人,还是已经带项目的资深工程师,这篇内容都值得花几分钟过一遍。

1. 后端开发里的JDK与Tomcat:这套组合到底在解决什么问题

1.1 拆解JDK与Tomcat的分工逻辑

要理解这套组合,先把两个角色弄清楚。

JDK 是 Java 开发工具包,它不只是给你写代码用的编译器和运行环境,还包含了 Java 类库、调试工具、监控工具等一大堆东西。后端 Java 程序最终能跑起来,靠的是 JDK 里的 JVM。你把 .java 源码编译成 .class 字节码,JVM 负责把字节码解释或编译成当前操作系统能识别的机器指令。换句话说,JDK 是整个 Java 世界的底座,你写的一切 Java 代码都跑在它之上。

Tomcat 是一个 Servlet 容器,同时也是轻量级的 Web 服务器。它的核心工作是:接收 HTTP 请求,把请求解析成 Java 层面的 HttpServletRequest 对象,分发给你的 Servlet(或者通过 Servlet 规范工作的 Spring MVC 框架代码),然后把处理结果封装成 HttpServletResponse 响应返回给客户端。除此之外,它还管理 Servlet 的生命周期、处理 JSP、支持 Session 管理、提供静态资源服务等。

打个不太严谨但好理解的比方:JDK 像发动机,提供基础动力;Tomcat 像变速箱和传动系统,把发动机的动力转化成实际的行驶动作。没有发动机,车根本动不了;没有传动系统,发动机再猛,轮子也不会转。后端 Java 项目要对外提供服务,这两个角色缺一不可。项目里所有业务逻辑、数据库访问、接口返回,本质上都是在这套基础上运转的。

1.2 为什么这套组合至今没有被淘汰

现在新技术一轮接一轮,很多人会问:Spring Boot 都内嵌 Tomcat 了,部署直接 java -jar 就行,为什么还要单独装一个 Tomcat?

我拆过不少项目,原因其实很朴素。

第一,传统企业项目的历史包袱。大批老项目是从 Servlet + JSP 时代走过来的,它们被打包成 WAR 包,部署在独立的 Tomcat 容器里。项目没有重构动力,跑得好好的,谁也不会为了追新而瞎折腾。这种系统在银行、政企、传统软件公司里依然大量存在,而且短期内看不到替换的可能。

第二,多应用隔离部署的需求。一台服务器上跑多个 Web 应用,用独立的 Tomcat 实例做物理隔离,或者在一个 Tomcat 里部署多个上下文路径的应用,这种运维模型至今简单可靠。相比每个应用都起一个独立进程,Tomcat 多应用部署在资源利用率和运维成本上都有优势。

第三,运维习惯和技能栈。很多运维团队对 Tomcat 的管理已经非常熟练,监控、日志、重启流程全都有标准作业程序,换成容器化方案需要搭一套新的基础设施,成本并不低。一套稳定运行的系统,在没有任何改造收益的前提下,没有人愿意去动它。

所以这篇文章聊的虽然是老技术,但对相当一部分后端开发者来说,它是日常工作里真正要面对的东西,不是博物馆里的展品。

2. 版本选型:JDK 和 Tomcat 怎么搭配才能真正省心

2.1 JDK 版本的真实差异

JDK 从 8 到 21,中间经历了无数个大版本迭代。但在后端开发的实际部署环境里,我看到的分布大致是:JDK 8 仍然占了很大的存量比例,JDK 11 和 JDK 17 在新项目里逐渐成为主流,JDK 21 开始有团队尝试,但集中在基础设施较新的公司。

这里有个核心概念必须搞清楚:JDK 版本不是一个简单的"数字越大越好",它还绑定了 Java 语言规范、虚拟机实现、类库 API 的变化。

  • JDK 8 引入了 Lambda 表达式和 Stream API,让 Java 从"啰嗦"开始变得有点函数式。当年这是巨大的生产力提升,很多老团队至今仍未完全迁移其上。
  • JDK 9 开始引入模块化系统,但实际使用率并不高,后面版本默认还是类路径模式,模块化带来的强封装收益在普通业务项目里并不明显。
  • JDK 11 是长期支持版本,提供了很多虚拟机层面的改进,比如 G1 收集器成为默认、ZGC 开始出现,但早期 ZGC 并不完善,生产落地的团队也不多。
  • JDK 17 也是长期支持版本,性能、GC、安全都有不少打磨。最现实的好处是很多新框架、新工具链会默认要求 JDK 17 起步,比如新版本的 Spring 框架。
  • JDK 21 是最新的长期支持版本,虚拟线程是一个大亮点,对高并发 IO 密集型场景非常友好,但生态适配还需要时间。

我不建议在生产环境盲目追新,除非你的团队有能力处理新版本带来的兼容性问题。后端开发的铁律永远是:稳定优先。升级 JDK 带来的性能提升,在大部分业务系统里感受并不明显,但升级引发的问题往往是肉眼可见的。

2.2 Tomcat 版本与 Servlet 规范之间的关系

Tomcat 的版本选型有一个严格的对应关系:Tomcat 版本对应特定的 Servlet 规范、JSP 规范和 Java EE 规范版本。我拿一张表直接展示,选型时对照着看就行。

Tomcat 版本Servlet 规范最低 JDK 要求典型适配场景
Tomcat 8.5Servlet 3.1JDK 7+JDK 8 老项目,大量存量系统
Tomcat 9.xServlet 4.0JDK 8+传统 WAR 部署的主流选择
Tomcat 10.xServlet 5.0JDK 8+包名必须从 javax 换成 jakarta
Tomcat 11.xServlet 6.0JDK 17+新项目,JDK 17/21 的标配

这里的"包名从 javax 换成 jakarta"是一个大坑。很多老项目从 Tomcat 9 升级到 10 的时候,代码里 import 的是 javax.servlet.*,到了 Tomcat 10 直接编译都过不了。因为 Java EE 移交给开源基金会后改名为 Jakarta EE,命名空间整体变更。所以如果项目使用的是 Spring Boot 2.x,建议继续用 Tomcat 9;使用 Spring Boot 3.x 的项目,天然就是 jakarta 命名空间,选 Tomcat 10 以上版本没有障碍。

2.3 我推荐的搭配组合

基于实际项目经验,我给几个直接的组合方案,你直接照着抄问题不大。

  1. 存量老项目:JDK 8 + Tomcat 8.5/9.x + WAR 包部署。不要动版本,不要听人忽悠升一下版本能提升多少性能,收益不明显,风险却不小。
  2. 新传统项目(仍用 WAR 部署):JDK 17 + Tomcat 10.1 或 11。JDK 17 是长期支持版本,Tomcat 10.1 也很稳定,Java 生态对新版本的适配已经比较成熟。
  3. 微服务项目:直接用 Spring Boot 内嵌 Tomcat。Spring Boot 3.x 自带 Tomcat 10.1,这种情况下不需要单独部署 Tomcat,但你仍然需要了解 Tomcat 的相关配置,因为内嵌和独立部署的配置逻辑大同小异,只是一个是自动装配,一个是手动管理。
  4. 教学和学习环境:JDK 17 + Tomcat 10.1。既避开老版本的一些历史问题,又不会因为版本太新而找不到参考资料。

记住一条底层逻辑:Tomcat 的版本跟着 JDK 走,项目的技术栈跟着 Spring Boot 版本走,这三个因素要统一考虑。别 JDK 17 配个 Tomcat 8.5,那是给自己挖坑,到时候各种类库不兼容的报错能把你折腾到怀疑人生。

3. 从零搭建JDK与Tomcat开发环境

3.1 JDK 安装与环境变量配置的完整流程

我以 Linux 环境为例说明,假设你用的是常见的 CentOS 7.x 系列,这是最常见的服务器环境。Windows 机器上做本地开发的逻辑完全一样,只是路径写法不同。

第一步,下载 JDK。建议使用 OpenJDK 发行版,因为商业 JDK 的许可模式比较复杂,个人和企业使用容易踩到授权问题。网上有很多 OpenJDK 的镜像源,选择你自己熟悉的一个就行。

第二步,解压安装。一般习惯把 JDK 放在 /usr/local/java 目录下。

mkdir -p /usr/local/java tar -zxvf jdk-17_linux-x64_bin.tar.gz -C /usr/local/java/

解压后目录名可能带有版本号,例如 jdk-17.0.10。为了后续升级方便,我习惯做一个软链接,这样以后换新版本 JDK 只需要改链接指向,不用改环境变量。

ln -s /usr/local/java/jdk-17.0.10 /usr/local/java/jdk17

第三步,配置环境变量。编辑 /etc/profile 或者 ~/.bashrc 都可以,一个生效于系统级,一个生效于当前用户。

export JAVA_HOME=/usr/local/java/jdk17 export PATH=$JAVA_HOME/bin:$PATH export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar

这里多说一句,JDK 9 之后已经没有单独的 dt.jar 和 tools.jar 了,严格来说 CLASSPATH 可以不配。配不配都不影响日常使用,把 JAVA_HOME 和 PATH 配好是最核心的。配置完执行:

source /etc/profile java -version

看到类似 openjdk version "17.0.10" 的输出,说明环境基本没问题。

第四步,确认 javac 和 java 都属于同一个 JDK。有些机器上有系统自带的 OpenJDK,在 /usr/bin/java 和 /usr/bin/javac 里先命中了旧版本。用 which java 和 which javac 检查一下路径,如果有异常,把 $JAVA_HOME/bin 放到 PATH 最前面,确保优先使用你装的那个版本。

3.2 Tomcat 的安装与启停操作

Tomcat 的安装比 JDK 还简单:下载一个 tar.gz 包,解压到某个目录就能跑。我把解压后的目录习惯放在 /usr/local/tomcat,这样路径简短,写脚本也方便。

tar -zxvf apache-tomcat-9.0.89.tar.gz -C /usr/local/ mv /usr/local/apache-tomcat-9.0.89 /usr/local/tomcat

然后设置关键环境变量 CATALINA_HOME,Tomcat 启动脚本靠它找到自己所在的位置。

export CATALINA_HOME=/usr/local/tomcat

启动、停止、重启的操作命令如下:

/usr/local/tomcat/bin/startup.sh /usr/local/tomcat/bin/shutdown.sh

启动后别急着高兴,先确认两件事:一是 8080 端口有没有监听,二是 Tomcat 的日志有没有报错。我用得最多的命令是:

netstat -tlnp | grep 8080 tail -f /usr/local/tomcat/logs/catalina.out

catalina.out 是 Tomcat 的主日志文件,90% 的启动问题都能在里面找到线索。每次启停之后我建议都看一眼这个文件,不要只看启动脚本的输出,脚本输出只是告诉你"脚本执行成功"而已。

3.3 配置防火墙与安全组时的注意细节

本地环境可能一步到位,服务器上部署就没那么简单了。这一步最大的坑是:明明 Tomcat 起来了,日志也正常,但外网就是访问不了。原因十有八九是端口没放行。

在 Linux 上,如果用的是 firewalld:

firewall-cmd --zone=public --add-port=8080/tcp --permanent firewall-cmd --reload

如果用的是 iptables,则需要单独加规则。如果你用的是某云服务商的服务器,还要去云控制台的安全组里把 8080 端口加入白名单。安全组和机器防火墙是两个独立的层级,任何一个挡着都访问不了。

这里顺带提醒一个习惯:别图省事把防火墙直接关了。我见过不少部署的人为了简单执行 systemctl stop firewalld,结果服务器被扫描工具盯上,各种爆破尝试不断。你的 Tomcat 如果暴露在公网,至少不要使用默认的 8080 端口,改成不常用的高位端口,能避开大部分扫描程序的默认探测。生产环境更要养成"最小暴露面"的原则,只开放必要的端口。

4. 项目部署实操:从 WAR 包到 Tomcat 的完整流程

4.1 打包与部署的两种方式

传统项目部署到 Tomcat,无外乎两种方式。

第一种,也是用的最多的:把打包好的 WAR 文件直接扔到 webapps 目录下。Tomcat 检测到新的 WAR 包后会自动解压部署。这是最经典的方式,适合小项目、测试环境,也适合没有自动化发布管线的团队。

cp myapp.war /usr/local/tomcat/webapps/

文件复制过去之后,Tomcat 会自动解压出 myapp 目录,等几秒,访问 http://服务器IP:8080/myapp/ 就能看到应用首页。这种方式虽然简单,但生产环境不建议直接这么干,因为 Tomcat 的自动解压过程有时候会留下旧文件残留,导致新代码没完全生效。

第二种,使用 Tomcat 的管理界面或 manager 脚本部署。通过 manager 的图形界面可以上传 WAR、启停应用、查看会话信息。但 manager 默认是关闭的,需要编辑 conf/tomcat-users.xml 添加管理员账号。

<role rolename="manager-gui"/> <user username="admin" password="一个足够强的密码" roles="manager-gui"/>

我极少在生产环境开 manager,因为这是暴力破解的重点目标。如果你只是本地测试,可以临时开一下;生产环境一般用自动化方式部署,比如通过 CI 工具的流程把 WAR 传到服务器、停 Tomcat、替换文件、再启动,全程不需要开管理界面。

4.2 上下文路径与虚拟主机配置

访问地址里的那段路径术语叫上下文路径。默认情况下一个 myapp.war 包部署后,上下文路径就是 /myapp。如果你想改成根路径 /,有两种办法:

  • 把 WAR 包改名为 ROOT.war 再放入 webapps;
  • 在 conf/server.xml 的 Host 节点下增加配置:
<Context path="" docBase="myapp" reloadable="false"/>

我在实际项目中更喜欢用配置文件的方式,明确、可控,而且可以一次配多个应用,不依赖文件名去控制路径。

虚拟主机配置也在 server.xml 里。可以定义多个 Host,让同一个 Tomcat 服务于多个域名。比如:

<Host name="www.example-a.com" appBase="/srv/apps/a" unpackWARs="true" autoDeploy="true"> </Host> <Host name="www.example-b.com" appBase="/srv/apps/b" unpackWARs="true" autoDeploy="true"> </Host>

这样两个域名分别对应不同的应用目录,互不干扰。我用过这个方案在一台 Tomcat 上同时运行三个不同客户的项目,每个项目绑定不同域名,前面再挂一层 Nginx 做反向代理,整体非常干净,客户各自访问自己的地址,资源互相隔离。

4.3 JVM 参数配置:给 Tomcat 规划内存

Tomcat 默认的 JVM 参数非常朴素,生产环境必须调。最常用的配置文件是 bin/catalina.sh,在文件开头找到合适位置,加入自定义参数:

JAVA_OPTS="-Xms512m -Xmx2048m -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -XX:+UseG1GC"

这几个参数一个个说清楚:

  • Xms 和 Xmx 是堆内存的初始值和最大值。建议两者设置成相同的值,避免运行期堆扩容带来的性能抖动。堆扩容时 JVM 要执行内存复制,在流量高峰期容易出现卡顿。
  • MetaspaceSize 和 MaxMetaspaceSize 是元空间的初始值和最大值。JDK 8 之后方法元数据改存在元空间,不再占用堆内存。元空间大小要按项目的类数量来估计,老项目类多、代理类多,512m 可能都不够用。
  • UseG1GC 指定 G1 收集器。JDK 9 之后默认就是 G1,但显式写出来更清晰,也方便团队其他成员知道当前用的什么收集器。

关于堆内存到底给多大,给一个经验公式:堆上限不要超过服务器物理内存的 50%。因为你不仅要给 Tomcat 里的 JVM 留内存,操作系统本身、Nginx、数据库、日志缓冲等都要吃内存。如果一台 8G 内存的服务器只跑一个 Tomcat,Xmx 给 3G 比较合理;如果还跑着数据库,Xmx 最多给 2G,再多就容易把整机内存吃满触发系统 OOM。

4.4 配置 Tomcat 的并发参数

Tomcat 使用 Java 线程池处理请求,核心配置在 conf/server.xml 的 Connector 节点上:

<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" maxThreads="400" minSpareThreads="20" acceptCount="200" maxConnections="10000" URIEncoding="UTF-8"/>

这些参数的含义:

  • maxThreads 是处理请求的最大线程数。设太高没用,线程多了光是上下文切换就耗费大量 CPU,还容易把内存吃满。通常 200 到 400 已经够用,除非你的业务接口都很轻并且没有外部依赖阻塞,否则盲目拉高线程数只会让系统更不稳定。
  • minSpareThreads 是空闲时保留的最小线程数。它避免了流量突然涌进来时现场创建线程带来的延迟。这个值一般 10 到 20 就行。
  • acceptCount 是等待队列的长度。当线程全忙时,新的请求会进入系统的 Accept 队列排队。注意队列不等于并发处理,请求在队列里等待,响应时间会变长。
  • maxConnections 是 Tomcat 同时接受的 TCP 连接数。连接数和线程数是两个概念,连接可以排队等待线程处理。
  • URIEncoding="UTF-8" 强制用 UTF-8 解析 URI,解决 GET 请求的中文参数乱码问题。

这些参数的值不是越大越好,需要结合实际压测结果来调。我的习惯是先跑一轮压力测试看线程利用率、响应时间、错误率,再决定往上调还是往下调。一上来就把 maxThreads 调到 1000 的,十个里有九个会让系统更糟。

5. 常见问题排查与避坑指南

5.1 端口被占用:Address already in use

这个问题出现频率极高。常见场景:之前的 Tomcat 进程没被杀干净,或者你又启动了一个 Tomcat 实例。

排查思路:

netstat -tlnp | grep 8080

会看到一个 PID,如果是 java 进程,八成就是之前的 Tomcat 没停干净。处理方式:

kill -9 PID

然后重新启动。如果 netstat 看不到占用,但 Tomcat 仍然报端口被占用,检查一下是不是端口被其他服务绑定,或者是否叠加了端口转发规则,这种情况比较少见但排查起来很费时间。

这里说一个我踩过的坑:在测试环境用 shutdown.sh 停 Tomcat 之后,没检查进程就重新部署,结果发现新代码没生效。后来才查到是旧的 Tomcat 进程还活着,新启动实例因为端口冲突启动失败。所以后来我形成习惯:每次启停之后,先 netstat 确认端口监听状态,再操作下一步。这个习惯看着简单,确实能省掉不少冤案时间。

5.2 内存溢出:java.lang.OutOfMemoryError

这是最影响线上稳定性的问题。常见的报错形式有三类:

  • Java heap space:堆内存不够
  • Metaspace:元数据区溢出
  • unable to create new native thread:系统线程数达到上限

堆内存不够是最常见的,排查步骤一般是:

  1. 在 catalina.sh 里加上 GC 日志参数,让 JVM 记录 GC 详情:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/var/log/tomcat/gc.log
  1. 用 jstat 查看 JVM 的堆使用情况:
jstat -gcutil PID 1000 10
  1. 重点看 Full GC 的次数和耗时的趋势。如果 Full GC 频繁且回收后仍然有大量存活对象,基本可以断定存在对象泄漏。

  2. 用 jmap 导出堆快照:

jmap -dump:format=b,file=/tmp/heap.hprof PID

然后在分析工具里看大对象、看对象引用链,定位到具体的类和方法。

老实说,堆转储分析对新手来说有点门槛,但这是一项后端开发的必备技能,值得花时间学会。不要一遇到内存溢出就只想着加大堆内存,那只缓解症状,不解决病灶。Metaspace 溢出多半是项目用了太多动态代理、CGLIB、反射生成的类。如果确认项目结构没问题,适当调大 MaxMetaspaceSize 即可。

5.3 类加载冲突:ClassNotFoundException 与 NoSuchMethodError

Tomcat 的类加载机制是父类委托模式,但是它的 Web 应用类加载器优先加载自己目录下的类。这个机制导致一个经典问题:应用 lib 下放了某个库的旧版本,同时容器 lib 下也有一份,两个版本不一致,运行时就会出现 NoSuchMethodError、NoClassDefFoundError 这类诡异问题。

排查思路:

  1. 看启动日志中是否有类加载警告,Tomcat 有时会输出自检提示。
  2. 检查应用 WEB-INF/lib 和 Tomcat lib 目录下是否有重复的 jar 包。
  3. 查看 class 的实际加载来源,可以在启动参数加:
-XX:+TraceClassLoading

日志会输出每个类是从哪个 jar 加载的,定位到具体的 jar 再对比版本。

预防手段很简单也很重要:Tomcat 的 lib 目录里尽量只放容器级别的公共库,应用相关的库全部放到 WEB-INF/lib,不要图省事往容器里塞。容器目录里的 jar 会被所有应用共享,往里面放应用级依赖等于埋地雷,哪天两个应用依赖同一个库的不同版本,你就等着排查到半夜吧。

5.4 部署后看不到新代码:版本没更新

这个问题排在部署事故的第一位。表象是:你重新打包部署了,浏览器上访问到的仍是旧功能。原因往往有三种:

  • Tomcat 没有解压新 WAR,因为旧目录还在,自动部署机制在某些配置下不会覆盖更新。
  • 浏览器或 Nginx 缓存了静态资源。
  • 部署过程没停 Tomcat,虽然文件覆盖了,但有些类没有真正重新加载。

我的标准操作流程是:

  1. 用 shutdown.sh 正常停止 Tomcat。
  2. 删除 webapps 下对应的旧应用目录和旧 WAR 包。
  3. 复制新的 WAR 包到 webapps。
  4. 删除 Tomcat 的 work 目录,里面是 JSP 编译的中间产物,留着容易出妖蛾子。
  5. 启动 Tomcat,再确认访问。

这套流程每一步都有意义:删除旧目录避免残留 class 干扰,清空 work 目录避免旧 JSP 编译结果复活。在出现"代码改了没生效"的问题时,按这个流程走一遍,基本能解决。

5.5 中文乱码问题

乱码问题在 Tomcat 后端开发里几乎人人都会遇到。来源无非三个环节:请求、响应、日志。

请求乱码:POST 请求要在字符编码过滤器里设置 UTF-8,Spring MVC 有现成的 CharacterEncodingFilter 可以直接配置。GET 请求的 URI 编码则要在 Connector 上加 URIEncoding="UTF-8"。

响应乱码:给响应头加 Content-Type: text/html; charset=UTF-8,或者代码里在写入之前设置 response.setCharacterEncoding("UTF-8")。

日志乱码:在 conf/logging.properties 里调整 UTF-8 相关配置,并确保启动脚本里的 JVM 参数加上 -Dfile.encoding=UTF-8。

这三个环节全设置对,乱码问题就能彻底消失。最怕的是只设置其中一两个,当时看着好了,过几天换个接口又冒出来,那种零散排查最浪费时间。建议在一开始就把三个环节全部统一成 UTF-8,这是最稳妥的做法。

5.6 请求量一大就卡死:线程耗尽

某个时期连接数突增,Tomcat 请求全部排队,页面加载极慢,CPU 却不高。这种情况基本是线程池被打满,请求在 acceptCount 队列里排队等待。

先别急着加线程数。要看线程卡在哪里。用 jstack 导出线程栈:

jstack PID > thread_dump.txt

分析线程栈,如果发现大量线程卡在数据库连接获取上,那问题在连接池配置,而不是 Tomcat 线程池。如果卡在远程调用上,要检查下游系统的性能。如果卡在锁竞争上,要检查代码里有没有同步块过大的问题。

给 Tomcat 加线程数是最后的手段,不是第一手段。我有个项目就是这种情况,当时运维直接把 maxThreads 调到 1000,结果数据库连接池先爆了,系统反而更不稳定。后来定位到问题是一次全表查询拖慢了所有请求,加了个索引就解决了。线程数调得再高,也救不了一个慢 SQL 拖垮的系统。

6. 性能调优经验与日常维护心得

6.1 静态资源分离:让 Tomcat 专心处理动态请求

Tomcat 的本质是 Servlet 容器,处理静态文件并不擅长。但很多项目早期为了省事,静态资源全部由 Tomcat 直接服务,这在高并发下会浪费大量线程。

常用的优化方案是用 Nginx 作为前置反向代理,把静态资源请求直接交给 Nginx,动态请求转发到 Tomcat:

server { listen 80; location /static/ { alias /srv/static/; expires 7d; } location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

这个组合方案的好处是:Nginx 处理静态文件的性能远超 Tomcat,还能做压缩、缓存控制。Tomcat 的资源全部留给动态请求处理。一套架构改下来,在我实际维护的项目里,高峰期的线程占用率能降 40% 以上。改动成本不高,收益非常直接。

6.2 连接器模式的选择:NIO 是当前主流

Tomcat 的连接器经历过 BIO、NIO、APR 三种主要模式。BIO 模式在 Tomcat 8.5 之后就不再建议使用了,现在主流是 NIO。如果你在 server.xml 里看到 protocol 写成 HTTP/1.1,那么默认用的就是 NIO 模式。

APR 模式依赖操作系统级别的原生库,在 Linux 上性能表现更好一点,但部署复杂度明显高,需要额外编译安装 native 库和 SSL 相关组件,对服务器环境要求更苛刻。我在生产环境一般用 NIO 就够了,除非压测数据显示 APR 有明确且稳定的优势,否则不值得为它增加环境复杂度。毕竟对大多数业务系统来说,真正的瓶颈在数据库查询、慢接口和外部依赖调用上,而不在这层 IO 模型的选择上。

6.3 定期查看 Tomcat 日志的排查习惯

Tomcat 的日志体系分好几类,别只盯着 catalina.out。还有两个很重要的:

  • localhost.log:Web 应用初始化、销毁、类加载相关日志,应用启动报错经常在这里出现。
  • localhost_access_log.txt:访问日志,记录所有请求的 URL、状态码、耗时。排查慢请求、异常请求时非常有用。

我的习惯是写一个简单的脚本,每天定时检查 access log 里的 5xx 状态码和响应时间超过 3 秒的请求,汇总到工作群里。这个方法看起来原始,但确实帮我在早期发现过几次潜在问题。有一次就是发现某个接口在每天下午三点准时变慢,后来排查发现是定时任务的批量操作占了数据库连接,影响了同步请求。如果等到用户反馈才去查,早黄了。

6.4 定时重启与优雅停机

老项目还有个痛点:长时间运行后,即使没有明显内存泄漏,JVM 也会因为堆碎片化、连接残留等导致性能下降。对于这种项目,我见过很多团队直接使用凌晨低峰期自动重启的方案。重启不是最优解,但确实能解决大量莫名其妙的性能问题。

如果要用定时重启,建议用 systemd 管理 Tomcat,而不是裸跑 startup.sh。写一个简单的 systemd 服务文件:

[Unit] Description=Tomcat Server After=network.target [Service] Type=forking Environment=JAVA_HOME=/usr/local/java/jdk17 Environment=CATALINA_HOME=/usr/local/tomcat ExecStart=/usr/local/tomcat/bin/startup.sh ExecStop=/usr/local/tomcat/bin/shutdown.sh TimeoutStopSec=60 User=tomcat Group=tomcat [Install] WantedBy=multi-user.target

这样就可以用 systemctl restart tomcat 做优雅重启。注意 ExecStop 用的是 shutdown.sh 而不是直接 kill,因为 shutdown.sh 会走 JVM 的关闭钩子,给应用一个清理资源和完成请求的机会。直接 kill 可能导致数据库事务中断、缓存数据不一致等问题,在业务系统里不容小觑。

在我实际维护这套 JDK + Tomcat 技术栈的过程中,最大的体会是:这个组合之所以能活这么多年,不是因为技术多么前沿,而是因为它足够简单、足够稳定、出了问题有海量参考资料可查。新东西学得再多,把 JDK 和 Tomcat 这套基本功打扎实,处理线上问题的时候心里会特别有底。

最后分享一个小习惯:每次新装一台服务器跑 Tomcat,我会写一个检查清单,内容大致是"端口通不通、日志有没有 ERROR、磁盘空间够不够、JVM 参数是不是生产配置"。这四条看着不起眼,但每一条都能在关键时刻救你一命。有一次就是靠检查磁盘空间提前发现了日志文件暴涨的问题,避免了一次磁盘写满导致的服务宕机。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进