1. 从“能跑就行”到“稳定可靠”的认知转变
在Java Web开发领域,Tomcat作为一款久经考验的Servlet容器,其部署和配置的简易性,既是它的优点,也恰恰是许多问题的根源。很多开发者,尤其是刚入行的朋友,常常抱有一种“能跑就行”的心态:把WAR包往webapps目录里一扔,启动脚本一执行,看到控制台没报错,页面能访问,就觉得大功告成了。这种心态下构建的应用,在开发环境或低负载测试时或许相安无事,但一旦上了生产环境,面对真实的用户流量和复杂的运行场景,各种稀奇古怪的问题就会接踵而至——内存泄漏、线程池耗尽、响应缓慢、甚至毫无征兆地宕机。
我经历过太多因为早期配置不当或编码习惯不良而导致的线上事故复盘。事后看,很多问题其实都有明确的“最佳实践”可以规避,但往往因为项目初期追求快速上线而被忽略。这篇文章,我想结合自己踩过的坑和解决过的实际问题,系统性地梳理一下在Tomcat环境下构建Web应用时,那些看似不起眼、却影响深远的通用错误。这些错误不局限于某个具体的框架或业务逻辑,而是涉及部署、配置、编码、监控等多个层面。我们的目标,是把应用从“能跑”的状态,提升到“跑得稳、跑得快、出了问题能快速定位”的工业级可靠状态。
2. 部署与配置:埋下隐患的第一现场
很多问题的种子,在应用部署和Tomcat服务器配置阶段就已经种下。错误的配置不仅影响性能,更会直接导致服务不可用。
2.1 上下文路径与文档根目录的混乱管理
最常见的错误之一,是对应用上下文路径(Context Path)和静态资源目录的随意处理。很多开发者喜欢直接把项目解压到webapps/ROOT目录,让应用跑在根路径(/)下。这样做虽然访问方便,但会带来一系列管理上的麻烦。
首先,ROOT应用是特殊的,它的docBase(文档根目录)默认就是webapps/ROOT。如果你有多个应用,或者未来需要做A/B测试、蓝绿部署,这种把所有文件混在一起的做法会让回滚和清理变得极其困难。一旦需要替换ROOT应用,你必须非常小心地处理旧文件,否则残留的.class或配置文件可能会引发类加载冲突。
正确的做法是:为每个应用建立独立的目录。例如,将你的应用打包成myapp.war,直接放入webapps目录,Tomcat会自动将其解压到webapps/myapp,并通过/myapp上下文路径访问。如果你确实需要根路径访问,应该在$CATALINA_BASE/conf/Catalina/localhost/目录下创建一个名为ROOT.xml的上下文配置文件,通过docBase属性明确指向你的应用目录,例如/opt/apps/my-production-app。这样,应用内容和Tomcat自带的webapps目录完全分离,管理清晰,部署和卸载互不影响。
<!-- $CATALINA_BASE/conf/Catalina/localhost/ROOT.xml --> <Context docBase="/opt/apps/my-production-app" reloadable="false"/>注意:在生产环境中,务必设置
reloadable="false"。如果设为true,Tomcat会监视/WEB-INF/classes和/WEB-INF/lib下的文件变动并自动重载应用。这个特性在开发时很方便,但在生产环境会严重消耗性能,并可能导致内存泄漏,因为旧的类加载器可能无法被完全回收。
2.2 线程池配置:别让连接器成为瓶颈
Tomcat处理请求的核心是连接器(Connector),而连接器的性能瓶颈往往在于线程池。默认的配置对于生产环境来说通常过于保守。
<!-- 默认的HTTP/1.1连接器配置片段 --> <Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" />这个配置使用的是Tomcat的默认线程池,关键参数如maxThreads(最大工作线程数)依赖内部逻辑,可能无法应对突发流量。一个常见的错误是,应用性能很好,数据库也没压力,但Tomcat就是无法处理更多并发请求,表象就是连接超时或拒绝连接。
必须显式配置并调优线程池参数。使用Executor元素定义线程池,并在Connector中引用它,是更专业的方式。
<!-- 在server.xml中定义共享线程池 --> <Executor name="tomcatThreadPool" namePrefix="catalina-exec-" maxThreads="200" minSpareThreads="20" maxQueueSize="100" prestartminSpareThreads="true"/> <!-- 连接器使用该线程池 --> <Connector executor="tomcatThreadPool" port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" acceptCount="100" maxConnections="10000"/>关键参数解析与避坑:
maxThreads(最大线程数):这是最重要的参数。设置过低,并发请求排队,响应延迟高;设置过高,线程上下文切换开销巨大,反而降低吞吐量。一个基础的估算公式是:maxThreads = (预期最大QPS * 平均响应时间(秒)) + 缓冲线程数。例如,目标QPS为100,平均响应时间50ms,则约需100 * 0.05 = 5个线程来处理,加上缓冲,初始可设为50-100,再根据监控调整。绝对不要拍脑袋设成1000甚至更高。acceptCount(等待队列长度):当所有工作线程都忙碌时,新的请求会进入这个队列。默认值是100。如果队列也满了,连接器会拒绝连接。这个值不宜过大,否则队列中等待的请求超时,会白消耗服务器资源。它应该与maxThreads和预期的流量模式配合调整。maxConnections(最大连接数):这个参数和maxThreads容易混淆。它表示在任何给定时间,服务器能够接受和处理的最大连接数(包括正在处理的、等待的)。对于BIO连接器(已废弃),它很重要;对于NIO/NIO2(默认),通常将其设置为一个比maxThreads大得多的值(比如10000),以应对大量保持活跃(Keep-Alive)但空闲的连接。prestartminSpareThreads:设为true,Tomcat启动时就会初始化minSpareThreads数量的线程。这可以避免第一批请求到来时因创建线程导致的延迟,对于要求快速响应的应用很有用。
一个真实的坑:我们曾有一个应用,maxThreads设为200,但某次营销活动时,流量突增,所有线程迅速被慢查询(平均响应2秒)占用。acceptCount是默认的100,队列很快也满了。结果就是,新的用户请求立刻收到“连接被拒绝”的错误,而Tomcat的访问日志里却看不到这些请求,因为它们在进入应用逻辑前就被拒绝了。排查时费了好大劲,最后是监控系统显示TCP连接错误数飙升才定位到问题。解决方案除了优化慢查询,就是适当提高maxThreads(根据资源)和acceptCount,并更重要的是,在连接器层面或前端负载均衡器(如Nginx)设置更短的连接超时和快速失败机制,避免一个慢请求拖死整个线程池。
2.3 JVM内存与垃圾回收的盲目设置
在catalina.sh或catalina.bat中设置JVM参数是部署的必经步骤,但错误也很常见。
错误示例:
JAVA_OPTS="-Xms1024m -Xmx1024m -XX:+UseG1GC"这个配置有两个问题:1) 初始堆(Xms)和最大堆(Xmx)设置一样,这本身是推荐的做法,可以避免运行时堆伸缩带来的性能开销。但问题在于,它没有设置元空间(Metaspace)的大小。在Java 8+中,永久代(PermGen)已被元空间取代。如果不设置-XX:MaxMetaspaceSize,元空间可能会无限增长,直到耗尽本地内存,引发OutOfMemoryError: Metaspace。2) 使用了G1垃圾回收器,但没有给出任何调优参数,G1在默认参数下可能表现不佳。
改进后的配置:
JAVA_OPTS="-Xms2g -Xmx2g -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:InitiatingHeapOccupancyPercent=45"-Xms2g -Xmx2g:堆大小固定,根据机器内存和应用需求设定。通常建议不超过系统总内存的50%-70%。-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m:限制元空间大小,避免膨胀。MetaspaceSize是初始阈值,达到后会触发Full GC进行扩容。-XX:+UseG1GC:使用G1垃圾回收器,适合多核大内存服务器,追求低延迟。-XX:MaxGCPauseMillis=200:设置GC暂停时间的目标值(毫秒),G1会尽力达成,但非硬性保证。-XX:InitiatingHeapOccupancyPercent=45:设置触发并发GC周期的堆占用率阈值。默认45,降低此值可以让G1更早开始回收,可能减少Full GC,但会增加GC频率。
更大的坑在于没有GC日志。生产环境没有启用GC日志,就像开车没有仪表盘。当应用出现周期性卡顿或内存缓慢增长时,你根本无法诊断。
必须添加的GC日志参数:
JAVA_OPTS="$JAVA_OPTS -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintGCCause -XX:+PrintTenuringDistribution -Xloggc:/opt/tomcat/logs/gc-%t.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=20M"-Xloggc:指定GC日志文件路径。%t会被时间戳替换,便于归档。-XX:+UseGCLogFileRotation等:启用日志轮转,防止单个日志文件过大。- 有了详细的GC日志,你就可以使用像GCViewer、gceasy这样的工具来分析GC频率、暂停时间、内存回收效果,从而科学地调整堆大小和GC参数。
3. 应用编码:在容器内写作的纪律
Tomcat是一个容器,它为你的应用提供了运行环境,但你的代码如何与这个环境交互,决定了应用的健壮性。
3.1 类加载器泄漏:隐形内存杀手
这是Tomcat环境下最经典、也最难排查的问题之一。症状通常是:应用重新部署几次后,PermGen或Metaspace内存持续增长,最终导致OutOfMemoryError,即使你的代码看起来没有创建任何静态大对象。
根本原因:Tomcat为每个Web应用分配了一个独立的WebappClassLoader。当应用被卸载(redeploy)时,这个类加载器以及它加载的所有Class对象都应该被垃圾回收。但如果你的应用中有任何对WebappClassLoader或其加载的类的全局引用,那么该类加载器就无法被回收,它加载的所有类(可能几十MB)也就滞留在了内存中。
常见的泄漏点:
ThreadLocal滥用:如果在
ThreadLocal中存储了来自Web应用类加载器的对象(比如你自己写的某个Service实例),并且没有在使用后显式地remove()。而Tomcat使用的是线程池,工作线程是复用的,这个线程下次被用来处理另一个请求时,旧的ThreadLocal变量可能还在,导致类加载器被线程间接引用。// 错误示例 private static final ThreadLocal<MyService> serviceHolder = new ThreadLocal<>(); // 在某处set了之后,忘记remove解决方案:确保在
try-finally块中或在Servlet过滤器的finally语句里调用ThreadLocal.remove()。更好的模式是避免用ThreadLocal存储业务对象。静态集合引用:一个非常典型的错误是在某个类的静态
Map或List中缓存了对象,而这些对象的类是由WebappClassLoader加载的。// 错误示例:一个“缓存管理器” public class CacheManager { private static final Map<String, Object> GLOBAL_CACHE = new ConcurrentHashMap<>(); // 这个map会一直增长,并且其中的Object可能持有类加载器的引用 }解决方案:对于需要缓存的数据,考虑使用独立的缓存服务(如Redis、Memcached),或者使用作用域明确的缓存(如Spring的
@Cacheable,确保在应用上下文关闭时能被清理)。如果必须使用静态集合,要确保有明确的清理机制,并在ServletContextListener的contextDestroyed方法中手动清空。第三方库的陷阱:一些第三方库(如某些旧版本的日志框架、JDBC驱动、XML解析器)可能会在静态块中注册自己(例如,
java.sql.DriverManager注册JDBC驱动)。如果这些库的JAR包放在你的WEB-INF/lib下,它们会随着你的应用类加载器一起“泄漏”。解决方案:将这类有“全局注册”行为的JAR包(如数据库驱动、日志桥接包)移动到Tomcat的lib目录($CATALINA_HOME/lib)下,由Tomcat的公共类加载器加载。这样它们就是单例的,不受应用重载影响。但要注意版本冲突问题。
如何诊断类加载器泄漏?
- 在重启Tomcat后,使用
jmap -histo:live <pid>观察老年代(Old Gen)中你的应用类的实例数量。 - 执行一次重部署(redeploy)。
- 再次使用
jmap -histo:live <pid>。如果那些本该被卸载的类的实例数没有归零,甚至还在增加,就说明存在泄漏。 - 使用专门的内存分析工具,如Eclipse MAT,对堆转储文件进行分析,查看
WebappClassLoader的引用链,找到是哪个GC Root路径阻止了它被回收。
3.2 阻塞操作耗尽线程池
Tomcat的请求处理线程是宝贵的资源。如果在业务逻辑中执行了长时间的阻塞操作(如同步调用远程HTTP接口、进行复杂的文件IO、执行耗时计算),这个线程就会被完全占用,无法处理其他请求。当这样的操作过多时,就会迅速耗尽maxThreads配置的线程,导致新的请求排队或拒绝。
错误示例:在Servlet的doGet方法中直接调用一个需要5秒才能返回的第三方支付接口。
protected void doGet(HttpServletRequest req, HttpServletResponse resp) { // 这是一个同步阻塞调用! PaymentResult result = paymentClient.callExternalAPI(req); // ... 处理result }解决方案:异步化处理。
使用Servlet 3.0+的异步支持:将耗时的操作交给另一个线程池执行,释放Tomcat的工作线程。
@WebServlet(urlPatterns = "/async", asyncSupported = true) public class AsyncServlet extends HttpServlet { private ExecutorService executor = Executors.newFixedThreadPool(10); // 自定义业务线程池 protected void doGet(HttpServletRequest req, HttpServletResponse resp) { AsyncContext asyncCtx = req.startAsync(); executor.submit(() -> { try { // 执行耗时操作 PaymentResult result = paymentClient.callExternalAPI(req); // 将结果写回响应 asyncCtx.getResponse().getWriter().write(result.toJson()); } catch (Exception e) { // 处理异常 } finally { asyncCtx.complete(); // 必须完成异步上下文 } }); } }关键点:一定要调用
asyncCtx.complete(),并且要管理好自定义的业务线程池,避免它自身成为瓶颈。使用Spring MVC的
@Async或DeferredResult:如果你使用Spring框架,可以利用其更高级的异步抽象。根本性解决:对于真正的IO密集型操作(如大量微服务调用),考虑使用非阻塞的响应式编程模型(如WebFlux),但这需要对架构进行较大改造。
一个经验:在Tomcat的server.xml中,可以为连接器配置一个socket级别的超时connectionTimeout,和一个请求处理级别的超时keepAliveTimeout(对于HTTP/1.1 Keep-Alive连接)或processorCache等。但这些超时主要作用于网络连接层面,对于应用层业务逻辑的阻塞是无效的。业务超时必须在你自己的代码或框架(如Hystrix、Resilience4j)中实现。
3.3 资源未正确关闭:连接泄漏的噩梦
数据库连接、HTTP连接池、文件流等资源,如果在使用后没有正确关闭,就会造成泄漏。在Tomcat中,这个问题会被放大,因为应用是长时间运行的,微小的泄漏经过长时间累积,最终会耗尽资源池。
以数据库连接为例:
// 错误示例:经典的手动管理连接漏洞 public void updateUser(User user) { Connection conn = null; PreparedStatement stmt = null; try { conn = dataSource.getConnection(); // 从连接池获取 stmt = conn.prepareStatement("UPDATE users SET name=? WHERE id=?"); stmt.setString(1, user.getName()); stmt.setInt(2, user.getId()); stmt.executeUpdate(); // 如果这里发生异常,conn和stmt不会被关闭! } catch (SQLException e) { // 处理异常 } // 忘记在finally块中关闭资源! }正确做法:使用try-with-resources语法(Java 7+),确保资源自动关闭。
public void updateUser(User user) { String sql = "UPDATE users SET name=? WHERE id=?"; try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, user.getName()); stmt.setInt(2, user.getId()); stmt.executeUpdate(); } catch (SQLException e) { // 处理异常,连接会在try块结束时自动关闭 log.error("Update user failed", e); throw new RuntimeException(e); } }对于连接池的监控:务必启用并定期查看连接池(如HikariCP、Druid)的监控指标。关注:
- 活跃连接数(Active Connections):是否持续处于高位甚至接近最大连接数?
- 空闲连接数(Idle Connections):是否合理?
- 等待获取连接的线程数(Threads Awaiting Connection):如果这个数持续大于0,说明连接池不够用,或者有连接泄漏(获取后未归还)。
- 连接创建总数:如果这个数字在应用稳定运行后还在持续快速增长,几乎可以断定存在连接泄漏。
我曾经排查过一个线上问题,应用运行一周后,数据库连接池(最大100)被占满,新的请求全部卡在获取连接上。使用jstack导出线程栈,发现大量线程阻塞在dataSource.getConnection()上。再结合连接池监控,发现活跃连接数始终是100,但实际数据库的SHOW PROCESSLIST显示只有不到10个活跃会话。最终定位到,是一段古老的、在Filter中手动获取连接但没有在异常分支中关闭的代码导致的。修复后,连接数立刻恢复正常。
4. 会话管理:状态保持的双刃剑
HttpSession是Tomcat提供的一个关键特性,用于在无状态的HTTP协议上维持用户状态。但使用不当,它会成为性能和内存的“黑洞”。
4.1 会话超时与持久化的误区
错误一:永不超时的会话。web.xml中默认的会话超时时间是30分钟。有些开发者为了“省事”,将其设置为一个极大的值(如<session-timeout>1440</session-timeout>,24小时)甚至-1(永不过期)。这会导致服务器内存中堆积大量无效的会话对象,尤其是对于用户访问不规律的应用,最终引发OutOfMemoryError。
建议:根据业务场景设置合理的超时时间。对于安全性要求高的应用(如银行),可以设置短一些(如15分钟)。对于用户体验要求高的,可以设置长一些(如几小时),但必须配合会话持久化机制(如保存到Redis),并定期清理过期数据。
错误二:将整个大对象塞入Session。例如,把用户查询的一个包含上万条记录的结果集List<Entity>直接存入Session。每个用户的Session都会占用巨大内存,当在线用户数上千时,内存消耗是灾难性的。
// 错误示例 List<Product> hugeProductList = productService.searchAllProducts(); request.getSession().setAttribute("allProducts", hugeProductList);建议:Session中只应存放最小必要的状态信息,如用户ID、角色、登录令牌等。像查询结果、报表数据等,应该存储在数据库、缓存(Redis)中,或者通过分页、懒加载等方式减少单次数据量。对于上面的例子,应该只存储查询条件,或者存储一个指向缓存结果的Key。
4.2 集群环境下的会话共享陷阱
在Tomcat集群中,默认情况下会话是存储在单个节点内存中的。如果用户第一次请求落到节点A,他的Session就存在A上。下次请求通过负载均衡落到节点B,B上找不到这个Session,用户就会被踢出登录。
解决方案:必须启用会话复制(Session Replication)或使用外部会话存储。
- Tomcat会话复制:通过配置
<Cluster>和<Manager>,让Tomcat节点间自动同步Session数据。缺点:同步有网络开销,会降低写操作性能;并且Session数据依然占用JVM堆内存,在节点多、数据量大时复制压力大。 - 外部会话存储(推荐):将会话数据存储到外部集中式缓存,如Redis。所有Tomcat节点都从同一个Redis读写Session。Spring Session项目可以非常优雅地实现这一点,只需引入依赖和简单配置。这样做的好处是:会话与Tomcat节点解耦,节点可以无状态地水平扩展;可以利用Redis的高性能和持久化能力;内存压力从Tomcat转移到了Redis。
配置Spring Session与Redis的示例:
<!-- pom.xml 依赖 --> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>// 配置类 @Configuration @EnableRedisHttpSession // 启用Redis Http Session public class SessionConfig { // Redis连接配置通常在application.properties中 }这样配置后,HttpSession的创建、读取、销毁都会自动映射到Redis操作,对业务代码完全透明。
一个集群环境下的真实坑:我们曾经在Tomcat集群中使用内存复制的会话管理器,并开启了<Manager pathname="">将会话序列化到磁盘。初衷是为了在服务器重启时能恢复会话。但在一次全集群滚动重启时,先启动的节点从磁盘加载了旧的会话文件,而后启动的节点在复制同步时,由于序列化ID等问题,导致了诡异的会话数据错乱。最终我们放弃了Tomcat内置的复制方案,全面转向基于Redis的Spring Session,再也没有出现过类似问题。
5. 监控与日志:照亮黑盒的探照灯
“应用在线上跑得怎么样?”如果你无法回答这个问题,那么它就是在“裸奔”。缺乏有效的监控和清晰的日志,排查问题就像在黑暗中摸索。
5.1 缺乏应用性能监控(APM)
很多团队只监控服务器的CPU、内存、磁盘IO,但这对Java应用来说远远不够。你需要知道:
- 每个请求的响应时间(P50, P90, P99)。
- JVM内部情况:堆内存各区域使用情况、GC频率和耗时、活跃线程数、死锁检测。
- Tomcat指标:活跃会话数、请求处理线程池状态(活跃线程、队列大小)、错误请求计数。
- 业务关键指标:如数据库查询耗时、缓存命中率、外部接口调用成功率。
解决方案:
- 使用Micrometer + Prometheus + Grafana:这是目前最流行的组合。Micrometer作为应用层的指标门面,可以非常方便地将JVM、Tomcat(通过
TomcatMetrics)、Spring Boot、数据库连接池、缓存等各类指标暴露出来。Prometheus负责抓取和存储这些指标,Grafana则用于可视化展示和告警。// 在Spring Boot应用中,通常只需引入依赖,指标自动暴露 // spring-boot-starter-actuator 和 micrometer-registry-prometheus - 接入分布式链路追踪:对于微服务架构,必须使用SkyWalking、Zipkin或Jaeger来追踪一个请求跨多个服务的完整路径,快速定位性能瓶颈和故障点。
5.2 混乱低效的日志记录
日志是排查线上问题的第一手资料。糟糕的日志实践会让查问题变得异常痛苦。
常见错误:
日志级别滥用:在线上环境使用
DEBUG级别,或者将大量无关紧要的INFO日志(如“方法进入”、“参数是xxx”)打印出来。这会导致日志文件体积暴增,刷屏式输出掩盖了真正的错误信息,同时严重的IO操作也会影响应用性能。建议:生产环境使用INFO或WARN级别。DEBUG日志应包含对排查复杂逻辑问题有用的详细信息,并且通过配置可以动态开启(如通过Logback的JMXConfigurator或Spring Boot Actuator的loggers端点)。日志格式不统一:没有使用结构化日志(如JSON格式),使得用工具(如ELK)分析日志变得困难。或者日志中没有包含必要的上下文信息,如
traceId、userId、sessionId等。建议:使用Logstash Logback Encoder或Logback的PatternLayout定义包含时间、级别、线程、类名、上下文信息(MDC)和消息的固定格式。对于微服务,务必在日志中打印链路追踪的traceId。同步日志阻塞线程:默认情况下,Logback等日志框架是同步写文件的。在高并发场景下,如果磁盘IO慢,写日志操作会阻塞业务线程。建议:启用异步日志(Async Appender)。让业务线程将日志事件放入一个队列,然后由单独的线程负责写入磁盘。这能极大提升性能,但需要注意队列大小配置,避免内存溢出和日志丢失。
<!-- logback-spring.xml 示例 --> <appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender"> <queueSize>512</queueSize> <!-- 队列容量,根据情况调整 --> <discardingThreshold>0</discardingThreshold> <!-- 队列剩余容量低于此值时,丢弃级别低于INFO的日志 --> <appender-ref ref="FILE"/> </appender>没有日志轮转和清理策略:日志文件无限增长,最终撑满磁盘。建议:配置基于大小和时间的滚动策略,并设置最大历史文件保留数或总大小上限。
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_PATH}/app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"> <fileNamePattern>${LOG_PATH}/app.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <maxFileSize>100MB</maxFileSize> <!-- 单个文件最大 --> <maxHistory>30</maxHistory> <!-- 保留30天 --> <totalSizeCap>10GB</totalSizeCap> <!-- 所有日志文件总大小上限 --> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender>
5.3 忽视Tomcat自身日志
除了应用日志,Tomcat自身的日志文件(catalina.out,localhost.log,localhost_access_log.*.txt)也富含信息。
catalina.out:标准输出和标准错误。这里会打印JVM启动参数、部署信息、严重的运行时错误(如类加载失败)以及你通过System.out.println打印的内容(生产环境应杜绝此行为)。localhost.yyyy-MM-dd.log:应用相关的日志,特别是ServletContext初始化、销毁过程中抛出的异常,以及你在web.xml中配置的<context-param>等。localhost_access_log.*.txt:访问日志。格式可以在server.xml的Valve中配置。强烈建议在生产环境开启访问日志,并包含响应时间字段(%D或%T),这对于分析慢请求、统计接口吞吐量非常有帮助。
其中<Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs" prefix="localhost_access_log" suffix=".txt" pattern="%h %l %u %t "%r" %s %b %D" />%D表示处理请求的时间(微秒)。通过分析这个日志,可以快速找出哪些URL的响应时间异常。
我曾经处理过一个案例,应用偶尔会响应极慢,但应用日志和监控指标都没有明显异常。后来查看Tomcat访问日志,发现个别请求的%D值高达几十秒。再结合请求参数和时间点,最终定位到是某个特定的用户操作触发了一个隐藏的、没有索引的全表扫描数据库查询。如果没有访问日志中的响应时间记录,这个问题很难被主动发现。
构建一个稳定、高性能的Tomcat Web应用,远不止是写好业务代码那么简单。它要求我们从部署配置、编码规范、资源管理、状态处理到监控观测,建立起一套完整的“纪律”。上面提到的这些错误,每一个我都曾亲眼见过它们引发的线上问题。避免它们,并没有高深的技术,需要的只是对细节的关注、对“最佳实践”的尊重,以及一套可遵循的规范流程。希望这些从实战中总结出的经验,能帮助你绕开这些坑,让你的应用在Tomcat这个经典的容器里,跑得更稳、更远。