Java Web 常见故障
一个请求可以在连接建立前失败,也可以连接成功后一直等待响应。即使客户端已经收到 200,后续响应体仍可能中断。定位 Java Web 故障,先确定请求已经走到哪一步,再选择对应的观测方式。
先区分连接、HTTP 响应和业务结果
curl 的退出码与 HTTP 状态码分别表示什么
在允许直接访问目标节点的 Linux 环境中,先做一次带时限的请求:
TARGET=http://127.0.0.1:18091/health
curl -q --noproxy '*' --connect-timeout 2 --max-time 5 \
-sS -o health-response.txt \
-w 'http=%{http_code} connect=%{time_connect} first_byte=%{time_starttransfer} total=%{time_total}\n' \
"$TARGET"
CURL_EXIT=$?
printf 'curl_exit=%s\n' "$CURL_EXIT"-q 放在第一个参数位置,禁止读取默认 curl 配置;--noproxy '*' 排除环境代理对这个回环实验的影响。企业网络只允许通过代理时,应保留批准的代理路径,并说明测到的是该路径,不能把代理响应误当成后端直连结果。
这里故意没有加 --fail-with-body:诊断时同时保留 HTTP 错误正文与传输退出码。后面的“预期成功”检查再加上它,把 HTTP 400 以上视为失败。选项与错误码见 curl 官方手册。
| 观察 | 当前能确定的情况 | 下一步 |
|---|---|---|
| 退出码 7,HTTP 000 | 没有成功建立到目标的连接 | 检查地址、监听、端口映射、防火墙 |
| 退出码 28 | 本次请求超过设定时限 | 结合 connect、first_byte 和服务端状态区分阶段 |
| 退出码 0,HTTP 404 | 收到完整 HTTP 响应,但路径未找到或被隐藏 | 检查 context、Servlet 映射、网关重写、权限策略 |
| 退出码 0,HTTP 500 | 服务端生成了错误响应 | 用关联标识查服务端异常与 ERROR 分派 |
| HTTP 200,但传输退出码非零 | 已收到响应头,响应传输又失败 | 检查写出、代理、客户端断连和长度约定 |
time_starttransfer 累积了前面的连接等阶段,不能直接当作某一个 Servlet 的执行时间。请求进入业务方法的日志时间、线程栈和下游调用时间,需要另外采集。
一次失败至少保留哪些信息
记录方法、路径、目标节点、响应状态、curl 退出码,以及经过脱敏的关联标识。请求体只保留定位所需字段,认证头、Session Cookie、密码和原始个人数据不应进入共享排障记录。
反向代理返回 502 或 504 时,先确认生成响应的是哪一层,再查该层的上游连接或超时日志。状态码名称及标准语义可查 IANA HTTP 状态码注册表。应用业务是否已经执行,仍要用业务记录确认。
复现两个工作线程被占住的情况
启动一个可以主动恢复的实验
下载完整诊断工程,解压进入 servlet-diagnostics/。工程包含 POM、完整启动类和三个真实 HTTP 集成测试。环境为 Linux、Docker、curl;容器运行 Temurin 25,源码编译目标 Java 17,Tomcat 11.0.25 / Servlet 6.1。
实验启动两个监听器:
127.0.0.1:18091 → Tomcat :8080,只有 2 个工作线程
/hold 等待放行,最多 30 秒
/health 立即返回 ok
/timeout 异步周期超时后返回 503
/broken 提交前抛出异常
/committed 提交后尝试 sendError
127.0.0.1:18092 → 独立实验管理 :8081
/stats 查看 holding / active / queued
/release 放行 /hold管理监听器使用独立执行器,避免解除等待的请求也排进已经占满的 Tomcat 工作池。它没有认证,只用于本机实验,两个宿主端口都必须绑定回环地址。不要把这个辅助入口部署到共享测试或生产网络。
构建目录由当前普通用户拥有;同一 UID/GID 运行构建和应用:
BUILD_IMAGE=maven:3.9.12-eclipse-temurin-25
RUN_IMAGE=eclipse-temurin:25.0.4_7-jdk
mkdir -p .m2
docker run --rm --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/cache -v "$PWD/.m2:/cache" \
-v "$PWD:/work" -w /work "$BUILD_IMAGE" \
mvn -B -Dmaven.repo.local=/cache/repository clean verify
docker run --detach --name servlet-diagnostics \
--user "$(id -u):$(id -g)" --read-only \
--cap-drop ALL --security-opt no-new-privileges \
--tmpfs /tmp:rw,nosuid,nodev,size=64m,mode=1777 \
-p 127.0.0.1:18091:8080 -p 127.0.0.1:18092:8081 \
-v "$PWD/target:/app:ro" -w /app \
"$RUN_IMAGE" java -cp 'classes:dependency/*' lab.Server 8080 8081
docker logs servlet-diagnostics构建应有三个测试全部通过;运行日志应出现 LISTENING 8080 ADMIN 8081。若端口被占用,先停止属于自己的旧实验容器,或同时调整映射与下面的变量。
BASE=http://127.0.0.1:18091
ADMIN=http://127.0.0.1:18092
curl -q --noproxy '*' --fail-with-body --max-time 5 "$BASE/health"正常输出为 ok。还未看到这一结果时,不开始故障注入。
先占用两个线程,再发送第三个请求
在同一个 Bash 终端执行:
curl -q --noproxy '*' --fail-with-body --max-time 40 \
-sS "$BASE/hold" > hold-1.txt &
HOLD_1=$!
curl -q --noproxy '*' --fail-with-body --max-time 40 \
-sS "$BASE/hold" > hold-2.txt &
HOLD_2=$!
curl -q --noproxy '*' --fail-with-body --max-time 5 "$ADMIN/stats"直到 holding=2 再发第三个请求;若还是 0 或 1,立即重复一次 stats。此时应接近下面的状态:
holding=2
active=2
queued=0接着执行:
curl -q --noproxy '*' --fail-with-body --max-time 40 \
-sS "$BASE/health" > queued-health.txt &
HEALTH_PID=$!
curl -q --noproxy '*' --fail-with-body --max-time 5 "$ADMIN/stats"第三个请求进入后,queued 应至少为 1;若快照仍为 0,重复读取一次。这里没有其他业务流量,通常得到 holding=2 / active=2 / queued=1。三个命令都是异步启动的客户端,输出文件暂时为空属于预期现象。
服务器的 /hold 有 30 秒保护期限。若操作时间超过期限,它会返回 503 和 hold-deadline,以免无限占住线程;这时重新启动容器,再进行实验。
线程数和队列数为什么会这样变化
实验只配置了 Tomcat 内部执行器,没有挂接共享 Executor:
var protocol = (AbstractProtocol<?>) connector.getProtocolHandler();
protocol.setMaxThreads(2);
protocol.setMinSpareThreads(2);每个 /hold 在 Servlet 中等待同一个 CountDownLatch,占着一个工作线程。/health 的逻辑虽然很短,仍需要从同一执行器得到运行机会,所以会排队。增加业务方法的速度解决不了它前面的等待。
stats 读取的是这个固定实验配置下的 Tomcat 执行器。Tomcat 11.0.25 的类型为 org.apache.tomcat.util.threads.ThreadPoolExecutor;不能强制转换成同名的 java.util.concurrent.ThreadPoolExecutor。生产应用若改用共享执行器或虚拟线程,需要改观测方式。Connector 属性见 Tomcat HTTP Connector,JMX 入口见 Tomcat 监控说明。
完整启动类:监听器、受控端点和资源关闭
将下面内容放在 src/main/java/lab/Server.java,使用下载包中的 POM 编译。辅助监听器只负责实验观察与放行,不与业务端口共享工作池。
package lab;
import com.sun.net.httpserver.HttpServer;
import jakarta.servlet.*;
import jakarta.servlet.http.*;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.Comparator;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.catalina.*;
import org.apache.catalina.startup.Tomcat;
import org.apache.coyote.AbstractProtocol;
public final class Server implements AutoCloseable {
private final Tomcat tomcat = new Tomcat();
private final Path base;
private final HttpServer admin;
private final ExecutorService adminExecutor = Executors.newSingleThreadExecutor();
private final CountDownLatch release = new CountDownLatch(1);
final CountDownLatch entered = new CountDownLatch(2);
private final AtomicInteger holding = new AtomicInteger();
public Server(int port, int adminPort) throws IOException {
base = Files.createTempDirectory("servlet-diagnostics-");
tomcat.setBaseDir(base.toString());
tomcat.setPort(port);
var connector = tomcat.getConnector();
var protocol = (AbstractProtocol<?>) connector.getProtocolHandler();
protocol.setMaxThreads(2);
protocol.setMinSpareThreads(2);
Context context = tomcat.addContext("", base.toString());
context.setParentClassLoader(Server.class.getClassLoader());
add(context, "/health", new Health(), false);
add(context, "/hold", new Hold(), false);
add(context, "/timeout", new Timeout(), true);
add(context, "/committed", new Committed(), false);
add(context, "/broken", new Broken(), false);
admin = HttpServer.create(new InetSocketAddress("0.0.0.0", adminPort), 0);
admin.setExecutor(adminExecutor);
admin.createContext("/stats", exchange -> {
byte[] body = stats().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/plain;charset=UTF-8");
exchange.sendResponseHeaders(200, body.length);
try (var out = exchange.getResponseBody()) { out.write(body); }
});
admin.createContext("/release", exchange -> {
release();
byte[] body = "released\n".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (var out = exchange.getResponseBody()) { out.write(body); }
});
}
private static void add(Context context, String path, HttpServlet servlet, boolean async) {
Wrapper wrapper = Tomcat.addServlet(context, path, servlet);
wrapper.setAsyncSupported(async);
context.addServletMapping(path, path);
}
public void start() throws LifecycleException { tomcat.start(); admin.start(); }
public int port() { return tomcat.getConnector().getLocalPort(); }
public int adminPort() { return admin.getAddress().getPort(); }
public void release() { release.countDown(); }
public String stats() {
var protocol = (AbstractProtocol<?>) tomcat.getConnector().getProtocolHandler();
var pool = (org.apache.tomcat.util.threads.ThreadPoolExecutor) protocol.getExecutor();
return "holding=" + holding.get() + "\nactive=" + pool.getActiveCount()
+ "\nqueued=" + pool.getQueue().size() + "\n";
}
@Override public void close() throws IOException {
release();
admin.stop(0);
adminExecutor.shutdownNow();
try { tomcat.stop(); tomcat.destroy(); }
catch (LifecycleException e) { throw new IOException(e); }
finally {
try (var files = Files.walk(base)) {
for (Path path : files.sorted(Comparator.reverseOrder()).toList()) Files.deleteIfExists(path);
}
}
}
public static final class Health extends HttpServlet {
private static final long serialVersionUID = 1L;
@Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType("text/plain;charset=UTF-8");
resp.getWriter().write("ok\n");
}
}
public final class Hold extends HttpServlet {
private static final long serialVersionUID = 1L;
@Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
holding.incrementAndGet();
entered.countDown();
try {
boolean done = release.await(30, TimeUnit.SECONDS);
resp.setStatus(done ? 200 : 503);
resp.setContentType("text/plain;charset=UTF-8");
resp.getWriter().write(done ? "released\n" : "hold-deadline\n");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
resp.sendError(503);
} finally { holding.decrementAndGet(); }
}
}
public static final class Timeout extends HttpServlet {
private static final long serialVersionUID = 1L;
@Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
AsyncContext async = req.startAsync();
async.setTimeout(100);
async.addListener(new AsyncListener() {
@Override public void onTimeout(AsyncEvent e) throws IOException {
HttpServletResponse response = (HttpServletResponse) e.getSuppliedResponse();
if (!response.isCommitted()) {
response.setStatus(503);
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().write("async-timeout\n");
}
e.getAsyncContext().complete();
}
@Override public void onComplete(AsyncEvent e) { System.out.println("ASYNC complete"); }
@Override public void onError(AsyncEvent e) { System.out.println("ASYNC error"); }
@Override public void onStartAsync(AsyncEvent e) { e.getAsyncContext().addListener(this); }
});
}
}
public static final class Committed extends HttpServlet {
private static final long serialVersionUID = 1L;
@Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType("text/plain;charset=UTF-8");
resp.getWriter().write("prefix\n");
resp.flushBuffer();
try { resp.sendError(500); }
catch (IllegalStateException expected) {
System.out.println("COMMITTED sendError rejected; original status=200");
}
}
}
public static final class Broken extends HttpServlet {
private static final long serialVersionUID = 1L;
@Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException {
throw new ServletException("controlled-failure");
}
}
public static void main(String[] args) throws Exception {
Server server = new Server(args.length > 0 ? Integer.parseInt(args[0]) : 8080,
args.length > 1 ? Integer.parseInt(args[1]) : 8081);
try { server.start(); }
catch (Exception failure) {
try { server.close(); } catch (IOException cleanup) { failure.addSuppressed(cleanup); }
throw failure;
}
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try { server.close(); } catch (IOException e) { e.printStackTrace(); }
}));
System.out.println("LISTENING " + server.port() + " ADMIN " + server.adminPort());
new CountDownLatch(1).await();
}
}从线程栈确认等待点,再验证恢复
在同一容器与身份下读取 JVM
上述启动方式直接以 Java 作为容器主进程,因此 PID 为 1。镜像包含 JDK 工具,可在容器内部、使用默认配置的同一 UID/GID 读取线程栈:
docker exec servlet-diagnostics id
docker exec servlet-diagnostics jcmd 1 Thread.print -ldocker exec 没有额外指定 root;会沿用容器配置的用户。命令执行环境见 Docker exec 文档。jcmd 的进程可见性、权限要求和诊断命令见 JDK 25 jcmd 手册。
在两条 http-nio-8080-exec-* 线程中,可以找到类似调用栈:
java.lang.Thread.State: TIMED_WAITING (parking)
...
java.util.concurrent.CountDownLatch.await(...)
lab.Server$Hold.doGet(...)
jakarta.servlet.http.HttpServlet.service(...)
...
org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(...)线程编号、地址和行号随实际构建变化,不需要逐字符匹配。重点是两条工作线程都停在相同的受控等待点;队列里还有尚未执行的健康请求。
生产栈里若替换成 JDBC 获取连接、数据库 socket 读取、HTTP 客户端等待或锁获取,下一步应查对应资源。采样三次、间隔数秒,可以区分持续停留与偶然经过;所有栈都持续 RUNNABLE 时,也要结合进程 CPU,进一步用采样剖析判断热路径。
Thread.print 更适合这里使用的平台线程。采用虚拟线程时,可按 JDK 文档选择 Thread.dump_to_file 等支持该场景的诊断方式,并留意输出量。不要因传统线程转储里没看到某个任务,就直接断言它不存在。
放行后回到同一观察点
在前面的 Bash 终端执行:
curl -q --noproxy '*' --fail-with-body --max-time 5 "$ADMIN/release"
wait "$HOLD_1"
wait "$HOLD_2"
wait "$HEALTH_PID"
cat hold-1.txt hold-2.txt queued-health.txt
curl -q --noproxy '*' --fail-with-body --max-time 5 "$ADMIN/stats"
curl -q --noproxy '*' --fail-with-body --max-time 5 "$BASE/health"三个等待命令应成功,文件内容依次为 released、released、ok。随后 holding 与 queued 回到 0,健康接口再次立即返回。瞬时 active 可能短暂变化,需在请求完成后再观察。
放行是一次性的,之后 /hold 会直接通过。重复饱和实验需要重新启动容器。
自动化测试按相同顺序等待两个 Servlet 真正进入,再发送第三个请求;它检查队列有任务、第三个响应仍未完成,以及管理接口放行后全部恢复。输出为:
holding=2 queued=1 adminRelease=true allRecovered=true这同时解释了一个排障陷阱:连接已经建立、进程仍活着、健康方法也很简单,但业务线程被占满时,健康请求依旧可能超时。生产就绪检查应该如何设计,要结合是否希望该实例继续接收流量决定。
异步超时与已提交响应使用不同处理方式
异步周期要有最终处理
先发受控异步超时请求:
STATUS=$(curl -q --noproxy '*' --max-time 5 -sS \
-o timeout-response.txt -w '%{http_code}' "$BASE/timeout")
TRANSPORT=$?
test "$TRANSPORT" -eq 0 && test "$STATUS" = 503 \
&& test "$(cat timeout-response.txt)" = async-timeout
cat timeout-response.txt这里同时检查传输退出码、HTTP 503 和完整响应文本;判断失败时先查看 curl 错误与容器日志。正常输出为 async-timeout。Servlet 开始异步后没有提交任务,100 ms 超时触发 Listener;Listener 设置 503 并调用 complete(),随后日志出现 ASYNC complete。异步超时从初始分派返回后开始计时,不能用它覆盖此前的连接、排队和 Servlet 执行时间。
若真实服务的未完成请求持续增长,应一起检查异步开始数、最终完成数、超时数、任务队列和异常路径。后台 Future 已经结束,却漏掉 dispatch() 或 complete(),请求可能仍占用异步上下文。Listener 的最终回调比 Filter 的初始调用返回更适合作为请求结束信号,详见请求线程、异步与非阻塞 I/O及 Servlet AsyncContext API。
超时后若后台任务仍在运行,还要阻止它继续写响应,并按具体客户端能力取消外部调用。已经提交的数据库事务不会因为 HTTP 超时自动撤销。
提交后无法把原响应换成新的 JSON 错误
比较两个端点:
STATUS=$(curl -q --noproxy '*' --max-time 5 -sS \
-o broken-response.txt -w '%{http_code}' "$BASE/broken")
TRANSPORT=$?
test "$TRANSPORT" -eq 0 && test "$STATUS" = 500
curl -q --noproxy '*' --fail-with-body --max-time 5 -i "$BASE/committed"/broken 在响应提交前抛异常,容器可以生成 500。/committed 先写入 prefix 并调用 flushBuffer();200 和响应头已经提交。随后 sendError(500) 抛出 IllegalStateException,原来的状态不会被改写。实验捕获并记录这个异常,所以客户端收到完整的 200 和 prefix。
真实流式下载若在提交后出现异常,可能只能终止流或连接,客户端表现取决于协议、长度声明和已收到的字节。服务端再拼一段 JSON 错误,会破坏原有文件或数据格式。可在开始写出前完成必要校验;对长流设计记录级错误或独立结果查询机制。提交约束见 HttpServletResponse API。
客户端断连产生的 Broken pipe、Connection reset 或容器专属异常,说明写出或读取链路断了。是否需要告警,要结合断连比例、代理期限、响应大小和业务结果;不能仅凭异常名称断定服务器故障。
请求结束后仍要检查资源是否释放
Session、上传和类加载器留下不同线索
| 持续增长的对象 | 首先查看 | 后续处理 |
|---|---|---|
| Session 数量与堆占用 | 创建速率、空闲超时、属性大小 | 检查每次请求是否意外创建 Session,以及会话中是否保存大对象 |
| 上传临时目录 | 可用空间、Part 删除、异常中断路径 | 设置上传总量与文件上限,修复失败路径回收,确认文件所有者 |
| 后台线程与定时任务 | 线程名称、栈、创建次数 | 在应用停止时关闭自建执行器,并等待已启动任务 |
| 重部署后的旧应用类 | classloader 数量、存活对象引用 | 查 ThreadLocal、线程上下文类加载器、静态注册和第三方驱动 |
Session 操作与生命周期见 Filter、Listener 与 Session;multipart 限制和临时文件清理见文件上传与字符编码。
读取本实验容器状态可用:
docker stats --no-stream servlet-diagnostics
docker exec servlet-diagnostics df -h /tmp
docker exec servlet-diagnostics jcmd 1 VM.classloader_stats这些命令适用于这里的 JDK 镜像。精简生产镜像可能没有 shell、df 或诊断工具,应通过已有平台监控或批准的诊断环境取数,不临时把未知工具复制进生产实例。
类加载器数量高可能只是正常装配了多个模块。怀疑重部署泄漏时,要比较多次部署后的旧实例是否持续存活、是否被线程或静态对象引用,不能用一次计数定案。堆转储还可能包含凭据与个人数据,并产生较大磁盘、停顿和分析开销;获取前确认授权、空间和存储访问范围。
恢复动作应针对已经观察到的等待
数据库连接池耗尽时,扩大 Tomcat 线程池通常只会让更多请求一起等连接。应先处理连接泄漏、长事务或数据库容量,再协调入口并发上限。CPU 已饱和时同样要先减少无效工作或分摊负载。
需要重启实例时,先保存有价值的线程栈、关键指标和关联日志,再摘流、等待在途请求或明确停止期限。恢复后用同一路径复测延迟、错误率和队列,继续观察是否重新增长。一次重启让队列清空,只能说明进程状态被重置,原因还要由前后的证据解释。
完成本机实验后停止并删除自己的容器:
docker stop --timeout 15 servlet-diagnostics
docker rm servlet-diagnostics关闭逻辑会先放行受控等待,停止两个监听器,再删除本进程创建的临时目录。源码、Maven 缓存以及宿主上的诊断输出文件保留,便于后续复查。
权威资料与规范地址
诊断命令的权限、开销和完整参数应按实际 JDK 与容器版本查阅。
