ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Tomcat 8.0.47 生产环境并发调优与连接池故障排查实践

Tomcat 8.0.47 生产环境并发调优与连接池故障排查实践

简介:Tomcat 8.0.47 是一份基于 Java 的 Web 服务器资源包,面向 Java Web 开发人员、运维工程师以及入门学习者,实现了 Servlet 与 JSP 规范,适合部署小型到中型 Web 应用、搭建本地学习环境,也可作为理解容器原理的参考材料。压缩包内共包含 628 个文件,文件类型主要包括 html 静态页面、Java 源码、编译后的 class 文件、JSP 动态页面、jar 依赖库以及 xml、sh、bat 等配置与启动脚本,整体压缩后大小约为 9.75 兆字节,目前已有 122 人学习下载。包内保留了完整的 Tomcat 目录结构,解压后即可直接部署运行;其中可以看到 Catalina 容器核心、Jasper 引擎、Coyote 连接器等相关文件,并支持通过 server.xml、web.xml 调整端口、连接器和安全约束,借助 manager 管理界面监控部署状态,从 logs 日志中排查运行问题。无论用于实际部署还是深入学习 Tomcat 的组件协作方式、目录组织或配置思路,这份资源都具备较高的参考价值。

1. 还在用 Tomcat 8.0.47 的生产环境:这版老实例到底能扛多少并发

如果你接手过任何一台上线超过三年的服务器,大概率见过 Tomcat 8.0.47 的目录结构。Apache 下的这个经典版本,是很多旧系统的默认容器,没换过。它的价值不是新特性,而是稳定、资料多、踩坑记录全。但问题也出在这:网上教程大多写给 8.5 或 9.0,直接套用到 8.0.47 上,经常出现配置项不识别、连接池行为不一样的情况。这篇文章从部署核对、数据源配置、JVM 参数一直写到常见故障排查,用的都是 8.0.47 实际会遇到的情况,适合正在维护旧系统、或者被分配了迁移升级任务的开发者和运维。没有空泛的原理,全部是可验证的操作。

2. 部署前的环境与配置核对:lib 目录、server.xml、web.xml 的一张核对清单

2.1 环境变量与启动脚本:JAVA_HOME 和 CATALINA_HOME 最容易埋雷

Tomcat 8.0.47 要求 JDK 7 及以上,但我在生产环境见过两种典型问题:JAVA_HOME 指向了 JRE,或者 CATALINA_HOME 与实际解压目录不一致。前者会导致后续无法用 jstack、jmap 这类排查工具,后者会让 Tomcat 找到一套错误的配置文件,表象是改了 server.xml 不生效。启动脚本里最常用的检查命令如下:

# 先确认 Java 版本,再确认 Tomcat 是否真的用了这个 Java echo $JAVA_HOME /usr/lib/jvm/java-8-openjdk-amd64/bin/java -version # 如果只想临时指定一次,不写入全局环境变量 export JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 export CATALINA_HOME=/opt/apache-tomcat-8.0.47 $CATALINA_HOME/bin/startup.sh

我一般会写进 /etc/profile.d/tomcat.sh,但注意用source重新加载后再启动,避免当前 shell 没拿到新变量,导致脚本里实际用的是旧值。这段代码的核心逻辑是:启动前用 echo 输出两个变量,确认不是空路径,因为 Tomcat 的 catalina.sh 对空变量有默认值兜底,一旦兜底生效,配置就完全不可控。

2.2 server.xml 核心标签:Executor、Connector、Host 的参数边界

8.0.47 的 server.xml 里,最值得逐行核对的是连接器和线程池。默认配置是并发能力偏保守的,多个 Connector 各用自己的线程池,高峰期容易互相抢资源。常见的做法是把线程池抽出来共用:

<Executor name="tomcatThreadPool" namePrefix="catalina-exec-" maxThreads="400" minSpareThreads="50" prestartminSpareThreads="true"/> <Connector executor="tomcatThreadPool" port="8080" protocol="HTTP/1.1" connectionTimeout="20000" acceptCount="300" maxKeepAliveRequests="100" URIEncoding="UTF-8"/>

