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 reloadablefalse/注意在生产环境中务必设置reloadablefalse。如果设为trueTomcat会监视/WEB-INF/classes和/WEB-INF/lib下的文件变动并自动重载应用。这个特性在开发时很方便但在生产环境会严重消耗性能并可能导致内存泄漏因为旧的类加载器可能无法被完全回收。2.2 线程池配置别让连接器成为瓶颈Tomcat处理请求的核心是连接器Connector而连接器的性能瓶颈往往在于线程池。默认的配置对于生产环境来说通常过于保守。!-- 默认的HTTP/1.1连接器配置片段 -- Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 /这个配置使用的是Tomcat的默认线程池关键参数如maxThreads最大工作线程数依赖内部逻辑可能无法应对突发流量。一个常见的错误是应用性能很好数据库也没压力但Tomcat就是无法处理更多并发请求表象就是连接超时或拒绝连接。必须显式配置并调优线程池参数。使用Executor元素定义线程池并在Connector中引用它是更专业的方式。!-- 在server.xml中定义共享线程池 -- Executor nametomcatThreadPool namePrefixcatalina-exec- maxThreads200 minSpareThreads20 maxQueueSize100 prestartminSpareThreadstrue/ !-- 连接器使用该线程池 -- Connector executortomcatThreadPool port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 acceptCount100 maxConnections10000/关键参数解析与避坑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设为trueTomcat启动时就会初始化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:MetaspaceSize256m -XX:MaxMetaspaceSize256m -XX:UseG1GC -XX:MaxGCPauseMillis200 -XX:InitiatingHeapOccupancyPercent45-Xms2g -Xmx2g堆大小固定根据机器内存和应用需求设定。通常建议不超过系统总内存的50%-70%。-XX:MetaspaceSize256m -XX:MaxMetaspaceSize256m限制元空间大小避免膨胀。MetaspaceSize是初始阈值达到后会触发Full GC进行扩容。-XX:UseG1GC使用G1垃圾回收器适合多核大内存服务器追求低延迟。-XX:MaxGCPauseMillis200设置GC暂停时间的目标值毫秒G1会尽力达成但非硬性保证。-XX:InitiatingHeapOccupancyPercent45设置触发并发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:NumberOfGCLogFiles10 -XX:GCLogFileSize20M-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 ThreadLocalMyService serviceHolder new ThreadLocal(); // 在某处set了之后忘记remove解决方案确保在try-finally块中或在Servlet过滤器的finally语句里调用ThreadLocal.remove()。更好的模式是避免用ThreadLocal存储业务对象。静态集合引用一个非常典型的错误是在某个类的静态Map或List中缓存了对象而这些对象的类是由WebappClassLoader加载的。// 错误示例一个“缓存管理器” public class CacheManager { private static final MapString, 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-timeout1440/session-timeout24小时甚至-1永不过期。这会导致服务器内存中堆积大量无效的会话对象尤其是对于用户访问不规律的应用最终引发OutOfMemoryError。建议根据业务场景设置合理的超时时间。对于安全性要求高的应用如银行可以设置短一些如15分钟。对于用户体验要求高的可以设置长一些如几小时但必须配合会话持久化机制如保存到Redis并定期清理过期数据。错误二将整个大对象塞入Session。例如把用户查询的一个包含上万条记录的结果集ListEntity直接存入Session。每个用户的Session都会占用巨大内存当在线用户数上千时内存消耗是灾难性的。// 错误示例 ListProduct hugeProductList productService.searchAllProducts(); request.getSession().setAttribute(allProducts, hugeProductList);建议Session中只应存放最小必要的状态信息如用户ID、角色、登录令牌等。像查询结果、报表数据等应该存储在数据库、缓存Redis中或者通过分页、懒加载等方式减少单次数据量。对于上面的例子应该只存储查询条件或者存储一个指向缓存结果的Key。4.2 集群环境下的会话共享陷阱在Tomcat集群中默认情况下会话是存储在单个节点内存中的。如果用户第一次请求落到节点A他的Session就存在A上。下次请求通过负载均衡落到节点BB上找不到这个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 groupIdorg.springframework.session/groupId artifactIdspring-session-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-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 nameASYNC_FILE classch.qos.logback.classic.AsyncAppender queueSize512/queueSize !-- 队列容量根据情况调整 -- discardingThreshold0/discardingThreshold !-- 队列剩余容量低于此值时丢弃级别低于INFO的日志 -- appender-ref refFILE/ /appender没有日志轮转和清理策略日志文件无限增长最终撑满磁盘。建议配置基于大小和时间的滚动策略并设置最大历史文件保留数或总大小上限。appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender file${LOG_PATH}/app.log/file rollingPolicy classch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy fileNamePattern${LOG_PATH}/app.%d{yyyy-MM-dd}.%i.log/fileNamePattern maxFileSize100MB/maxFileSize !-- 单个文件最大 -- maxHistory30/maxHistory !-- 保留30天 -- totalSizeCap10GB/totalSizeCap !-- 所有日志文件总大小上限 -- /rollingPolicy encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n/pattern /encoder /appender5.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 classNameorg.apache.catalina.valves.AccessLogValve directorylogs prefixlocalhost_access_log suffix.txt pattern%h %l %u %t quot;%rquot; %s %b %D /其中%D表示处理请求的时间微秒。通过分析这个日志可以快速找出哪些URL的响应时间异常。我曾经处理过一个案例应用偶尔会响应极慢但应用日志和监控指标都没有明显异常。后来查看Tomcat访问日志发现个别请求的%D值高达几十秒。再结合请求参数和时间点最终定位到是某个特定的用户操作触发了一个隐藏的、没有索引的全表扫描数据库查询。如果没有访问日志中的响应时间记录这个问题很难被主动发现。构建一个稳定、高性能的Tomcat Web应用远不止是写好业务代码那么简单。它要求我们从部署配置、编码规范、资源管理、状态处理到监控观测建立起一套完整的“纪律”。上面提到的这些错误每一个我都曾亲眼见过它们引发的线上问题。避免它们并没有高深的技术需要的只是对细节的关注、对“最佳实践”的尊重以及一套可遵循的规范流程。希望这些从实战中总结出的经验能帮助你绕开这些坑让你的应用在Tomcat这个经典的容器里跑得更稳、更远。