Java后端线上排查必备:从curl到tcpdump的完整网络排查链路
Java后端线上排查必备:从curl到tcpdump的完整网络排查链路
摘要:接口偶发超时,后端日志正常,下游也说自己没问题,问题到底出在哪?本文从一次真实排查经历出发,整理了从应用层到网络层的完整排查链路,包含命令、输出样例和Java代码示例,适合后端开发收藏备查。
关键词:Java、网络排查、tcpdump、连接超时、连接池、线上故障
先说30秒快速判断法
拿到一个"接口超时"的问题,先别急着翻代码。花30秒做两件事:
# 1. 端口通不通
nc -zv 目标IP 端口 -w 3
# 2. 各阶段耗时
curl -o /dev/null -s -w "TCP连接:%{time_connect}s 首字节:%{time_starttransfer}s 总耗时:%{time_total}s\n" http://目标:端口/接口
nc显示Connection refused → 服务没启动或者端口错了,跟网络没关系。
nc超时 → 包被拦截了,查防火墙或者安全组。
nc通了但curl首字节时间很长 → 下游处理慢,不是你这边的问题。
nc通了且curl正常但你的Java代码超时 → 大概率是连接池或者超时配置的问题。
30秒之内就能排除掉大部分方向,不用上来就翻代码翻半天。
排查全过程:一个真实的偶发超时
之前负责一个Java后端服务,对接下游的订单系统。某天下午开始超时率从0.1%涨到了3%。
应用日志里只有SocketTimeoutException: Read timed out,没有堆栈。找下游,他们拉出监控说平均响应时间20ms,一切正常。
我当时第一反应是去翻代码,看了半小时没看出问题。后来同事提醒我:你抓个包看看?
这才开始往网络层排查。
第一步:curl拆耗时
curl -o /dev/null -s -w \
"DNS解析: %{time_namelookup}s\n\
TCP连接: %{time_connect}s\n\
首字节时间: %{time_starttransfer}s\n\
总耗时: %{time_total}s\n" \
http://下游IP:8080/api/order/query
正常的时候:
DNS解析: 0.003s
TCP连接: 0.005s
首字节时间: 0.025s
总耗时: 0.026s
出问题的时候:
DNS解析: 0.003s
TCP连接: 0.005s
首字节时间: 3.025s
总耗时: 3.026s
TCP连接5ms就通了,但首字节等了3秒。说明TCP握手没问题,包送出去了,但下游过了3秒才回。
注意一点:time_starttransfer包含了TCP连接时间。如果要算纯服务端处理时间,应该用首字节时间 - TCP连接时间。上面这个例子是3025 - 5 = 3020ms,确实是下游处理耗时。
到这里其实已经能判断方向了:大概率是下游某些请求处理慢。但下游不认,说他们监控没问题。只能抓包,拿证据。
第二步:tcpdump抓包
tcpdump -i eth0 host 下游IP and port 8080 -nn -S -w /tmp/capture.pcap
等复现一次超时后停止,读取结果:
tcpdump -nn -S -r /tmp/capture.pcap
正常的请求:
14:23:01.000 IP 10.0.1.50.45678 > 10.0.1.60.8080: Flags [S], seq 1000000
14:23:01.001 IP 10.0.1.60.8080 > 10.0.1.50.45678: Flags [S.], seq 2000000, ack 1000001
14:23:01.001 IP 10.0.1.50.45678 > 10.0.1.60.8080: Flags [.], ack 2000001
14:23:01.002 IP 10.0.1.50.45678 > 10.0.1.60.8080: Flags [P.], seq 1000001:1000200
14:23:01.022 IP 10.0.1.60.8080 > 10.0.1.50.45678: Flags [P.], seq 2000001:2000500
01.002发出请求,01.022收到响应,20ms,正常。
超时的请求:
14:25:03.000 IP 10.0.1.50.46001 > 10.0.1.60.8080: Flags [S], seq 3000000
14:25:03.001 IP 10.0.1.60.8080 > 10.0.1.50.46001: Flags [S.], seq 4000000, ack 3000001
14:25:03.001 IP 10.0.1.50.46001 > 10.0.1.60.8080: Flags [.], ack 4000001
14:25:03.002 IP 10.0.1.50.46001 > 10.0.1.60.8080: Flags [P.], seq 3000001:3000200
14:25:03.003 IP 10.0.1.60.8080 > 10.0.1.50.46001: Flags [.], ack 3000200
...(3秒空白)...
14:25:06.003 IP 10.0.1.60.8080 > 10.0.1.50.46001: Flags [P.], seq 4000001:4000500
03.002发出请求数据,03.003收到TCP层的ACK确认,说明数据已经到达下游服务器的TCP缓冲区。但之后整整3秒没有任何数据包,直到06.003才收到响应。
这里需要说清楚:收到ACK只能说明TCP层确认了数据到达,严格来讲不能100%证明应用层已经读取并处理了。但抓包里没有看到任何重传,在没有丢包的情况下,可以合理推断数据已交付应用层,3秒延迟发生在应用处理阶段。
抓包截图发给下游,他们才承认有个定时任务每5分钟锁一次表。监控看的是平均值,没注意到P99的尖刺。
这件事教会我一点:平均响应时间是会骗人的。 后来我们要求所有下游接口都要提供P99数据,不能光看平均。
第三步:mtr查网络路径(跨网场景)
这次的case是下游处理慢,不是网络问题。但很多超时确实是网络导致的,特别是跨网场景。mtr比traceroute好用,因为它会持续发包做统计,而不是只跑一次。
mtr -n -c 100 -r 目标IP
正常路径(同网):
Host Loss% Snt Last Avg Best Wrst StDev
1. 10.0.0.1 0.0% 100 0.3 0.4 0.2 0.8 0.1
2. 172.16.0.1 0.0% 100 1.1 1.2 0.9 2.0 0.2
3. 10.0.1.60 0.0% 100 0.5 0.6 0.4 1.2 0.1
跨网路径(问题路径):
Host Loss% Snt Last Avg Best Wrst StDev
3. 219.x.x.x 0.0% 100 15.2 16.1 14.0 22.3 2.1
4. 202.97.x.x 6.0% 100 78.2 80.5 72.1 125.0 12.8
5. 61.x.x.x 6.0% 100 82.5 84.3 78.0 130.0 11.5
第4跳Loss%突然升高、延迟陡增、抖动大,这基本就是跨网互联节点了。如果你的应用服务器和下游不在同一个运营商,出现这种模式很常见。
有个容易误判的地方:某一跳Loss%高但后续跳恢复正常,不代表有问题。 很多路由器会限制ICMP回复速率,显示丢包但实际上不影响业务流量。判断标准是:如果Loss%从某一跳开始一直持续到最终目标,才是真丢包。
Java代码层面能做什么
排查网络问题不能只靠系统工具,Java代码本身的连接管理、超时配置、监控埋点同样关键。
连接池状态监控
很多超时问题的根因不是网络,是连接池。以Apache HttpClient 4.x为例:
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(200);
cm.setDefaultMaxPerRoute(50);
// 定时打印连接池状态,排查阶段建议开着
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(() -> {
PoolStats total = cm.getTotalStats();
log.info("连接池 - 活跃:{}, 空闲:{}, 最大:{}",
total.getActive(), total.getAvailable(), total.getMax());
}, 0, 30, TimeUnit.SECONDS);
如果日志里持续出现活跃:200, 空闲:0,说明连接池满了。这时候新请求会卡在等连接上,表现为超时。两种可能:连接泄漏(借了没还),或者下游太慢(连接被占用太久)。
超时参数要设全
RequestConfig requestConfig = RequestConfig.custom()
.setConnectTimeout(3000) // TCP建连超时
.setSocketTimeout(5000) // 等待响应超时
.setConnectionRequestTimeout(2000) // 从连接池获取连接超时
.build();
ConnectionRequestTimeout这个参数很多人漏了。它控制的是"从连接池借连接等多久"。如果不设,连接池满了之后请求会无限等待,表现出来的超时时间和你设的SocketTimeout对不上,排查时很迷惑。
OkHttp的EventListener
如果用的是OkHttp,EventListener能提供更细粒度的耗时信息:
public class DebugEventListener extends EventListener {
private long start;
@Override public void callStart(Call call) {
start = System.nanoTime();
}
@Override public void connectEnd(Call call, InetSocketAddress addr, Proxy proxy, Protocol protocol) {
log.info("TCP连接完成: {}ms", elapsed());
}
@Override public void responseHeadersEnd(Call call, Response response) {
log.info("首字节到达: code={}, {}ms", response.code(), elapsed());
}
@Override public void callFailed(Call call, IOException e) {
log.error("请求失败: {}ms, {}", elapsed(), e.getMessage());
}
@Override public void callEnd(Call call) {
log.info("请求完成: {}ms", elapsed());
}
private long elapsed() {
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
}
}
OkHttpClient client = new OkHttpClient.Builder()
.eventListenerFactory(call -> new DebugEventListener())
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build();
排查阶段开着,上线后关掉或者降级成采样模式。每个请求的DNS、TCP、首字节、总耗时都有日志,出问题翻日志就能定位。
连接泄漏检测
CLOSE_WAIT堆积是Java后端最常见的网络相关问题之一。正常情况下CLOSE_WAIT应该只存在极短时间,如果持续堆积,说明代码里有连接没关。
# 检查CLOSE_WAIT数量
ss -ant | grep CLOSE_WAIT | wc -l
如果数值持续增长,找到对应的进程和fd:
ss -antp | grep CLOSE_WAIT | head -5
CLOSE-WAIT 0 0 10.0.1.50:45000 10.0.1.60:6379 users:(("java",pid=12345,fd=89))
再用jstack看这个Java线程在干什么:
jstack 12345 > /tmp/thread_dump.txt
grep -A 5 "nid=0x" /tmp/thread_dump.txt | head -30
常见的泄漏模式:
// 反面:异常路径没有关闭连接
Connection conn = pool.getConnection();
Result result = conn.query(sql); // 这里抛异常的话,conn就泄漏了
pool.release(conn);
// 正面:用try-finally确保归还
Connection conn = pool.getConnection();
try {
Result result = conn.query(sql);
return result;
} finally {
pool.release(conn); // 无论是否异常都会归还
}
这种泄漏不直接报错,表现是运行一段时间后连接池耗尽、新请求超时。重启就好了,过几天又出现。如果你的Java服务有这种症状,优先查连接泄漏。
排查清单
遇到"接口超时但后端日志正常"的问题,按这个顺序来:
30秒快速判断:nc测端口、curl看耗时分布。
curl显示TCP连接慢:mtr查网络路径,大概率是跨网或者防火墙。
curl显示首字节时间慢:tcpdump抓包,确认是下游慢还是中间有人拦截。
网络层没问题:查Java连接池状态(是不是池子满了)、查超时配置(是不是漏了ConnectionRequestTimeout)、查CLOSE_WAIT(是不是连接泄漏)。
以上都查不出:strace追踪Java进程的系统调用,看卡在recvfrom还是sendto上。
strace -f -T -e trace=network -p Java进程PID 2>&1 | grep -E "recvfrom|sendto" | tail -20
输出里的尖括号里是每个系统调用的耗时秒数。如果recvfrom等了5秒才返回,那就是对端处理慢。如果sendto阻塞了,可能是发送缓冲区满了。
几个容易踩的坑
ping正常不代表服务正常。 我之前犯过这个错,ping通了就觉得网络没问题,查了半天代码才发现是端口没开。ping只能证明ICMP可达,跟TCP端口没有关系。
下游说"平均20ms"不代表没有超时。 平均值会掩盖P99的尖刺。要求下游给分位数数据,或者自己在代码里埋点统计。
抓包一定要加过滤条件。 不加过滤直接抓,线上流量大的时候pcap文件几秒钟就好几百MB,而且在一堆包里找目标包非常费劲。host X and port Y一定要加上。
代码层面最容易忽略的是ConnectionRequestTimeout。 很多人只设了连接超时和读超时,没设这个参数。连接池满的时候,请求不是报连接超时,而是卡在那里不返回,日志里看起来像是"没有请求进来"一样,排查时非常迷惑。
tcpdump用-S看绝对序列号更方便。 排查时用绝对序列号更容易追踪每个包的去向,虽然数字看起来大一些,但不容易搞混。
之前做后端开发的时候,遇到超时问题第一反应是翻代码、加日志、改超时参数。后来发现很多时候问题不在代码里,在网络里,在连接池里,在那些你看不到的地方。掌握一些网络排查的基本功,线上出问题时真的能少加不少班。
有问题评论区聊,互相学习。
更多推荐



所有评论(0)