这段配置里最关键的参数是maxThreads和acceptCount。maxThreads 是最大工作线程数,超过这个数后,新连接不会立即被拒绝,而是排进 acceptCount 的队列。如果两者都设得太小,连接会直接被重置,表现是浏览器转圈几秒然后报 502。8.0.47 的 BIO 已不建议在压力大的场景使用,NIO 是默认选择,Connector 的 protocol 按 HTTP/1.1 写就行,不需要显式写成 NIO,除非你明确要压测对比。

Host 标签里有一个常被忽略的属性autoDeploy。开发环境开着方便,但生产环境开着,只要 webapps 目录里出现新的 war 包或者目录变化,Tomcat 就会热部署,引发类加载器混乱。我接触的系统里,线上一般设置为 false,发布时手动触发热部署。

2.3 web.xml 中的会话超时与全局配置

8.0.47 的 web.xml 位于 conf 目录,是全局生效的。会话超时、MIME 映射、欢迎文件列表都在这里。有个容易忽略的细节:全局 web.xml 里的 session-config,如果你在应用自己的 web.xml 里写了相同配置,应用的会覆盖全局的,这符合 Servlet 规范的覆盖逻辑,不是 Tomcat 例外。建议在应用层面统一写,全局只保留默认 MIME 映射,避免多个应用互相影响。

<session-config> <session-timeout>30</session-timeout> <cookie-config> <http-only>true</http-only> <secure>false</secure> </cookie-config> </session-config>

session-timeout 单位是分钟,生产系统如果做的是后台管理类应用,30 分钟是常见值。http-only 建议保留开启,能避免部分 XSS 会话劫持问题。secure 标记在只有 HTTP 访问的内网环境不要开,否则浏览器会拒绝回传 JSESSIONID,用户第一次登录后直接跳回登录页。

3. 数据源与连接池实战:从 JNDI 配置到数据库连接池参数的取舍

3.1 JNDI DataSource 配置模板:Resource 标签怎么放到 Context

很多旧系统在代码里自己写 JDBC 连接,每次访问数据库都新建连接,压力稍微一上来数据库就大量 sleep 连接。改造方式是把数据源下沉到 Tomcat 的 JNDI,应用通过javax.sql.DataSource获取连接。8.0.47 中可以在 conf/context.xml 直接配置全局数据源,也可以放在应用 META-INF/context.xml,后者隔离性更好,每个应用自己维护。

<Context> <Resource name="jdbc/orderDB" auth="Container" type="javax.sql.DataSource" factory="org.apache.tomcat.jdbc.pool.DataSourceFactory" driverClassName="com.mysql.jdbc.Driver" url="jdbc:mysql://192.168.1.10:3306/order?useUnicode=true&amp;characterEncoding=UTF-8" username="app_user" password="encrypted_password" initialSize="10" maxActive="80" maxIdle="20" minIdle="5" maxWait="10000" validationQuery="SELECT 1" testOnBorrow="true" testWhileIdle="true" timeBetweenEvictionRunsMillis="30000" minEvictableIdleTimeMillis="60000" removeAbandoned="true" removeAbandonedTimeout="60" logAbandoned="true"/> </Context>

注意javax.sql.DataSource不要引用错包,factory 必须用 Tomcat 自带连接池的工厂类,如果用 DBCP 工厂,很多参数行为和默认值会不一样。上面配置的核心逻辑是让连接池在启动时创建 10 个连接,上限 80,当请求超过连接数时最长等待 10 秒。testOnBorrow 是每次取连接前验证是否有效,生产环境必须开,否则数据库重启后应用拿到的全是死连接。removeAbandoned 表示超过 60 秒没用完的连接会被强制回收,配合 logAbandoned 可以定位代码里忘记归还连接的位置,但要注意这个机制只对物理连接生效,在应用层持有连接做长事务的场景千万别开。

3.2 连接池参数怎么按业务定:初始大小和最大活跃数的经验值

我处理过的系统里,最常见的配置事故是 maxActive 设得和数据库 max_connections 一样大。比如 MySQL 默认最大连接数 151,你每个 Tomcat 节点 maxActive 设 200,两个节点一起跑,数据库直接拒绝连接。正确做法是先确认数据库上限,再除以节点数,留 20% 余量。比如数据库允许 200 个连接,2 个 Tomcat 节点,每个节点 maxActive 建议在 80 左右。initialSize 不要太大,启动时建立太多连接会让应用启动变慢,一般在 10 到 20。minIdle 和 maxIdle 控制空闲存活数,短连接请求多的系统把 minIdle 调高能明显降低创建连接的延迟,但空闲连接本身也会占用数据库资源,要根据实际请求频率来,不是越大越好。

