简介:面向Java Web开发者与运维人员的Apache Tomcat 8.5.100 Linux部署包,对应Java EE 8规范下的Servlet 4.0、JSP 2.3与EL 3.0运行环境,可用于搭建本地测试服务、部署WAR应用及学习容器配置。压缩包解压后共623个文件,以html管理页面与文档、class与java源码示例、jsp页面、jar依赖及xml、properties配置为主,另有sh、bat启停脚本和少量图片、字体资源,整体约10.38MB,目录保持官方发行版结构,便于直接对照conf、webapps、logs等模块查阅。已有2035人学习下载。读者可据此完成Tomcat安装、server.xml端口与连接器调整、Manager用户权限启用、JVM与线程池调优,并结合日志文件和JMX工具排查启动与运行故障,适合作为Java Web环境搭建与Tomcat机制学习的完整参考包。
1. 搜 tomcat apache 找包的人,多半不是在起新项目
会去搜 tomcat apache 下载 tar.gz 的人,十有八九不是在起新项目。新项目一套 Spring Boot 就带内嵌容器了,没人手动解压二进制包。真实场景是:一套跑了七八年的 Java Web 系统,web.xml里写着 servlet 3.1,代码里import javax.servlet.*,往上升 Tomcat 9 勉强能动,升到 Tomcat 10 直接全部报错,因为包名全换成了jakarta.servlet。这种时候 8.5 线就是最省事的落点,而apache-tomcat-8.5.100.tar.gz正是这条线上靠后的二进制包——解压即用,不写注册表,不往系统目录里塞东西,一个 JDK 加一个目录就能跑。
它解决的问题不是“怎么装一个 Web 服务器”,而是“怎么在一台不能随便装软件、不能联网拉依赖的机器上,把一个 javax 时代的 war 稳稳跑起来”。适合三类人:维护存量项目的后端、要在内网离线部署的运维、需要一个干净容器做实验的教学环境。下面按我实际拆包的顺序讲,从目录职责到server.xml再到翻车点。
2. 拆包先看目录:tar.gz 里七个目录各自管什么
tar.gz 这种包最大的好处是“透明”:里面有什么、改了哪里,一眼能看见;最大的坏处也是这个——所有东西都摊在你面前,改错一个文件,排查起来比装 rpm 还费劲。所以第一步不是急着startup.sh,而是把目录职责摸清楚,知道哪些能动、哪些动了升级就丢。
2.1 解压位置、权限与目录职责
解压位置有讲究。放/root下最省事,但一旦用非 root 用户跑 systemd 服务,读不到 jar 也写不了日志,报错还特别隐晦。我一般统一放/opt,并且留一个不带版本号的软链,升级时只改软链,服务脚本和 Nginx 配置一个字都不用动。
# 1) 解压到 /opt,--no-same-owner 防止包内 uid 覆盖本机用户 sudo mkdir -p /opt sudo tar -zxvf apache-tomcat-8.5.100.tar.gz -C /opt --no-same-owner # 2) 建稳定软链,后续所有脚本、systemd、Nginx upstream 都写 /opt/tomcat sudo ln -sfn /opt/apache-tomcat-8.5.100 /opt/tomcat # 3) 交给专用用户,logs/work/temp 必须可写,否则启动时静默失败 sudo useradd -r -s /sbin/nologin tomcat sudo chown -R tomcat:tomcat /opt/apache-tomcat-8.5.100--no-same-owner这个参数是容易忽略的:压缩包里的文件属主信息会被原样还原,如果打包机器上用的是 uid 1000,而你的机器上 uid 1000 是另一个业务账号,权限就会很尴尬。软链用-sfn,-n表示把软链本身当文件处理,避免在软链已存在时被解引用到目标目录里,那样会建出一个/opt/apache-tomcat-8.5.100/apache-tomcat-8.5.100的嵌套目录——这种问题不会报错,只会在你几个月后清理磁盘时才发现。
| 目录 | 职责 | 能不能动 |
|---|---|---|
bin | catalina.sh、startup.sh、shutdown.sh、setclasspath.sh | 不改,升级会覆盖 |
conf | server.xml、web.xml、context.xml、tomcat-users.xml、logging.properties | 主要改这里 |
lib | 容器自身与所有应用共享的 jar | 只放 JDBC 驱动这类公共包 |
webapps | 默认部署目录 | 可以整个换到外部路径 |
logs | catalina.out、localhost_access_log.*、localhost.*.log | 必须配轮转 |
temp | 临时文件,文件上传组件会用 | 建议单独挂盘 |
work | JSP 编译出来的.java/.class | 可以整个删掉,重启自动重建 |
lib那一行要特别说一句。很多人图省事,把业务 jar 甚至servlet-api.jar往里扔,结果就是类加载冲突:容器自己的类加载器优先级和你以为的不一样,ClassCastException、NoSuchMethodError这类问题会以完全不相干的形态冒出来,排查成本极高。work目录倒是可以放心删,JSP 改坏了、页面报奇怪的编译错误,停服删work再起,十次有八次能好——这是最便宜的一招“后悔药”。
2.2 JAVA_HOME 与 CATALINA_HOME 的边界
catalina.sh启动时会先找JAVA_HOME,找不到再退回JRE_HOME,都没有才去PATH里捞java。这个回退链看着贴心,实际是坑:PATH里的java很可能是一个 JRE,跑是能跑,但一旦涉及需要完整 JDK 能力的场景(比如某些编译逻辑),就会缺东西。所以我的习惯是永远显式指定JAVA_HOME,不让脚本自己猜。
JAVA_HOME=/usr/local/jdk1.8.0_xxx CATALINA_HOME=/opt/tomcat # 安装目录,只读 CATALINA_BASE=/opt/tomcat # 运行时实例目录,可写CATALINA_HOME和CATALINA_BASE单实例时是同一个值,多实例部署时才拆开:bin、lib共享一份,每个实例各自有conf、logs、webapps、work、temp。这个设计的好处是升级一次容器版本,五个实例全跟着升;坏处是某个实例的setenv.sh写错了,只有它起不来,很容易被当成偶发问题。
真正靠谱的做法是把环境变量写进bin/setenv.sh。catalina.sh里有这么一段逻辑:只要$CATALINA_BASE/bin/setenv.sh存在就source它。这个口子是官方预留的,升级不会覆盖,也不会像改catalina.sh那样下次换包全丢。
# /opt/tomcat/bin/setenv.sh —— 注意这个文件默认不存在,要自己建,且必须有可执行权限 CATALINA_PID=/opt/tomcat/tomcat.pid # 有了 pid 文件,shutdown.sh 才能精准停,不会误杀别的 java 进程 JAVA_HOME=/usr/local/jdk1.8.0_xxx CATALINA_OPTS="-server -Xms2g -Xmx2g"CATALINA_PID这一行值得单独强调。没有它的时候,shutdown.sh是发信号给“所有匹配的 java 进程”,机器上一旦跑了别的 Java 服务,停 Tomcat 顺手把别人也停了,这种事我见过不止一次。
2.3 第一次启动与三项验证
启动之前先跑version.sh,它不启容器,只打印容器版本和 JVM 版本,能第一时间确认JAVA_HOME指对了没有。
/opt/tomcat/bin/version.sh # 看 Server version 与 JVM Version 是否匹配预期 /opt/tomcat/bin/startup.sh tail -n 50 /opt/tomcat/logs/catalina.out # 重点找 SEVERE 和 "Server startup in ... ms" ss -lntp | grep -E '8080|8005|8009' # 业务端口、shutdown 端口、AJP 端口 curl -I http://127.0.0.1:8080/ # 期望 200,返回体是默认首页version.sh输出的JVM Version是判断环境是否干净的第一道关。ss那一行则决定后面安全配置的方向:8005是 shutdown 端口,8009是 AJP 端口,这两个在生产环境上出现,说明默认配置一个字都没改。curl -I返回的响应头里会带Server: Apache-Coyote/1.1之类的信息,等于把容器类型和大致版本告诉了外面,这也需要处理。
catalina.out要注意,它是标准输出重定向的产物,不会自动切分。跑上几个月,几个 G 很常见,而且这个文件写满磁盘会把整个服务拖死。要么配 logrotate 按天切,要么在logging.properties里把 ConsoleHandler 关掉只留 FileHandler。这是后话,但一开始就得知道。
3.server.xml的 Connector:端口、线程、编码三处必改
conf/server.xml是整个容器最该动、也最容易改坏的文件。它用的是 XML,但没有任何 schema 校验兜底,标签名拼错、属性名写错,容器照样启动,只是那个配置静默失效。我见过最典型的例子:有人把maxThreads写成了maxThread,压测时线程数一直停在 200,查了两天才发现。
3.1 Connector 关键参数表
8.5 的 Connector 默认走 NIO,protocol="HTTP/1.1"会被自动映射到Http11NioProtocol。下面这几个参数是实际调优时绕不开的:
| 参数 | 8.5 默认值 | 什么时候要改 |
|---|---|---|
port | 8080 | 前面挂 Nginx 就改成 8081 并只监听本机 |
address | 不限制 | 必须显式写127.0.0.1或内网 IP |
maxThreads | 200 | 按 QPS×平均 RT 估算,一般不超过 500 |
minSpareThreads | 10 | 低峰保留量,调大可减少突发时的线程创建抖动 |
acceptCount | 100 | 全忙时的等待队列长度,超了直接拒连 |
maxConnections | 10000(NIO) | 与线程数不是一回事,见下文说明 |
connectionTimeout | 60000毫秒 | 内网可降到 20000,防止慢连接长期占位 |
URIEncoding | UTF-8 | 从 7 迁过来的旧配置里若写着 ISO-8859-1,要删掉 |
compression | off | 文本类接口开on,压缩比通常 3~5 倍 |
compressibleMimeType | 一串默认 MIME | 记得补上application/json |
maxThreads和maxConnections的区别值得单独说清楚,因为这俩经常被混为一谈。NIO 模式下,一条连接在空闲等待数据时是不占用工作线程的,由 Poller 负责监听事件,只有真正有请求要处理才交给http-nio-8081-exec-*线程。所以maxConnections远大于maxThreads是完全正常的,前者限制的是“同时挂多少个连接”,后者限制的是“同时有多少个请求在处理”。把maxThreads调到 2000 并不会让系统变快,只会让线程上下文切换把 CPU 吃满。
connectionTimeout的默认 60 秒在公网场景下是合理的,但纯内网、前面还有 Nginx 的情况下,长连接由 Nginx 维持,Tomcat 这一侧的 60 秒反而会让慢客户端占住连接,调到 20 秒更合适。
3.2 关掉 8005 与 AJP,改监听地址
下面这段是我在几乎所有部署里都会用的骨架,重点是三处:Server port="-1"、Connector 加address、AJP 整段注释掉。
<!-- conf/server.xml --> <!-- shutdown 端口设为 -1 表示禁用,只能靠 kill 或 systemd 停;比留一个裸的 8005 安全 --> <Server port="-1" shutdown="SHUTDOWN"> <!-- 业务 Connector:只监听本机,外部流量一律走 Nginx --> <Connector port="8081" protocol="org.apache.coyote.http11.Http11NioProtocol" address="127.0.0.1" maxThreads="200" minSpareThreads="20" acceptCount="100" maxConnections="10000" connectionTimeout="20000" URIEncoding="UTF-8" compression="on" compressionMinSize="2048" compressibleMimeType="text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json" relaxedQueryChars="[]|{}" /> <!-- AJP:不用就整段注释掉,8009 暴露在网络上属于历史遗留的高危项 --> <!-- <Connector port="8009" protocol="AJP/1.3" redirectPort="8443" /> --> </Server>Server port="-1"改动很小但收益明确:默认的 8005 端口配shutdown="SHUTDOWN",任何人只要能连上这个端口并发一个字符串,容器就停了。内网也不该留这个口子。关掉之后停服就只能靠shutdown.sh(依赖CATALINA_PID)或者systemctl stop,这反而倒逼你把进程管理规范化。
AJP 那一段是历史包袱。AJP 协议本身设计于信任边界之内,早期常配 Apache httpd 做前端,后来大量部署把它直接暴露在可访问网络上,就成了问题源头。现在前后端基本都是 Nginx 反代 HTTP,AJP 没有保留的理由,注释掉最干脆。
relaxedQueryChars这个属性放在这里是因为它和 URL 校验绑在一起,下一节会展开——它是 8.5 上被问得最多的一个“玄学 400”。
3.3 URIEncoding 与 access log 的两个细节
编码这件事在 8.5 上其实比 7 省心:从 Tomcat 8 开始,URIEncoding的默认值就是 UTF-8,GET 请求里的中文参数不需要额外配置。真正的坑在于“从 7 迁移过来的配置”。很多老项目的server.xml里明确写着URIEncoding="ISO-8859-1"甚至GBK,当年是为了兼容某套老系统,直接复制到 8.5 上,新接口的中文参数就全乱了。所以迁移时的动作不是“加一行”,而是“检查有没有这一行,有就删掉”。
还要区分清楚:URIEncoding只管 URL 路径和查询串的解码,POST 请求体的编码是应用层的事,靠request.setCharacterEncoding("UTF-8")或者框架里的CharacterEncodingFilter处理,容器管不了。这一点经常被搞混,导致有人反复改server.xml却怎么也修不好表单里的中文。
访问日志则是另一个必须动的地方,默认配置基本没法用:
<!-- 放在 <Host> 标签内部 --> <Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs" prefix="access_log" suffix=".txt" rotatable="true" fileDateFormat="yyyy-MM-dd" buffered="true" pattern="%{X-Forwarded-For}i %a %t "%r" %s %b %D" />rotatable="true"配fileDateFormat按天切分,这是必须的,否则单文件无限增长。buffered="true"减少磁盘 IO,代价是进程被强杀时最后一点日志会丢,能接受。%D是请求处理耗时(微秒),排性能问题时比什么都直接;%{X-Forwarded-For}i放在最前面,是因为 Nginx 反代之后%a记录的永远是127.0.0.1,真实客户端 IP 只能从这个头里拿——前提是 Nginx 侧确实配了proxy_set_header X-Forwarded-For,否则这一列全是空的。
4. 把 war 和配置放进去:部署路径、上下文与 JVM
到这一步容器能起来了,接下来是业务真正落地的地方。部署方式选错,后面每次发布都要停服;JVM 参数写错位置,下次升级容器全丢。这两件事都属于“一次做对,长期省心”。
4.1 三种部署方式与上下文路径
| 方式 | 做法 | 上下文路径 | 适用场景 |
|---|---|---|---|
直接扔webapps | cp app.war webapps/ | war 文件名,如/app | 单应用、图省事 |
server.xml加<Context> | 写在<Host>内 | 由path属性决定 | 不推荐,改配置要重启整个容器 |
conf/Catalina/localhost/xxx.xml | 文件名即上下文路径 | /xxx | 推荐,不动主配置 |
第三种方式是我一直用的:conf/Catalina/localhost/目录下的文件名直接决定上下文路径,文件内容只描述这一个应用。好处是发布时只碰一个独立文件,不动机器上共用的server.xml,出问题也好回滚。
# 1) war 放容器外部,避免升级容器时被一起清掉 mkdir -p /data/apps cp myapp.war /data/apps/myapp.war # 2) 按上下文名建描述文件,文件名 myapp.xml -> 访问路径 /myapp cat > /opt/tomcat/conf/Catalina/localhost/myapp.xml <<'EOF' <?xml version="1.0" encoding="UTF-8"?> <Context docBase="/data/apps/myapp.war" reloadable="false" unpackWARs="true" crossContext="false"> <!-- 数据源放这里,凭据走环境变量或外部配置,别硬编码进 war --> <Resource name="jdbc/main" auth="Container" type="javax.sql.DataSource" maxTotal="50" maxIdle="10" maxWaitMillis="3000" driverClassName="com.mysql.cj.jdbc.Driver" url="jdbc:mysql://127.0.0.1:3306/app?useUnicode=true&characterEncoding=utf8" username="app" password="CHANGE_ME" /> </Context> EOFreloadable="false"是生产必须项。设成true时,容器会周期性扫描WEB-INF/classes和WEB-INF/lib下所有文件的时间戳,稍有变化就重新加载整个应用。开发时方便,生产上就是持续的 IO 和 CPU 开销,而且重新加载会触发类加载器泄漏——每重载一次泄漏一点,跑上几周 Metaspace 就满了。unpackWARs="true"让 war 解压到磁盘,某些依赖文件路径的框架(读模板、读静态资源)必须有真实目录才能工作。
XML 里的&必须写成&,这在 JDBC URL 带多个参数时特别容易忘,症状是启动时解析报错,但错误信息指向的行号经常是上一行,看着莫名其妙。
4.2setenv.sh里堆内存、元空间与 GC
JVM 参数写哪里,决定了你下次换容器版本时要不要重来一遍。写catalina.sh是最差的选择,升级即丢;写/etc/profile.d只影响登录 shell,systemd 根本不读。唯一正确的位置是bin/setenv.sh。
# /opt/tomcat/bin/setenv.sh JAVA_HOME=/usr/local/jdk1.8.0_xxx # JAVA_OPTS 在 start 和 stop 时都会生效,只放通用参数 JAVA_OPTS="-XX:+UseG1GC -XX:MaxGCPauseMillis=200" # CATALINA_OPTS 只作用于启动,重量级参数放这里 CATALINA_OPTS="-server \ -Xms4g -Xmx4g \ -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m \ -Xss1m \ -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/data/dump \ -Dfile.encoding=UTF-8 \ -Duser.timezone=Asia/Shanghai"逐个说。-Xms和-Xmx设成一样,是为了避免堆在运行期反复扩容缩容,扩容触发的是 Full GC,业务低峰期看不出来,高峰期就是一次几百毫秒的停顿。MaxMetaspaceSize必须设上限,因为 Metaspace 用的是本地内存,不设的话类加载泄漏不会 OOM,而是直接把这台机器的内存吃光,然后被系统 OOM Killer 干掉——进程悄无声息地消失,日志里什么都没有,非常难查。
HeapDumpOnOutOfMemoryError配HeapDumpPath是那条“后悔药”:真出问题时至少有个堆快照能分析,不然只能靠猜。注意HeapDumpPath指向的目录要提前建好并且可写,否则转储失败,等于没配。
-Duser.timezone影响日志时间戳、new Date()的默认值和 JDBC 驱动对时间类型的处理。容器所在机器时区不对时,这条比改系统时区安全得多,因为改系统时区会影响机器上其他服务。
-XX:+UseG1GC在 JDK 8 上是可用的,堆大于 4G 时比 CMS 稳,停顿更可控。如果是小堆(2G 以内)且延迟要求不极端,默认的 Parallel 也够用,没必要为了“新”而换。
4.3 systemd 托管与开机自启
startup.sh手动起服务在生产上不可接受:机器重启后服务不会自己回来,进程挂了也没人拉。用 systemd 托管是最小成本的做法。
# /etc/systemd/system/tomcat.service [Unit] Description=Tomcat 8.5 Application Server After=network.target [Service] Type=forking User=tomcat Group=tomcat Environment="JAVA_HOME=/usr/local/jdk1.8.0_xxx" Environment="CATALINA_HOME=/opt/tomcat" Environment="CATALINA_BASE=/opt/tomcat" Environment="CATALINA_PID=/opt/tomcat/tomcat.pid" PIDFile=/opt/tomcat/tomcat.pid ExecStart=/opt/tomcat/bin/startup.sh ExecStop=/opt/tomcat/bin/shutdown.sh Restart=on-failure RestartSec=10 LimitNOFILE=65535 SuccessExitStatus=143 [Install] WantedBy=multi-user.targetType=forking对应startup.sh后台拉起进程的行为,必须配PIDFile,否则 systemd 找不到主进程,systemctl status一直显示activating。SuccessExitStatus=143是因为shutdown.sh结束时返回码可能是 143(128+15,被 SIGTERM 终止),不加这行 systemd 会把正常停止当失败,然后根据Restart=on-failure又给你拉起来,看着像“停不掉”。
LimitNOFILE=65535是另一个必设项。默认的文件句柄限制往往是 1024,连接数一上来就报Too many open files,而这个错误在 Tomcat 日志里表现为莫名其妙的连接重置,很难往句柄数上想。
Environment里重复写JAVA_HOME看着冗余,但 systemd 不读/etc/profile.d,也不读.bashrc,这是唯一能保证它拿到正确 JDK 的方式。
5. 避坑与排查:五个我实际翻过的车
下面这几条都是真实遇到的,按“现象 → 原因 → 解决”写,遇到同类问题可以直接对照。
5.1 启动阶段的三笔账
现象一:执行startup.sh只回一句The CATALINA_HOME environment variable is not defined correctly,或者一闪就回到提示符,logs里什么都没有。
原因在setclasspath.sh:它要靠$JAVA_HOME/bin/java判断 JDK 是否可用,JAVA_HOME没设或者指到了一个 JRE 目录,脚本直接退出,catalina.out还没开始写。解决是把JAVA_HOME显式写进bin/setenv.sh,别依赖export的临时变量,也别指望PATH里的java——那多半是个软链,指向的可能是 JRE。
现象二:BindException: Address already in use,8080 起不来,但ps里又看不到明显的 Tomcat 进程。
原因通常是上一轮没停干净。kill -9强杀之后,端口还在TIME_WAIT或者被一个残留的 java 进程占着;也可能是机器上另有一套服务用了同一个端口。解决分两步:先ss -lntp | grep 8080找出真正占用的 pid,确认是不是自己的;如果是残留,有CATALINA_PID时用shutdown.sh -force,没有就顺着 pid 停。别直接kill -9,那样work目录里会留下编译到一半的中间文件,下次启动可能出现莫名其妙的 JSP 编译错误。
现象三:接口报NoClassDefFoundError: javax/servlet/ServletContext,或者ClassCastException指向一个看着完全无关的类。
原因是 war 里打进了servlet-api.jar或tomcat-embed-core.jar。Maven 里javax.servlet:javax.servlet-api的 scope 应该是provided,打 war 时被排除;一旦写成默认的compile,它就被塞进WEB-INF/lib。容器的类加载顺序是 bootstrap → webapp → system,而javax.servlet.*在容器的过滤名单里本该被忽略,但如果打进的是 embed 包,包名和内部结构不完全一致,过滤就失效了,两套类同时存在,报什么错都有可能。解决方式是检查 war 里的WEB-INF/lib清单,把容器已提供的依赖全部排除。
5.2 部署与访问阶段的两笔账
现象四:war 扔进webapps半天没反应,访问/myapp返回 404,日志里也没有明显报错。
三个常见原因叠在一起。一是autoDeploy被关掉了,容器不会主动扫描新文件;二是 war 文件名带了版本号,myapp-1.0.war的上下文路径是/myapp-1.0而不是/myapp;三是unpackWARs="false"时,某些依赖真实文件路径的框架会读不到资源。判断方法是看catalina.out里有没有Deployment of web application archive ... has finished这一行——有,说明部署本身成功,问题在路径;没有,说明根本没触发部署。稳妥做法是用 4.1 节那种conf/Catalina/localhost/myapp.xml显式指定docBase,路径和文件名解耦,怎么改 war 名字都不影响访问地址。
现象五:接口返回 400,日志里写着Invalid character found in the request target。
这是 8.5 上最容易被当成“玄学”的一个。容器按 RFC 7230 严格校验请求行里的字符,|、{、}、[、]、\这些不在允许集合里的字符会被直接拒掉。前端在查询参数里塞了个 JSON 串、或者导出功能带了个ids=[1,2,3],就中招了。解决办法有两个:前端老老实实encodeURIComponent,这是根治;或者在 Connector 上加relaxedQueryChars="[]|{}",把真正用到的字符放开。别图省事把能想到的字符全列进去,这等于把容器的输入校验关掉,后面出事更难查。
6. 从能跑到跑得稳:压测前我必做的一轮核对
容器能起来、页面能打开,离“能扛住流量”还差一段。压测之前,我固定跑一遍三条命令,花不到两分钟,能挡掉大部分上线后才会暴露的问题。
PID=$(cat /opt/tomcat/tomcat.pid) # 1) 监听面核对:8005/8009 不该出现,8081 应只绑 127.0.0.1 ss -lntp | grep -E '8080|8081|8005|8009' # 2) 压测期间看 GC:FGC 次数基本不涨,说明堆给够了;持续上涨就是堆太小或有内存泄漏 jstat -gcutil $PID 1000 10 # 3) 看工作线程是否顶到 maxThreads:接近 200 说明瓶颈在应用处理速度 jcmd $PID Thread.print | grep -c 'http-nio-8081-exec' # 4) 连接状态:CLOSE_WAIT 堆积说明应用没关流,TIME_WAIT 多则是短连接太频繁 ss -sjstat那一条重点看FGC列和FGCT列。压测十分钟,FGC从 0 涨到 3、5 次还算正常;涨到几十次,说明对象晋升太快,要么堆不够,要么有地方在疯狂创建大对象。jcmd Thread.print统计http-nio-8081-exec开头的线程数,这个数字长期贴着maxThreads,加线程没用,得回头看慢 SQL 或者外部接口超时。
还有一个容易被忽略的比例:数据库连接池的maxTotal要小于 Connector 的maxThreads。如果线程池 200、连接池 50,那么 150 个线程会在拿连接这一步排队,表现为“CPU 不高、线程都在等”,从容器指标上完全看不出来。我一般把连接池设成线程数的 1/4 到 1/2,再用maxWaitMillis设个 3 秒超时,让它快速失败而不是无限等待。
最后是日志。上线前确认三件事:catalina.out有轮转策略、access_log开了buffered、JVM 参数里有HeapDumpPath且目录可写。这三件事平时毫无存在感,出事那天就是全部。
从那以后,我每次部署完都强制走一遍这套核对——version.sh看环境、ss看监听面、jstat看堆、Thread.print看线程,四步走完再交给压测。跳过哪一步,后面都得用更长的时间补回来。希望帮到你。
本文还有配套的精品资源,点击获取