1. 从一次线上故障说起:为什么我劝你先看懂server.xml再改配置
大概两年前,我接手过一个老项目的运维工作。交接文档里写得很简单:“Tomcat启动慢,偶尔报连接超时,调一下server.xml就好。”当时我没多想,顺着前人的思路,把Connector的maxThreads从200调到了800,又把connectionTimeout从20000调到了60000。结果呢?服务确实不报超时了,但CPU直接飙到90%以上,接口响应反而更慢了。后来回滚配置、翻源码、查线程栈,折腾了一整天才定位到问题根源——根本不是线程数不够,而是Host节点的appBase配置指错了目录,导致Tomcat每次部署都会重复扫描整个磁盘的临时文件。
那次以后我就养成了一个习惯:不管在哪个项目里,先花半小时把server.xml的完整逻辑彻底梳理一遍,再动手改任何一个参数。 因为Tomcat的server.xml是它的“总控开关”,里面每一个标签、每一个属性的搭配方式,几乎决定了你部署的Web应用能不能跑得稳、跑得快。你在网上搜到的很多配置片段,比如“Tomcat调优”、“Tomcat连接数优化”、“Tomcat虚拟主机配置”,本质都是在改这一个文件。
这篇文章我想用自己实际排查、调优、迁移过程中积累的经验,把server.xml从结构到细节完整拆一遍。适合这三类人看:刚接触Tomcat、被各种配置项弄晕的新手;正在做应用迁移、需要在新服务器上快速复现环境的人;以及遇到高并发、启动失败、乱码、CPU异常等问题的排障人员。文章不会只贴配置片段,更多是解释“为什么这么配”,以及哪些地方的坑我替你踩过了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. server.xml整体结构:先搞清楚谁管谁、谁听谁的
2.1 一个最小化配置长什么样
先看一个最精简的、能跑起来的server.xml骨架:
xml复制<?xml version="1.0" encoding="UTF-8"?>
<Server port="8005" shutdown="SHUTDOWN">
<Listener className="org.apache.catalina.startup.VersionLoggerListener" />
<Service name="Catalina">
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" />
<Engine name="Catalina" defaultHost="localhost">
<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="true">
<Context path="" docBase="myapp" reloadable="false" />
</Host>
</Engine>
</Service>
</Server>
这段配置里,从外到内一共五层:Server是最外层容器,一个Server可以包含多个Service;Service是逻辑分组,负责把一个或多个Connector绑定到一个Engine上;Connector负责接收外部请求,也就是监听端口的那一层;Engine负责处理Connector转进来的请求,内部按Host来路由;Host表示一个虚拟主机,对应一台服务器上的一个域名或IP;Context则精确到一个具体的Web应用。
很多新手在这里容易犯迷糊:Service、Connector、Engine、Host、Context这五个词到底分别对应什么?我个人习惯用公司的组织结构来类比——Server是整个集团,Service是事业部,Connector是前台接待窗口,Engine是事业部里的总调度,Host是调度手下按区域划分的主管,Context则是主管手里真正干活的具体项目组。一个请求进来,先是前台(Connector)接单,然后交给总调度(Engine),总调度根据对方找的是哪个区域(Host),把单子分给对应主管,主管再安排具体项目组(Context)执行。
2.2 必配项和可选项:哪些标签不能少
从长期实操来看,真正必须存在的标签其实很少。一个基本的server.xml至少要有Server、Service、Connector、Engine、Host这几层,否则Tomcat无法正常启动。其余像Context、Listener、GlobalNamingResources、Realm、Valve、Cluster这些都算可选,按需添加。
但“必须”不等于“推荐”。比如Context标签,虽然现在很多项目习惯把WAR包直接丢进webapps目录靠自动部署,但如果你有“一个Host下部署多个不同路径的应用”或“应用目录放在webapps之外的磁盘路径”这类需求,就必须显式声明Context,否则Tomcat的默认行为可能跟你的预期对不上。
再比如Listener标签,通常不会被人手动修改,但它里面藏了一些很关键的行为。VersionLoggerListener负责输出启动版本信息;AprLifecycleListener负责在存在APR原生库时启用高性能连接器;JreMemoryLeakPreventionListener解决类加载器相关的内存泄漏问题。把这些Listener删掉,短期内项目可能还能跑,但长期看会出现很多莫名其妙的问题,比如Tomcat反复reload后老年代内存持续上涨。
3. Connector深度解析:端口、协议、线程池和超时到底怎么配
3.1 三种常见protocol的区别与选型
Connector是整个server.xml里改动最频繁、也最影响性能的部分。它有一个关键属性叫protocol,这个属性决定了Tomcat用什么方式处理网络连接。常见的三种写法:
| protocol值 | 实际使用的连接器 | 适用场景 |
|---|---|---|
| HTTP/1.1 | BIO(阻塞式)已废弃,实际为NIO | 绝大多数HTTP请求场景,默认推荐 |
| org.apache.coyote.http11.Http11NioProtocol | NIO,非阻塞I/O | 高并发、长连接场景的首选 |
| org.apache.coyote.http11.Http11AprProtocol | APR,基于本地库 | 对性能有极致要求,且已安装APR库 |
实际部署中,Tomcat 8.5及以后版本直接写protocol="HTTP/1.1"就已经走NIO了,不需要再显式写那一长串类名。APR则需要额外安装tomcat-native库,普通项目不太建议一上来就上APR,收益未必明显,但环境配置复杂度会上升一个档次。
3.2 线程池参数:maxThreads、minSpareThreads、acceptCount的前世今生
讲线程池之前,先说一个背景。Tomcat每个Connector背后都会维护一个线程池,这个线程池不是无限大的,有一个核心值、一个最大值和一个等待队列。这三个值的配置关系,直接决定你在高并发下的表现。
xml复制<Executor name="tomcatThreadPool" namePrefix="catalina-exec-"
maxThreads="300" minSpareThreads="50" maxIdleTime="60000" />
<Connector port="8080" protocol="HTTP/1.1"
executor="tomcatThreadPool"
connectionTimeout="20000"
acceptCount="100"
maxConnections="10000" />
这里的逻辑是:当请求进来时,Connector先看当前线程数是否小于minSpareThreads,小于则创建新线程;然后看是否小于maxThreads,继续创建;如果已经达到maxThreads,新请求会进入等待队列,队列长度由acceptCount控制;队列也满了,Tomcat就会拒绝连接。
很多网上教程喜欢直接告诉你maxThreads设成多少多少,但我更建议根据你的机器核数和接口耗时来定。一个粗略的估算方法是:maxThreads ≈ CPU核数 × (1 + 平均等待时间 / 平均处理时间)。如果接口平均处理时间10ms、等待时间90ms,四核机器大概可以配400左右。但如果是IO密集型、大量接口都在等待下游返回,线程数可以适当放大;如果是CPU密集型,线程数超过核数两倍反而会带来上下文切换开销。
我曾经遇到过一个极端案例,项目方把所有接口都调成同步调用Redis和数据库,每个请求平均耗时200ms,然后他们按“默认配置”把maxThreads调到1000,结果CPU负载持续90%以上。后来把线程数降到200,接口吞吐反而提升了一倍。线程池参数一定要结合接口耗时特征来调,不能盲目堆大。
3.3 connectionTimeout、keepAliveTimeout和maxKeepAliveRequests的配合
这三个属性管的是连接的生命周期,很多人调了半天端口都通不了,问题就出在这几个值上。
connectionTimeout表示服务端等待客户端发送完整HTTP请求的最长时间。默认20000毫秒,也就是20秒。这个值不要设得过大,否则恶意连接可以一直占着线程不放;也不要设得太小,不然网络延迟稍微高一点,正常用户的请求就会被掐断。一般局域网内服务设10到15秒就够,跨公网的可以放宽到30秒。
keepAliveTimeout是HTTP长连接的空闲超时,表示一个连接在处理完一次请求后,如果客户端在指定时间内没有发起下一次请求,服务端就主动关闭连接。默认和connectionTimeout一致。如果项目上有大量短连接请求,比如移动端频繁轮询,把keepAliveTimeout设短一点可以让Tomcat更早释放连接资源。
maxKeepAliveRequests表示一个长连接最多可以复用多少次。默认100次。对HTTP/1.1来说,建议设成-1表示不限制,或者设一个较大的值,避免客户端频繁重新建立TCP连接带来的握手开销。
3.4 端口配置里最容易踩的三个坑
第一,port和redirectPort的区别。很多人以为redirectPort是“把HTTP请求自动转发到HTTPS”,其实不是。redirectPort的真正用途是:当请求需要SSL加密但当前Connector是明文HTTP时,Tomcat会把请求重定向到redirectPort指定的端口。它是否生效,取决于你是否配置了相应的SSL Connector和约束规则。
第二,URIEncoding参数。默认情况下,Tomcat 8.5及以上版本URI编码已经是UTF-8,但老版本迁移过来的项目可能还依赖ISO-8859-1。如果你遇到“GET请求传中文参数乱码”,十有八九是这里的问题。建议在Connector上显式加上URIEncoding="UTF-8",防止依赖环境默认值。
第三,port冲突。启动时报BindException: Address already in use,不要只想着换端口,先查是不是有别的进程占用了。Linux下可以用lsof -i:8080或者netstat -tunlp | grep 8080快速确认。我见过一个案例,服务器上跑着一个旧的Java进程没杀干净,新服务怎么启都报端口被占,最后才发现是同一个应用启动了两次。
3.5 压缩配置:Gzip能省流量,但别把PDF也压一遍
Tomcat支持HTTP响应Gzip压缩,通过Compression相关属性控制:
xml复制<Connector port="8080" protocol="HTTP/1.1"
compression="on"
compressionMinSize="2048"
noCompressionUserAgents="gozilla, traviata"
compressibleMimeType="text/html,text/xml,text/plain,text/css,application/javascript,application/json" />
compression有三个值:off(关闭)、on(开启)、force(强制,忽略最小大小限制)。建议用on,并配合compressionMinSize限定大于等于2KB的响应才压缩,因为小响应压缩后体积未必变小,反而浪费CPU。compressibleMimeType一定要把需要压缩的MIME类型列全,否则你会发现配了compression="on",但接口返回的JSON根本没过压缩——因为默认的compressibleMimeType列表里只有text开头的几种,不含application/json。
这里有一个实操经验:不要对图片、视频、PDF这类已经压缩过的二进制资源再开启Gzip。 一方面压缩效果微乎其微,另一方面会白白消耗CPU。配置里不列这些MIME类型就行。
4. Engine和Host:虚拟主机、默认域名和应用部署目录的玩法
4.1 Engine的defaultHost是什么意思
Engine是Servle容器内部的顶层组件,它本身不直接处理请求,而是根据请求头里的Host字段,把请求路由给对应的Host。如果找不到匹配的Host,就用defaultHost指定的那个。
xml复制<Engine name="Catalina" defaultHost="localhost">
这里有个容易忽略的点:defaultHost必须是在Engine内部真实存在的Host name之一,否则启动时会直接报错。也就是说你不能写一个不存在的虚拟主机名作为默认值。
实际工作中,如果你一台服务器上要部署多个不同域名的项目,就会出现多Host配置:
xml复制<Engine name="Catalina" defaultHost="www.example.com">
<Host name="www.example.com" appBase="webapps" unpackWARs="true" autoDeploy="true">
<Context path="" docBase="/data/apps/web1" reloadable="false" />
</Host>
<Host name="admin.example.com" appBase="/data/apps/admin" unpackWARs="true" autoDeploy="true">
<Context path="" docBase="." reloadable="false" />
</Host>
</Engine>
第二种Host的appBase直接指向了/data/apps/admin,你同样需要确认这个目录存在,否则Tomcat启动时会因为目录不存在而抛异常。
4.2 Host的appBase、unpackWARs、autoDeploy组合起来的作用
appBase是Host节点下Web应用的存放目录,默认值是webapps,相对路径相对于Tomcat安装目录。unpackWARs为true时,Tomcat会把WAR包解压成目录再运行;为false时直接以WAR包形式运行。autoDeploy为true时,Tomcat会定时扫描appBase目录,发现新增WAR包或目录就自动部署。
这三者的组合有讲究:
| unpackWARs | autoDeploy | 实际效果 |
|---|---|---|
| true | true | 开发环境最常用,改代码/丢WAR包即生效,方便但可能有部署延迟 |
| true | false | 启动时解压WAR包,之后不监听目录变化,适合生产环境固定版本 |
| false | true | 以WAR包直接运行,不产生解压目录,但每次启动都会重新读取WAR包,启动较慢 |
| false | false | 纯手动部署,适合对版本控制极其严格的生产环境 |
生产环境我个人推荐unpackWARs="true" autoDeploy="false"。原因很简单:打包成WAR包就是为了避免散文件部署的不可控,启动时解压一次,之后禁止自动热部署,你想发新版本就走完整发布流程,而不是偷偷丢一个WAR包上去让Tomcat自动解压。这样即使多个技术人员同时操作服务器,也不会出现“谁悄悄改了个class文件导致线上行为不一致”的情况。
4.3 Host name和域名解析的匹配规则
Host的name匹配不是随意写一个。Tomcat的匹配逻辑优先级从高到低大致是这样:精确匹配 > 通配符匹配(*.example.com)> 默认Host。举个例子,假设配了一个<Host name="*.example.com">,当请求头里的Host是api.example.com时,会匹配到这个通配Host;如果没有任何精确匹配和通配匹配规则能命中,就走defaultHost。
这里有一条很实际的注意事项:很多开发者在本地环境把Host name配成IP地址,比如127.0.0.1,但如果访问时用localhost,Tomcat也会有默认逻辑处理。 如果服务器上绑定了多个IP,或者有反代转发的情况,建议把Server.xml里Engine的defaultHost和第一个Host的name保持和实际域名一致,避免请求命中错误虚拟主机。
平时排查“为什么我访问IP能通、访问域名不通”的时候,不要只盯着防火墙和DNS,也检查一下Host name匹配。有一次我帮朋友排查一个Nginx代理Tomcat的问题,Nginx配置完全正确,但Tomcat就是返回404。最后发现是他server.xml里只有一个Host name=“localhost”,而Nginx转发过来的请求Host头是实际域名,匹配不到虚拟主机,直接走了404页面。遇到这种问题,优先看Tomcat的localhost_access_log日志里的请求Host字段。
4.4 host-manager和docs这两个默认应用要不要删
Tomcat默认会在webapps目录下放ROOT、docs、examples、host-manager、manager这几个应用。生产环境部署时,我建议把docs、examples、manager、host-manager全部删掉或禁掉,只保留ROOT。
理由不完全安全相关,更多是“减少不必要的扫描和潜在的配置冲突”。docs和examples是官方文档和示例,生产环境不会用到;manager和host-manager是管理界面,虽然方便,但如果你没有设置严格的用户角色和密码,很容易成为被试探的目标。即使要用manager,也必须配置Tomcat的用户认证并限制访问来源IP,而不是裸奔在外网。
5. Context部署:从路径映射到docBase,再到热部署的注意事项
5.1 为什么推荐显式配置Context而不是只丢WAR包
很多人觉得Context标签是个可有可无的东西。早期我也是这个想法,直到遇到一次线上事故:某人把一个新的WAR包丢到webapps目录,因为autoDeploy是true,Tomcat自动把WAR包解压成了一个同名目录,但项目的静态资源配置是基于根路径“/”写的,而新应用的Context path被默认设置成了WAR包文件名,导致所有静态资源请求全部404。
从那次以后,我的习惯是:只要应用对访问路径有要求,就显式配置一个Context,路径和docBase都写清楚,不依赖自动部署的默认规则。
xml复制<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="false">
<Context path="" docBase="myapp" reloadable="false" />
</Host>
这里的path=""表示应用访问路径是根路径,访问http://localhost:8080/就能直接命中。docBase可以是相对路径(相对于appBase目录),也可以是绝对路径。如果你把应用放在Tomcat之外的目录,比如/data/projects/myapp,docBase必须写成绝对路径。这是很多新手最容易搞错的地方:以为docBase填了绝对路径就不用管appBase了,其实两者是独立作用的,Host决定了扫描范围,Context决定了某个path映射到哪个实际目录。
5.2 reloadable="true"为什么不适合生产环境
reloadable属性表示当WEB-INF/classes或WEB-INF/lib下的文件发生变化时,Tomcat是否自动重新加载整个应用上下文。开发环境这套机制非常香,改完代码保存就能自动生效,不用手动重启。但生产环境坚决建议设为false。
原因是Tomcat的热加载不是无缝替换,而是重新创建一个新的类加载器去加载整个应用,这期间会经历一个新的应用初始化过程。如果应用里有大量的单例对象、数据库连接池、线程池,热加载会导致这些资源被反复创建和销毁,随之而来的就是Old Gen内存持续上涨,最终频繁Full GC。我见过一个线上项目就是因为这个原因,每发布一次代码,过一两天就会重现老年代被打满、接口全部超时的问题。
另外,热加载过程中如果代码里用了某些静态变量或者本地文件句柄,还会出现资源释放不干净的情况,越热加载越卡。所以生产环境要么重启服务,要么设reloadable="false"。
5.3 Context path的命名规范和常见冲突
Context path不能以斜杠结尾,不能包含特殊字符。配置path="/"表示根路径,配置path="/api"表示访问路径为http://domain/api。要特别注意的是,一个Host下不能有两个相同path的Context,否则启动时后注册的那个会被拒绝,并报警告日志。
还有一个在运维中经常出现的场景:Host的appBase指向webapps目录,里面有一个应用目录叫myapp,同时你又手动声明了一个<Context path="/myapp" docBase="/data/myapp">。这种配置Tomcat不会启动失败,但可能会因为docBase指向的目录与appBase目录下的同名应用产生部署歧义,导致最终行为不可预期。如果要用绝对路径的docBase,建议把Host的appBase单独指向一个空目录,或者直接关闭autoDeploy。
5.4 部署多个应用的几种思路
如果一台Tomcat要承载多个应用,常见有三种方案。
第一种:一个Host下多个Context path,每个path对应一个应用。
xml复制<Context path="/app1" docBase="/data/apps/app1" reloadable="false" />
<Context path="/app2" docBase="/data/apps/app2" reloadable="false" />
这种方式简单直接,但所有应用共享同一个线程池和JVM内存,应用之间如果一个出问题把内存耗尽,其他人也会跟着遭殃。
第二种:一个Server下多个Service,每个Service绑定不同端口。
xml复制<Service name="Catalina">
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" />
<Engine name="Catalina" defaultHost="localhost">
<Host name="localhost" appBase="webapps1" unpackWARs="true" autoDeploy="false" />
</Engine>
</Service>
<Service name="Catalina2">
<Connector port="8081" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8444" />
<Engine name="Catalina2" defaultHost="localhost">
<Host name="localhost" appBase="webapps2" unpackWARs="true" autoDeploy="false" />
</Engine>
</Service>
两个Service可以共用同一个Server里的全局JNDI资源,但线程池是各自独立的,相当于逻辑上的隔离。缺点就是端口各管各的,外部访问要区分端口。
第三种:一个Tomcat里只跑一个服务,多个应用用Nginx做路由转发。这是目前最流行的方式。Tomcat不做多应用部署,专注跑自己的业务逻辑,Nginx按路径转发到不同的Tomcat端口或不同的上游。这种方式的好处是资源隔离最清晰,也方便独立扩容。
实际项目怎么选?小项目、应用数量少、逻辑耦合度高,选第一种最省事;应用之间有明显的资源隔离需求,选第二种;团队规模大、要按服务独立扩缩容,选第三种。
6. Valve和Listener:你可能不知道的server.xml隐藏能力
6.1 AccessLogValve:访问日志的正确打开方式
Valve是Tomcat里的一个拦截器机制,作用有点类似于Servlet规范里的Filter,但是它在容器层面工作,能拦截到所有进入Host或Context的请求。最常见的Valve就是AccessLogValve,用来记录访问日志。
xml复制<Valve className="org.apache.catalina.valves.AccessLogValve"
directory="logs"
prefix="localhost_access_log"
suffix=".txt"
pattern="%h %l %u %t "%r" %s %b %D" />
这里pattern里的%D是Tomcat特有的一项,记录请求耗时(毫秒)。做性能分析的时候非常有用。默认配置里通常没有%D,我建议加上。另一个常用的字段是%I,记录当前请求线程名,排查“哪个线程卡住了”的时候很有帮助。
需要注意,AccessLogValve要放在Host节点下,这样这个Host下的所有应用都会记录日志。如果想只记录某一个应用,也可以放进Context节点里。日志文件默认在Tomcat的logs目录下,文件名的prefix和suffix决定日志的命名规则,比如localhost_access_log.2025-01-01.txt。
6.2 RemoteAddrValve:用最简单的方式做IP白名单
RemoteAddrValve可以按IP地址或网段限制访问来源,适合用在一些管理后台、内部接口上。
xml复制<Valve className="org.apache.catalina.valves.RemoteAddrValve"
allow="127.0.0.1|10\.0\.0\..*|192\.168\.1\.\d+" />
allow和deny都支持正则表达式。匹配规则是:先看allow,如果客户端IP匹配中任何一个allow规则,则允许访问;再看deny,匹配中任何一个deny规则,则拒绝访问。如果只配置allow不配置deny,那么只有allow规则匹配的IP能访问,其余全部拒绝。
这类方案适合前置没有Nginx、直接暴露Tomcat端口的内网场景。如果前面有Nginx,建议在Nginx里做访问控制,比在Tomcat层做更高效,也更灵活。
6.3 自定义Valve和Pipeline的扩展思路
如果内置的Valve满足不了需求,你还可以写一个自定义Valve。继承org.apache.catalina.valves.ValveBase,在invoke方法里写业务逻辑,然后把编译好的class放到Tomcat的lib目录下,并在server.xml里声明:
xml复制<Valve className="com.example.MyCustomValve" someAttribute="value" />
ValveBase里invoke方法的调用链会形成一条管道,你可以在这条管道里做限流、请求染色、灰度标识注入等事。不过要提醒一句:自定义Valve的代码需要严格测试,因为它深入到Tomcat容器的调用链中,一旦抛异常,可能会导致整个Host或Engine下的请求都失败。
7. 从配置到运行:启动失败、乱码、CPU高的排查链路
7.1 启动日志看什么:从catalina.out到localhost.log
只要server.xml被修改过,就一定要养成“先看日志再谈其他”的习惯。Tomcat的日志体系里,最重要的几个文件:
| 日志文件 | 内容 |
|---|---|
| catalina.out / catalina.日期.log | Tomcat启动过程、容器生命周期日志,server.xml解析报错会出现在这里 |
| localhost.日期.log | 应用上下文初始化日志,Context部署失败往往在这里 |
| localhost_access_log.日期.txt | HTTP访问日志,请求量、耗时、状态码在这里 |
| manager.日期.log | Manager应用的操作日志,手动部署/取消部署会记录在这里 |
server.xml写错时,经常在catalina.out里看到类似Parse error in server.xml、Error creating bean之类的异常。如果你改完配置启动失败,第一反应不应该是“是不是Java环境坏了”,而是先打开catalina.out,搜索“SEVERE”或“ERROR”级别日志,基本上都能找到直接原因。
7.2 启动报错的常见套路和对应解法
端口占用:报BindException: Address already in use。先netstat -tlnp | grep 端口号看谁占了,或者直接换端口。如果换完端口启动还是被占用,检查是不是有多个Tomcat实例用了同一个server.xml,或者环境变量里的CATALINA_BASE指向不对。
server.xml格式错误:XML标签没闭合、属性值多写了引号、注释嵌套问题,都会导致解析失败。有一个细节:server.xml默认的DOCTYPE声明是<!DOCTYPE Server [ ... ]>,如果手滑把这段声明删了或者写错,Tomcat会把它当普通XML解析,但某些特殊字符可能触发布局错误。建议编辑server.xml之后,先手动用XML工具做一次格式校验,别直接重启生产环境。
defaultHost指定错误:报Invalid defaultHost,说明你写的defaultHost在Engine里找不到对应的Host节点。检查大小写和空格。
Context docBase指定错误:报The specified DocumentBase does not exist or is not a readable directory。大概率docBase路径写错了,或者该目录没有读取权限。Linux下注意目录权限,Tomcat进程USER要有读和执行权限。
7.3 startup.bat打印乱码和CORS配置这两个高频问题
这篇文章发了之后,肯定有小学生在Windows本地上开发。Tomcat在Windows下双击startup.bat,控制台出现中文乱码,基本原因就一个:Tomcat控制台的默认字符集和Windows的编码不一致。
解决方法不复杂。找到conf/logging.properties,把java.util.logging.ConsoleHandler.encoding从默认值(老版本可能是UTF-8)改成GBK。改完重启Tomcat,控制台中文就正常了。如果改完还是乱码,检查一下系统区域设置是否把非Unicode程序语言的编码改了。
另一个高频问题是CORS。老样子,网上一搜“Tomcat CORS filter”,配置五花八门,其实官方自带了一个CORS Filter,只需要在web.xml里配置Filter和FilterMapping,不用额外引jar包。如果你在server.xml里看到有人配置了CORS相关的Valve,那多半是第三方实现。这里不展开太多,只有一个提醒:CORS是浏览器端的机制,不代表接口真的安全。 如果接口本身需要鉴权,CORS配置不能替代鉴权逻辑。
7.4 Tomcat CPU 100%的定位流程
遇到Tomcat占用CPU过高,很多人第一反应是“调小线程池”。但线程池只是表面现象,真正的瓶颈往往在于应用代码。我的排查步骤固定是这样的:
第一步,top -Hp Tomcat进程ID,看哪个线程占CPU最高,记下线程ID。
第二步,把线程ID转成十六进制:printf "%x\n" 线程ID。
第三步,jstack Tomcat进程ID > threaddump.txt,在文件里搜索十六进制线程ID,找到对应的线程堆栈。
第四步,看堆栈里执行的是什么方法。如果是HTTP线程大量卡在数据库查询,那就是SQL或连接池问题;如果卡在某个业务方法里,那就是代码循环或死锁问题;如果卡在GC线程上,那就是内存分配和回收问题。
第五步,结合server.xml的线程池配置评估:如果你的线程数设得很大,比如maxThreads=1000,而每个线程都在等待某个下游接口超时,大线程池反而会放大问题。这种场景下,即使代码逻辑没问题,也要考虑给下游接口设置合理的超时时间,并把线程池压到一个合适范围。
我有一次遇到CPU高的问题,现场查完堆栈,发现是一个定时任务在读取server.xml里配置的某个Context路径下的目录文件,而且扫的是整个磁盘的临时目录,文件数量几十万,每次扫描耗时好几分钟。那次经历给我的教训是:不要轻易让应用代码直接去读文件系统目录,更不要用Context path作为目录扫描的基准,目录结构应该由应用自己维护,而不是依赖容器的配置。
8. 迁移场景:把Tomcat应用从一台服务器搬到另一台服务器
8.1 迁移前必须备份的清单
应用从一个服务器迁移到另一个服务器,很多人只拷贝了webapps目录,结果启动直接崩。完整迁移清单应该是:
| 文件/目录 | 原因 |
|---|---|
| conf/server.xml | 所有端口、Host、Context、线程池配置 |
| conf/web.xml | 默认Servlet映射、MIME映射、跨域等公共配置 |
| conf/context.xml | 全局JNDI数据源配置 |
| conf/tomcat-users.xml | 管理端用户角色 |
| lib目录下自定义的jar包 | 如果Tomcat的lib里有非官方jar包,必须一并迁移 |
| webapps下的应用目录或WAR包 | Web应用本体 |
| logs目录(如果需要保留历史日志) | 排障、审计需求 |
这里有个细节:Tomcat版本尽量保持一致。 不同大版本之间server.xml的语法可能有兼容性差异。比如Tomcat 8.5和Tomcat 9,Server、Service、Connector整体结构一致,但某些Listener的类名和默认配置不同。你直接把旧版本server.xml丢到新版本里,有时候不会报错,只是在行为上会有些细微差别;但如果是Tomcat 7迁移到Tomcat 9,NIO和线程模型的默认值都变了,不能拿旧配置的“经验值”生搬硬套。
8.2 迁移后要重点验证的几件事
迁移完成后,不要急着让流量切过来。按下面这个顺序做验证:
- 启动正常,端口监听无误(server.xml里的port没有被占用)。
- 访问根路径能返回应用首页,静态资源能加载。
- 登录功能能正常走通,Session能正常创建(如果用了Redis Session共享,要确认Redis配置同步迁走)。
- 数据库、消息队列等外部依赖的地址是否已从旧环境切换到新环境。
- 用
curl -I http://localhost:端口/检查响应头,确认Content-Type、字符集正确,没有乱码。 - 观察catalina.out日志连续10分钟,确认没有周期性报错。
很多迁移事故都出在外部依赖地址上。应用本身打包好了,代码里却还写着旧的数据库IP或者旧的Redis地址,这一类的排查往往要花不少时间。如果你用的是Nacos、Consul这类注册中心,还要检查注册中心的地址和命名空间是否同步。
8.3 配合systemd启动时的一个特殊坑
现在很多Linux服务器上,运维会习惯用systemd来管理Tomcat进程,比如systemctl start tomcat。如果你在setenv.sh里配置了JVM内存参数,比如JAVA_OPTS="-Xms512m -Xmx1024m",然后通过systemd启动失败,先别怀疑内存参数写错了,先检查systemd服务文件里有没有指定EnvironmentFile。
systemd启动Tomcat时,如果服务文件没有加载setenv.sh,Tomcat启动脚本里定义的CATALINA_OPTS/JAVA_OPTS可能根本没有被读取。另外,用systemd管理时,工作目录、PATH、JAVA_HOME这些环境变量都需要在service文件里显式声明。我见过这样的情况:手动执行catalina.sh run正常,一用systemctl start就失败,最后发现是service文件里User指定的是root,而Tomcat进程根本没有权限读setenv.sh所在的目录。
如果你遇到“通过systemctl启动失败但直接启动没问题”的现象,先查看systemctl status tomcat的输出,再看journalctl -u tomcat的标准输出和错误输出,基本能定位到是权限、环境变量还是等待超时的问题。还有一种情况是systemd的TimeoutStartSec默认值是90秒,如果JVM启动加上应用初始化超过90秒,systemd会判定启动失败,这时候把TimeoutStartSec调大即可。
9. 生产环境核心配置参考:一版经得起考验的template
最后给一份我目前在新项目里使用的server.xml模板,在这份配置里我把生产环境最常用、最稳妥的参数都融合进去了。每一段配置后面,我会用表格简单说明关键参数的作用,方便你按需调整。
xml复制<?xml version="1.0" encoding="UTF-8"?>
<Server port="8005" shutdown="SHUTDOWN">
<Listener className="org.apache.catalina.startup.VersionLoggerListener" />
<Listener className="org.apache.catalina.core.AprLifecycleListener" SSLEngine="on" />
<Listener className="org.apache.catalina.core.JreMemoryLeakPreventionListener" />
<Listener className="org.apache.catalina.mbeans.GlobalResourcesLifecycleListener" />
<Listener className="org.apache.catalina.core.ThreadLocalLeakPreventionListener" />
<Service name="Catalina">
<Executor name="tomcatThreadPool" namePrefix="catalina-exec-"
maxThreads="300" minSpareThreads="50"
maxIdleTime="60000" prestartminSpareThreads="true" />
<Connector port="8080" protocol="HTTP/1.1"
executor="tomcatThreadPool"
connectionTimeout="20000"
acceptCount="100"
maxConnections="10000"
redirectPort="8443"
URIEncoding="UTF-8"
compression="on"
compressionMinSize="2048"
compressibleMimeType="text/html,text/xml,text/plain,text/css,application/javascript,application/json" />
<Engine name="Catalina" defaultHost="localhost">
<Host name="localhost" appBase="webapps" unpackWARs="true" autoDeploy="false">
<Valve className="org.apache.catalina.valves.AccessLogValve"
directory="logs"
prefix="localhost_access_log"
suffix=".txt"
pattern="%h %l %u %t "%r" %s %b %D" />
<Context path="" docBase="myapp" reloadable="false" />
</Host>
</Engine>
</Service>
</Server>
| 参数 | 配置值 | 说明 |
|---|---|---|
| Server port / shutdown | 8005 / SHUTDOWN | 关闭Tomcat的指令端口,生产环境建议改掉默认值 |
| Executor maxThreads | 300 | 根据服务接口耗时调整,不是越大越好 |
| Executor minSpareThreads | 50 | 保持至少50个空闲线程应对突发流量 |
| Connector acceptCount | 100 | 线程池满后等待队列的长度,需要和maxThreads联动 |
| Connector maxConnections | 10000 | TCP连接最大数,超过后请求会在操作系统层排队 |
| connectionTimeout | 20000 | 等待客户端发送完整请求的最长时间 |
| URIEncoding | UTF-8 | 解决GET请求中文参数乱码 |
| compression | on | 开启Gzip,减少带宽消耗 |
| AccessLogValve pattern | %h %l %u %t "%r" %s %b %D | 增加% D记录耗时 |
这份模板不是“万能药”,但它覆盖了生产环境最常用的几个维度:连接数、超时、编码、压缩、访问日志。如果你的项目对静态资源访问量很大,可以把maxThreads适当调高;如果接口多是高耗时的复杂查询,反而应该降低maxThreads并增加超时阈值,避免线程都堵在一个查询上。
10. 最后分享几个让我少走弯路的经验
这篇文章写到这里,最后我想聊几条自己长期积累下来的实操体会,不一定都写在官方文档里,但都是我踩过坑之后的总结。
第一,改server.xml之前,永远先备份一份,并且带注释地备份。 我通常会在conf目录下保留一份server.xml.bak,里面写上备份日期和改了什么。出了问题时,快速回滚的第一选择永远是自己留的那份备份,而不是从网上重新下一份默认配置。
第二,不要在同一个文件里同时尝试多个大改动。 有人一次把端口、线程池、压缩、Host、Context全部改了,启动失败后根本不知道是哪一项导致的。正确的做法是每次只改一个维度的参数,重启一次验证一次,确认无误后再进行下一项。别嫌麻烦,这个流程能帮你省下大量排障时间。
第三,尽量少用通配符形式的Host匹配和模糊路径。 虽然Tomcat支持*.example.com这种配置,但如果你想分析访问日志做数据统计,通配符会让日志归类和域名归属变得非常混乱。一个域名一个Host,一个Host下面再拆Context,这是最清晰的结构。
第四,把server.xml里的注释当成文档来写。 XML本身支持<!-- -->注释,我会在每一个改动过的属性后面都写上为什么这样改、依据是什么、回滚到哪个值。以后换人维护,或者三个月后的你本人回来再看,都会感谢这份注释。
第五,记得关注Tomcat版本升级之后server.xml的兼容性变化。 我以前用Tomcat 8.5,后来升级到Tomcat 9,发现某些默认配置已经变了。比如Connector的默认maxPostSize、默认的acceptCount,版本之间并不完全一致。你不一定要追新版本,但升级前一定要对照官方文档看迁移说明,而不是把老配置文件原封不动搬过来。
说回到开头那个线上故障。那次之后,我把这个项目的server.xml按照本文的结构完整地梳理了一遍,标注清楚每一个属性的作用,然后把相关的部署脚本、启动参数、外部依赖地址全部整理成一个交接文档。半年后另一个同事接手这个项目时,只花了一个下午就完全理解了整套部署方案。我觉得这就是配置文件应有的价值——不只是让Tomcat能跑起来,更是让后面接手的人能快速看懂这套系统是怎么设计的。