maxWait 这个参数非常关键。如果连接池耗尽,应用线程会进入等待。等待时间过长,前端请求堆积,表现是卡顿和超时;等待时间过短,数据库压力还没消掉,应用直接抛连接获取失败异常。10 秒是相对保守的值,如果要更快速失败,可以设 3000,让上游快速返回错误,而不是把所有线程都挂起等待。

3.3 Tomcat JDBC Pool 与 DBCP2 的选择:8.0.47 里的默认差异

8.0.47 默认自带的连接池实现是 Tomcat JDBC Pool,和 DBCP2 在参数名上接近,但细节行为不同。最大区别是 validationQuery 的处理:Tomcat Pool 在配置了 testWhileIdle 时,即使不写 validationQuery,它也会根据 JDBC4 的 isValid 方法做检测,DBCP2 也会这样,但老版本 DBCP 在 MySQL 驱动低版本下会偶发假阳性。如果你维护的系统里有历史代码硬编码依赖 DBCP 的某些 SqlLog 类,迁移到 Tomcat Pool 时会编译不过,这种就沿用 DBCP,否则优先用默认的 Tomcat Pool,少引入一套依赖库,还能减少连接池版本冲突。选择原则是:能用默认就不额外加 jar,出了问题排查路径短很多,这条路我走过太多次了。

4. 压力测试与 JVM 调优:GC 日志、线程栈和三个参数组合

4.1 先从最小 JVM 参数模板开始:别把网上的大堆配置直接拷过来

JVM 调优是最容易变得“玄学”的环节,网上能找到大量大内存参数,直接贴到 8.0.47 的 catalina.sh 里,新老系统内存管理模型不同,反而容易触发严重停顿。推荐的起点模板如下,先把日志和基础参数做对,再根据压力测试结果调整:

JAVA_OPTS="-server -Xms1g -Xmx2g -Xss512k -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m \ -XX:+UseParallelGC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/tomcat \ -Xloggc:/var/log/tomcat/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps \ -Djava.awt.headless=true -Dfile.encoding=UTF-8"

这里有几个关键参数:MetaspaceSize 和 MaxMetaspaceSize 在 8.0.47 对应的 JDK 7/8 里很关键,如果没设置,加载大量 class 的应用会不断触发 Full GC 来调整元空间,表现是 CPU 高但业务慢。UseParallelGC 适合多数 Web 应用追求吞吐量的场景,如果你的系统对响应时间要求更敏感,可以改用 G1,但 G1 在 8.0.47 早期版本里稳定性不如 ParallelGC,除非你真的用了压测数据证明 G1 更好,否则从 ParallelGC 起步更稳。HeapDumpPath 必须指向有足够磁盘空间的位置,生产上我见过因为没有这个参数,内存溢出后无法拿到 dump 文件导致反复重启的情况。

4.2 压测时看三个指标:GC 频率、线程状态和堆使用趋势

压测时不要只盯着 TPS,先把 GC 日志抓到再说。启动后先跑一轮小流量,然后看 gc.log:

# 统计 GC 次数和停顿时间,用 awk 按行过滤即可 grep "Full GC" /var/log/tomcat/gc.log | wc -l # 查看最近一次 Full GC 的停顿时间 grep "Full GC" /var/log/tomcat/gc.log | tail -5

Full GC 次数如果超过每 10 分钟一次,说明堆大小或对象生命周期设计有问题。此时用 jstat 观察实时数据更直观:

# 每 1 秒打印一次堆使用情况,共打印 20 次 jstat -gcutil 12345 1000 20

12345 是 Tomcat 的 PID,可以用 jps 拿到。输出的 S0、S1、E、O 分别代表幸存区、Eden 区、老年代使用率,重点关注 O 列的波动趋势。如果老年代使用率稳定在 80% 以上,每次 GC 后下降不了多少,说明有大对象或者内存泄漏嫌疑,下一步要抓堆。如果老年代在每次 Full GC 后能回到 30% 以下,说明只是堆容量偏小,调大 -Xmx 即可。

