Java Web 常见故障:从超时、断连和线程耗尽反推容器状态
用户看到 504,网关说上游超时,应用日志没有异常,线程池监控却显示 active 已满。此时“重启 Tomcat”只能清空现场。Servlet 容器事故需要先回答请求卡在哪一种状态:尚未接受连接、已解析但等待执行、正在业务阻塞、已经异步却未完成、正在向慢客户端写出,还是请求结束后资源没有释放。不同状态可能都表现为超时,却留下完全不同的第一证据。
用状态轴代替异常名列表
异常只是某一观察者的结果。客户端 Connection reset 可能来自容器主动关闭、进程重启或代理;服务端 Broken pipe 只说明写出时对端已关闭,不说明业务事务是否成功。先把一次请求放入连接、映射、队列、执行、异步、提交、写出、清理八个阶段,再寻找阶段转换失败。
诊断记录必须包含 request id、connection/stream id、dispatcher type、mapping、线程名、开始时间、deadline、async 状态、response committed、已收/已写字节和最终清理结果。没有这些字段时,一条 access log 的耗时无法说明时间花在排队还是执行。
线程耗尽常常是下游变慢的二次症状
容器线程 dump 若大量停在连接池借用、socket read、数据库锁或同一 monitor,根因在依赖或锁;若 active 达上限且队列年龄增长,说明到达率与驻留时间超出容量。继续增加线程会扩大等待和内存,直到 native thread、堆或下游彻底耗尽。
examples/backend-development/servlet/servlet-failures/SaturationFailureDemo.java 用一个执行线程和一个队列槽稳定制造拒绝:
javac --release 17 -Xlint:all -Werror examples/backend-development/servlet/servlet-failures/SaturationFailureDemo.java examples/backend-development/servlet/servlet-failures/FailureClassifierDemo.java
java -cp examples/backend-development/servlet/servlet-failures SaturationFailureDemoactive=1 queued=1 rejected=true生产入口必须决定拒绝语义:未进入业务时快速返回 503/429 和有限重试提示;已读取大请求或响应已提交时可能只能关闭。容器队列、业务 executor 与下游连接池都要有独立指标,避免最外层队列看似空闲、内部队列已积压。
连续三份间隔线程 dump 比单份更有价值:同一批线程持续停在相同栈帧表示长驻留;线程快速变化但队列不降表示吞吐不足;CPU 高且 RUNNABLE 集中在解析/序列化表示计算瓶颈。结合 executor completed count 与请求完成率,才能区分死锁、阻塞和纯过载。
异步泄漏表现为线程空闲但请求不结束
AsyncContext 漏 complete() 时,容器线程可能已经归还,因此只看线程池会误判健康。连接、请求状态、超时任务、业务回调和 trace span仍被保留。信号是 async active 与最大年龄增长、worker 较空闲、客户端长时间无响应。
timeout 与业务完成并发时,若没有单一终止状态,会出现双写、重复清理和负数计数。应统计 complete、timeout、error、client abort 和 suppressed duplicate terminal。停机演练中,所有 async age 必须在宽限期后归零或被明确终止。
FailureClassifierDemo.java 用组合证据而非异常名进行分类:
java -cp examples/backend-development/servlet/servlet-failures FailureClassifierDemoclassified=[EXECUTOR_SATURATION, UPLOAD_CLEANUP_LEAK, ASYNC_NOT_COMPLETED]规则不是生产诊断器,但表达了方法:threads=busy + queue=growing 才指向执行器饱和;tempFiles=growing + requests=stable 指向上传清理;asyncAge=growing + workers=idle 指向异步未完成。单一 CPU、线程数或磁盘使用率不能直接归因。
客户端断开后业务可能继续
代理 deadline 短于应用 deadline 时,代理先向客户端返回 504,应用仍继续查询数据库并尝试写响应,随后得到 broken pipe。若调用量上升,大量“无人需要的工作”占满线程和连接池。入口 deadline 应向下传播,在队列、下游调用和循环中检查取消。
断开不等于事务回滚。请求可能已提交订单,只是响应写出失败;自动重试若没有幂等会重复执行。日志要把业务 operation id 与传输 request id 分开,响应失败后通过状态查询确认最终结果。不要把 broken pipe 直接计为业务失败,也不要忽略它造成的浪费。
慢客户端写会让响应缓冲和线程/回调长期占用。大下载与 SSE 需要待写字节、写就绪等待、连接年龄和断开原因;有界缓冲达到上限时按协议暂停上游、丢弃可合并事件或断开,而不是继续在堆中累积。
解析与上传故障发生在业务之前
请求行、Header、参数、multipart Header、单 part 或总请求超限,容器可能在映射 Servlet 前拒绝。因此应用 access log 没有记录不代表流量没到机器。Connector 解析错误计数、入口代理日志和网络字节是第一证据。错误响应应区分 400、413、414、431 等语义,同时不回显恶意内容。
上传中断若留下临时文件,磁盘会在 QPS 不高时缓慢爬升。检查 temp file count/bytes/age、打开文件句柄和请求终止原因;清扫器要与活跃租约、上传状态表核对,避免删除仍在写的文件。验收是在反复中断后临时空间回到基线,而不是某一次 finally 打印了 delete success。
编码故障则检查 Connector URI 配置、Content-Type charset、第一次参数访问和代理规范化。日志若只保存已经乱码的 String,就失去原始证据;可以保存不含敏感内容的字节长度、解码错误位置与摘要。
重部署泄漏是一种延迟故障
应用表面部署成功,旧 WebappClassLoader 因后台线程、ThreadLocal、JDBC driver、日志回调或静态缓存仍被引用。多次重部署后 metaspace、线程和连接累积,最终 OOM。证据包括类加载器实例数、各自加载类数量、GC 后存活趋势、线程 context class loader 与静态引用路径。
不要把一次 full GC 后下降当成修复。进行多轮部署/卸载,在相同负载与观察窗口下确认旧类加载器数量回到稳定基线;对不能自动停止的库建立显式 close adapter,并在 contextDestroyed 中按依赖逆序关闭。进程模式部署仍要优雅停机,但整进程退出能缩小类加载器泄漏持续时间,这也是外置共享容器与每服务进程的重要取舍。
建立一次事故的证据时间线
从网关拿到到达、上游选择、deadline 和最终状态;从容器拿到接受、排队、dispatch、async、commit、完成时间;从 JVM 拿到线程 dump、JFR、堆与类加载器;从依赖拿到连接池、查询与事务状态。所有时间使用统一时钟与 request/operation id 对齐。
处置动作也应可证伪:降低入口并发后队列年龄是否下降;隔离慢接口后核心接口是否恢复;取消是否让依赖 active 回落;摘流后在途请求是否收敛。重启能清空状态,但在重启前至少保存线程 dump、连接/线程池指标、async 列表和临时目录快照。
容器故障治理的最终目标不是积累命令,而是让每个请求都有唯一状态、每个资源都有所有者、每个终止路径都能回到基线。只有这样,同样的 504 才能被准确区分为未接入、排队、业务阻塞、异步泄漏或写出失败。
事故后要把时间线固化为回归实验:在同一阶段注入同类失败,断言入口状态、业务效果、响应语义、资源归还和指标变化。active 高但 queue age 稳定可能只是正常峰值;queue age、deadline exceeded 与 rejected 同时上升才是容量失配。组合趋势比“线程多就告警”更接近真实故障。