线程栈抓取是定位线程卡死的手段,压测中如果发现请求无响应,先抓一遍线程:

# 抓取线程快照,连续抓 3 次,间隔 5 秒,避免单次快照误判 jstack 12345 > /tmp/thread_1.txt sleep 5 jstack 12345 > /tmp/thread_2.txt sleep 5 jstack 12345 > /tmp/thread_3.txt

对比三个文件里同一线程的栈顶,如果每次都在相同位置,基本可以断定是锁等待或外部资源调用超时。把多次快照的线程状态汇总,统计处于 WAITING 状态的线程数有没有持续上升,就能判断连接池或线程池参数是否需要调整。

4.3 三套参数组合:给不同并发量级的系统做参考

根据压测结果,实际会落到三种参数组合。并发量在 200 以下的中小型管理系统,JVM 堆 1g 到 2g,线程池 maxThreads 200,acceptCount 100,GC 用 ParallelGC,这是最省心的组合,压测时几乎不会出现明显瓶颈。并发量在 500 左右的应用系统,堆调到 2g 到 4g,maxThreads 400,acceptCount 200,此时要特别留意数据库连接池和文件句柄上限,单纯调 Tomcat 线程池并不能解决全部问题。并发量超过 1000 的网关类系统,先考虑加节点,而不是单机调优,单机硬扛的代价是频繁 Full GC 和不可预测的响应时间,遇到这种规模第一反应应该是横向扩展,而不是查参数表。

参数调整后必须重新压测做前后对比,不要凭感觉。每次只改一个变量,比如这次只调 maxThreads,下次再改 acceptCount,两个一起容易互相掩盖效果,出了问题往回查也麻烦。这个习惯帮我避过很多坑。

5. 避坑排查:五个反复出现的问题与对策

5.1 应用重启后偶发 404:类加载器冲突

现象:应用启动日志显示正常,但访问某个 Servlet 时偶发 404,刷新几次又能通,没有固定规律。
原因:webapps 目录下有历史遗留的旧版本 war 解压目录,和当前加载的应用形成了同名不同版本的类加载器叠加。8.0.47 热部署时不会完全清理旧类,容易造成同一个路径由两个不同版本类各响应一次。
解决:关闭autoDeploy后,删除 webapps 下所有同名目录,重启确保只加载当前要发布的版本。检查CATALINA_BASE/webapps下是否存在带时间戳后缀的临时目录,如果有,先停服务再清掉。

5.2 Tomcat 启动时端口占用:netstat 看到的是 TIME_WAIT

现象:重启时报Port already in use,但netstat -anp | grep 8080却看不到 LISTEN 状态的进程。
原因:端口被占用未必是当前进程,可能是上个进程退出后留下的 TIME_WAIT 连接,8.0.47 在端口复用参数未开启时,遇到大量 TIME_WAIT 可能无法立即绑定。
解决:先netstat -tlnp | grep 8080看监听进程,如果没有 LISTEN,再检查ss -s看 TIME_WAIT 数量是否异常。CIPHER 严重时可以等待 60 秒,或者用sysctl -w net.ipv4.tcp_tw_reuse=1临时加快回收,生产环境这个值建议由网络运维统一评估,不要自己一改就完事。这条问题我一周内遇到过两次,后来总结出规律:先查监听,再查等待,不要一看到端口占用就杀进程,容易误杀别的服务。

5.3 高负载下 Tomcat 停止响应:线程池打满但 CPU 不高

现象:请求全部超时,查看线程栈发现大量线程卡在java.net.SocketInputStream.socketRead0。
原因:外部接口变慢导致调用线程长时间阻塞,Tomcat 工作线程全部被占用,新请求进不来。这不是线程池不够大,而是下游服务拖住了线程。
解决:给调用下游服务的代码设置显式超时时间,例如 3 秒,不要依赖默认的无限等待。同时调低 maxThreads 到 200 左右,让快速失败的请求能有机会被处理,总比所有线程挂死好。设置超时后压测,观察线程状态里 RUNNABLE 的比例是否回升,这个方法比单纯加大线程池有效得多。

5.4 数据库连接池耗尽后恢复不了:连接泄漏的隐藏点

现象:压测结束后,数据库连接数依然保持在高位,应用里报获取连接超时。
原因:代码里使用完的 ResultSet、Statement 没有关闭,连接无法归还池中。部分框架封装下,连接关闭了但游标没有释放。
解决:在连接池配置中开启removeAbandoned和logAbandoned,前者强制回收,后者打印泄漏时的调用栈。找到调用栈后修复对应的 try-with-resources 结构,确保 finally 块关闭所有资源。排查时注意 SQL 查询量大时,ResultSet 不关闭也会占用物理连接,这种问题在日志里往往看不到异常,只能靠 logAbandoned 输出定位。

5.5 修改 server.xml 后配置不生效:从自定义环境脚本查起

现象:改了端口或参数,重启后生效,但过一段时间自动变回旧值,或者只有部分配置文件加载了修改。
原因:部署环境存在多个 CATALINA_BASE 目录,启动脚本没有显式指定,Tomcat 有时会去读取默认路径下的配置文件;同时克隆虚拟机模板里的 Tomcat 目录可能保留了旧配置。
解决:在 catalina.sh 顶部强制指定CATALINA_BASE为实际使用的目录,并在 server.xml 开头用注释标记修改时间和原因。排查时先ps aux | grep tomcat看实际启动的是哪个目录下的脚本,再用ls -l /proc/[pid]/cwd确认当前工作目录,这两个命令能快速定位是不是读错了配置文件。

6. 验证技巧:十分钟检查实例健康与隐藏风险

配置和调优做完了,最后一步不是直接放量,而是快速验证。我习惯在每轮调整后用一组命令做体检,不依赖监控平台,能够快速确认核心指标。

第一个动作看整个 JVM 的堆情况。用 jmap 输出堆直方图,能直观看到有哪些对象占用了大量内存。历史系统常见的是 char[] 和 byte[] 占比过高,这通常是字符串操作或者文件读取没有释放缓冲区。如果始终看到某个自定义业务对象排在列表前面,说明内存泄漏点就在这些对象的创建路径上。

jmap -histo:live 12345 | head -30

第二个动作检查线程池实际大小和执行状态。不需要看配置值,直接看运行值。用 jstack 抓一次线程快照,统计http-nio-8080-exec-开头的线程数量,如果运行中的线程数经常达到配置的 maxThreads,说明请求吞吐已经接近边界。再看这些线程的栈顶,如果大量出现在 JDBC 调用相关代码,应当优先查数据库连接池的连接获取时间,而不是盲目扩容。

jstack 12345 | grep "http-nio-8080-exec" | wc -l

第三个动作确认 GC 日志里的一个关键指标:Full GC 后的老年代使用率趋势。连续对比三轮压测的 gc.log,如果每轮 Full GC 后老年代基线不断上移,说明有稳定的内存累积,可能不是泄漏但是存在缓存未清理的问题。确认方式是定时抓两次堆直方图对比,如果某个类对象数量持续增加,顺着它的引用链找持有者,一般都能定位到缓存的 key 设计问题或静态集合的清理缺失。

grep "Full GC" gc.log | awk '{print $NF}' | tail -10

最后用一个综合命令一次性输出关键状态的快照。包括进程存活、端口监听、JVM 堆使用率和线程数,适合调整完参数后快速确认服务状态没有偏离基线。

# 按需组合,输出一行状态信息 echo "PID=$(ps -ef | grep '[o]rg.apache.catalina.startup.Bootstrap' | awk '{print $2}') \ PORT=$(ss -tlnp | grep 8080 | wc -l) \ THREADS=$(ps -L -p $(pgrep -f org.apache.catalina.startup.Bootstrap) 2>/dev/null | wc -l)"

我把这一整套方法固化成了一个固定的发布检查脚本,每次改完配置、升级完依赖,都要强制走一遍这些命令才允许提交上线。Tomcat 8.0.47 的调试之所以让人头疼,是因为问题往往不在单个配置项上,而是多个参数互相影响,比如线程池配置相对保守时,连接池的 maxWait 偏长会导致线程被无效挂起,堆直方图里又会显示出大量等待中的线程对象,看起来像内存问题,实质还是线程问题。这种干扰项在排查时很容易分散注意力,所以我的习惯是每次只改一个维度,其余保持基线不变,验证完再动下一个。这套做法帮助我减少了很多重复排障的时间,也希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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