半夜两点被值班电话吵醒,那个月的第二次了。支付回调大面积超时,用户卡在“支付中”出不来,技术群里已经开始有合作方找过来问情况。我登上机器后没有立刻看业务日志,而是先跑了三条命令:ss -s 看全局连接统计,ss -antp 看当前连接明细,再用 tcpdump 抓几分钟握手包。真正把问题定位下来,靠的其实是脑子里把“TCP连接”从建立到断开、从正常到异常的整个生命周期重新过了一遍。这篇文章我会把那晚的排查路径、TCP连接的核心机制、以及我在线上反复踩过的坑一并讲清楚。不管你是后端开发、运维,还是刚学网络不久的新人,只要业务里还有服务器之间的通信,这条连接背后的逻辑都值得吃透。
1. 深夜事故的排查起点:先把连接状态摸清楚
1.1 事故现象与第一轮命令
那晚的现象很诡异:服务整体没宕机,进程还在,CPU 也不算高,但支付网关回调到我们内网网关的请求大量超时,客户端表现为“连接建立后没有响应”。值班同事比较急,在群里说已经重启过一次应用,当时恢复了十几分钟,然后又超时了。
我让他先停手,重启只是在掩盖问题,因为每次重启会把 TCP 连接池整个清空重建,看起来“好了”,但根本原因没找到,过一会儿连接被污染后又会复发。
我先执行的命令是:
bash复制ss -s
当时看到的关键指标大致是:
text复制TCP: 2917 (estab 2819, closed 44, orphaned 5, synrecv 48, timewait 116), ports 303
这个输出里最扎眼的就是 synrecv 48。正常情况下,内网服务之间的连接建立是毫秒级完成的,SYN_RECV 状态长期保持 0 或者个位数才对。如果这个数字持续上涨,说明有大量 TCP 连接握手没握手完,卡在了服务端“收到 SYN、等待最终 ACK”的阶段。
1.2 从状态统计判断故障范围
TCP 连接不是只有“建立”和“断开”两个状态,而是十几个状态。排查时最常用的几个状态必须一眼能认出来:
| 状态 | 含义 | 常见堆积原因 |
|---|---|---|
| LISTEN | 服务端正在监听端口 | 正常 |
| SYN_RECV | 已完成两次握手,等待第三次 ACK | 半连接队列满、握手包丢失、SYN 洪水 |
| ESTABLISHED | 连接已建立,正常收发数据 | 正常,但堆积多且业务无响应时注意半开连接 |
| FIN_WAIT_1 / FIN_WAIT_2 | 主动关闭方等待对端确认/关闭 | 对端不处理、应用不 close |
| TIME_WAIT | 主动关闭方等待 2MSL 后彻底释放 | 主动关闭太频繁,多为短连接场景 |
| CLOSE_WAIT | 已收到对端 FIN,应用还没 close | 业务代码没释放 socket,几乎全是 bug |
那晚的 SYN_RECV 在涨,说明问题大概率发生在“连接建立这一关”,而不是应用代码的处理逻辑。所以接下来的重点很明确:为什么三次握手完不成。
1.3 抓包确认,区分客户端还是服务端问题
看内核统计只能知道“堆在哪”,要判断是谁的锅,还得抓包。
我在服务端执行了抓包:
bash复制tcpdump -nn -i eth0 tcp port 8080 -c 100
抓到的现象是:客户端持续发 SYN,服务端没有回 SYN-ACK,也没有看到任何 RST。这说明 SYN 请求要么被服务端内核直接丢弃了,要么在进入协议栈之前的某个环节丢了。
结合当时 SYN_RECV 的状态数,基本可以锁定:TCP 三次握手的第一个队列——半连接队列(syn queue)或者后面的全连接队列(accept queue)——大概率出问题了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三次握手背后:连接建立为何会卡在SYN_RECV
2.1 三次握手的过程与为什么必须是三次
三次握手的教科书描述大家都知道:客户端发 SYN,服务端回 SYN-ACK,客户端再回 ACK。但很多人没想过,为什么必须是三次,而不是两次或者四次。
用最直白的方式理解:三次握手的目标是让双方都确认“我能收你的包,你也能收我的包”。
- 客户端发出 SYN,此时客户端不知道自己发送能力是否正常;
- 服务端收到 SYN 并回 SYN-ACK,服务端确认了自己能收、自己也能发,但服务端不知道客户端能不能收到自己的包;
- 客户端收到 SYN-ACK 后回 ACK,此时客户端确认了自己发送正常、服务端收发正常;
- 服务端收到 ACK,才最终确认客户端能收自己的包。
所以第三次 ACK 不是多余的动作,它是唯一能让服务端得到“我和客户端之间双向可达”这个结论的机制。少了这一次,服务端不敢把连接放进 accept 队列。
如果只做两次握手,出现旧 SYN 延迟到达时,服务端没法区分这是一个重复的旧请求还是新请求,很可能会错误地建立连接并分配资源。三次握手配合序列号机制,才能把这种“历史连接”挡在门外。
2.2 两个队列决定了握手能否成功
很多人在排查 TCP 连接问题时,只知道看 SYN_RECV 多不多,却不知道服务端接收新连接其实要过两道闸门。
第一道闸门叫 syn queue,也就是半连接队列。客户端 SYN 到达后,服务端内核会在这里记录一个“半连接”条目,然后回 SYN-ACK。如果这个队列满了,新到的 SYN 可能被直接丢弃。
第二道闸门叫 accept queue,也就是全连接队列。三次握手完成后,连接会从这里等待应用进程调用 accept() 取走。如果这个队列满了,即使握手完成,连接也无法交给应用。
两个队列的参数来源不一样,这点非常重要:
| 队列 | 内核参数 | 说明 |
|---|---|---|
| 半连接队列 | net.ipv4.tcp_max_syn_backlog |
决定 SYN 队列上限 |
| 全连接队列 | 应用 listen(fd, backlog) 与 net.core.somaxconn 取较小值 |
应用传入的 backlog 并不是无限有效的,最终受限于 somaxconn |
| 是否开启 SYN Cookie | net.ipv4.tcp_syncookies |
半连接队列满时的一种保护机制 |
| 全连接队列满时的行为 | net.ipv4.tcp_abort_on_overflow |
0 静默丢弃,1 回 RST |
很多人只听过后端框架里的 backlog 参数,以为调大就万事大吉,实际上如果 net.core.somaxconn 是默认的 128,就算你 listen(1024),全连接队列依然只有 128。
2.3 队列溢出时的内核行为与参数调整顺序
那晚排查时,我执行了一条很容易被忽略的命令:
bash复制nstat -az | grep -i listen
输出里有明显的 TcpExtListenDrops 和 TcpExtListenOverflows 增长,说明全连接队列已经在丢连接了。
问题确认后,参数调整的顺序很重要,不要一上来就开 tcp_abort_on_overflow=1。
先做三件事:
- 看应用进程到底传了多少 backlog 给
listen()。Java 的ServerSocket默认 backlog 是 50,Nginx 默认 511,很多应用框架默认很小; - 看
net.core.somaxconn,把它和应用的 backlog 对齐调大。比如应用想用 1024,那就sysctl -w net.core.somaxconn=1024; - 确认应用 accept 连接的模型是否高效。如果应用是单线程阻塞 accept,队列就算调到 1024 也很快会再次堆满。
至于 tcp_syncookies,我建议在半连接队列经常被打满时开启,它能在不依赖队列空间的情况下完成握手过程,对 SYN 类洪峰很有效。tcp_abort_on_overflow 除非你明确知道“队列满时立刻拒绝客户端比让客户端傻等重传更好”,否则别轻易开 1。
2.4 连接被“假完成”后,应用层为什么毫无察觉
这里还有一个非常隐蔽的坑:当 accept queue 满时,即使三次握手已经完成,内核也可能把连接“做掉”——它不会一直保留在队列里,而是直接丢弃。客户端那边看到的现象是连接建立了,发数据过去却石沉大海,直到超时。
为什么 TCP 状态机已经完成了握手,应用却拿不到这条连接?
因为内核网络栈和应用进程之间是异步的。内核完成了握手,把连接放入队列,然后等待应用调用 accept()。如果应用迟迟不来取,队列又满了,新完成的连接就会被丢弃。客户端不知道服务端应用层没接住,只会认为对端“收到了但没反应”。
这种“连接已建立但业务无响应”的现象,在后端问题排查里极其常见,而且很容易被误判为应用线程阻塞。下次再看到 ESTABLISHED 数量很大但请求全部超时,先看 ss -lntp 的 Recv-Q 和 Send-Q,确认一下 accept 队列是否在积压。
3. 连接建立之后:数据传输中的流量控制与等待之谜
3.1 用 ss 的 Recv-Q/Send-Q 判断数据卡在哪一侧
连接建好了,接下来就是数据传输。TCP 在这阶段的核心机制是滑动窗口和缓冲区,但线上排查时不需要背理论,直接用 ss 看积压数据就够了。
ss -antp 的输出里有两列:Recv-Q 和 Send-Q。注意它们的语义分两种情况:
- 在
LISTEN状态下,Recv-Q 表示 accept 队列当前积压的连接数,Send-Q 表示 accept 队列的最大长度上限; - 在
ESTABLISHED等其他状态下,Recv-Q 表示内核接收缓冲区里还没被应用读走的数据字节数,Send-Q 表示发送缓冲区里还没被对端确认的数据字节数。
举个实际判断思路:
text复制State Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB 0 456 10.0.1.5:8080 10.0.2.8:52346
这个连接的 Send-Q 是 456,说明本地有 456 字节数据已经放进内核发送缓冲区,但还没收到对端的 ACK。这时候要判断是网络问题还是对端不消费:去对端机器上看同一条连接的 Recv-Q,如果对端 Recv-Q 也很大,说明对端应用没读;如果对端 Recv-Q 是 0,说明数据可能丢在网络链路上,或者是对端内核缓冲区没空间、通告了零窗口。
3.2 Nagle 算法与延迟确认的碰撞
这一节要说一个非常经典的性能陷阱,也是很多连接延迟高但吞吐正常的元凶:Nagle 算法和 TCP 延迟确认的互相等待。
Nagle 算法的规则很简单:发送方如果发现有未确认的数据在发送缓冲区里,那新写入的小包就不能立刻发出去,要等这些数据被 ACK 后,和新数据合并成一个更大的包再发。它的目的是减少网络上小包的数量,提升带宽利用率。
TCP 延迟确认则是接收方的策略:收到数据后不立刻回 ACK,而是等一小段时间(通常最多 40ms),如果这期间有数据要发,就能把数据捎带在 ACK 包里一起发出去。
这两个机制单独看都没问题,放到一次小请求-小响应的短交互里就会互相卡死:
- 客户端写了一个很小的请求包;
- 由于之前可能有未确认的数据,小请求被 Nagle 算法卡住,不能立刻发;
- 服务端收到请求后,回了一个小响应,同时因为延迟确认机制,这个 ACK 迟迟不发;
- 客户端收不到 ACK,发送缓冲区里的数据继续被 Nagle 卡住;
- 直到延迟确认的 40ms 定时器超时,ACK 才发出,客户端才继续发送。
这就是为什么有些时候,单次请求-响应的延迟会莫名多出几十毫秒。解决办法很直接:对低延迟交互型的连接,关闭 Nagle,在业务 socket 上设置 TCP_NODELAY:
python复制import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
但要注意,关了 Nagle 之后,如果应用疯狂写小包,网络上的小包数量会明显上升。所以更稳妥的做法是:在应用层做消息合并,一次 write 写多个业务消息,同时关闭 Nagle,这样既低延迟又不会制造太多小包。
3.3 丢包重传对延迟和吞吐的连锁影响
TCP 是可靠传输,但可靠性有代价。一旦网络链路出现丢包,TCP 会触发重传,重传本身又会导致拥塞窗口收敛,吞吐率断崖式下降。
正常情况下,如果只是少量乱序,TCP 会通过 SACK 选项和快速重传机制,在收到几个重复 ACK 后就重发丢失的包,不用等超时。但如果是链路质量问题,比如跨地域网络丢包率达到 1% 甚至更高,TCP 就会频繁进入拥塞避免阶段,窗口被缩小,总吞吐可以跌到原来的几分之一。
这个阶段最容易踩的坑是通过调参数“硬扛”:比如调大 net.ipv4.tcp_retries2,让 TCP 多重传几次。默认值是 15,意味着已建立连接在尝试重传 15 次后才会放弃,按指数退避计算,可能要几十秒到几分钟才能判定对端不可达。这个时间对很多业务来说太长了,尤其是对端进程已经挂掉的情况,应用可能要等很久才会收到连接错误。
如果业务可以接受更快失败,可以适当调小这个值:
bash复制sysctl -w net.ipv4.tcp_retries2=5
但请记住,这只是“快速失败”的方法,不能改善真正的网络丢包。链路质量不行,再怎么调参数都是拆东墙补西墙。
3.4 零窗口问题:接收方不消费,发送方被“锁死”
还有一个不太容易被发现的性能杀手,叫零窗口。
TCP 的流量控制依赖接收方通告自己的可用缓冲区大小。假设接收方的应用不再从 socket 读取数据,接收缓冲区就会慢慢被填满,最终内核会通告 window = 0,明明白白告诉发送方:我现在一个字都收不了了。
发送方收到零窗口通告后,会停止发送数据,并启动一个持续定时器,周期性地发送窗口探测包,看接收方的窗口有没有恢复。如果接收方应用一直不消费,这条连接就成了“僵尸活连接”:状态是 ESTABLISHED,但数据一动不动。
我之前排查过一起消息队列消费者卡住的线上事故。表象是生产者发送队列不断堆积,连接每一端的 Send-Q 都在涨,对端 Recv-Q 也在涨,但业务日志里消费者线程池明明还在处理。最后定位到是消费者线程池里的某个慢查询把数据库连接池占满了,拿到数据库连接的线程在处理一个很慢的 SQL,其他线程全部阻塞,socket 没人读了,窗口被通告为零,上游全部堵住。
排查零窗口问题,用一条命令就能看到端倪:
bash复制ss -ant | awk '{print $2, $3, $4, $5, $6}'
如果大量 ESTABLISHED 连接的 Recv-Q 长期不为 0,而且趋势只增不减,基本可以断定是应用消费速度跟不上,而不是网络问题。
4. 断开连接的学问:TIME_WAIT、CLOSE_WAIT 与资源泄漏
4.1 四次挥手的状态流转
TCP 连接断开比建立更复杂一点,因为支持“半关闭”——一方可以停止发送数据,但还继续接收。所以正常的断开需要四次交互:
- 主动关闭方发送 FIN,进入 FIN_WAIT_1;
- 被动关闭方收到 FIN,回 ACK,进入 CLOSE_WAIT,主动关闭方收到 ACK 后进入 FIN_WAIT_2;
- 被动关闭方应用调用 close,发送 FIN,进入 LAST_ACK;
- 主动关闭方收到 FIN,回最后一个 ACK,进入 TIME_WAIT,被动关闭方收到 ACK 后关闭连接。
为什么不能像握手一样三次搞定?因为被动关闭方并不一定在收到 FIN 后就立刻没有数据要发了,它可能还有数据要返回。所以中间一定要拆成“先 ACK”和“再 FIN”两步。
排查断开阶段的问题,主要盯两个状态:TIME_WAIT 和 CLOSE_WAIT。
4.2 TIME_WAIT 的作用与大量出现时的应对
TIME_WAIT 是主动关闭方在发出最后一个 ACK 后进入的状态,持续 2MSL(Maximum Segment Lifetime),Linux 里通常等于 60 秒左右。
TIME_WAIT 存在有两个原因:
- 防止最后一个 ACK 丢失。如果对端没收到这个 ACK,它会在 LAST_ACK 状态重发 FIN,主动关闭方需要还留在这个状态,才能再回一个 ACK;
- 防止旧连接的迟到数据包污染新连接。如果没有 TIME_WAIT,一个端口很快被复用,之前那条连接里在网络中飘着的旧数据包,可能被当作新连接的数据接收。
TIME_WAIT 本身不是问题,问题是大量短连接场景下,主动关闭方会积压大量 TIME_WAIT,从而消耗本地端口。客户端每连一次服务端,就占用一个源端口。如果短连接几万几十万地建立和关闭,端口很快会被 TIME_WAIT 占满,这时候新连接就会报 cannot assign requested address。
应对 ORDER 是这样的:
- 第一选择:改应用,用连接池复用长连接,从根上减少主动关闭频率;
- 第二选择:调大
net.ipv4.ip_local_port_range,扩大可用端口范围; - 第三选择:启用
net.ipv4.tcp_tw_reuse=1,允许内核在满足条件的情况下复用 TIME_WAIT 状态的端口做出站连接。
注意,tcp_tw_reuse 只对主动出站连接有效,对监听端口收到的新连接无效。它依赖 TCP 时间戳机制来区分新旧连接,一般默认开启时间戳时安全可用。
4.3 CLOSE_WAIT 堆积,基本等于应用没 close
如果说 TIME_WAIT 是资源问题,那 CLOSE_WAIT 就是代码问题,而且几乎 100% 是代码问题。
CLOSE_WAIT 的触发条件很简单:对方发了 FIN,本端协议栈回了 ACK,但应用进程一直没有调用 close() 关闭自己这一侧的 socket。此时连接就一直停在 CLOSE_WAIT。
大量 CLOSE_WAIT 堆积一般发生在这些场景:
- 业务代码在 try-catch 块里提前 return,没有走 finally 关闭资源;
- HTTP 长连接被对端关闭后,连接池没有检测到,还把已经失效的连接继续给业务使用;
- NIO 这类非阻塞模型中,没有在 OP_READ 返回 -1 时关闭 channel;
排查时直接用:
bash复制ss -antp | grep CLOSE_WAIT
然后根据输出的进程号和 fd 信息,去代码里找对应的 socket 没有释放的原因。
这台机器上的 CLOSE_WAIT 数量很多时,重启应用能暂时清掉,但只要那个泄漏路径还在,过一会儿又会堆回来。这不是调内核参数能解决的问题,唯一根治办法是把所有 socket 的释放路径梳理清楚。
4.4 参数调整的正确姿势:tw_reuse 可以用,tw_recycle 别碰
关于 TIME_WAIT 相关的参数,有一个必须单独拿出来说的坑:tcp_tw_recycle。
这个参数在较新版本内核中已经被移除,但它曾经坑过很多人。它的问题在于,开启后会粗暴地让内核基于时间戳快速回收 TIME_WAIT 连接,但在 NAT 环境下,同一公网 IP 后面有多个内网设备,各设备的时间戳进度不一致,内核误判并发送 RST,导致部分用户连接被无理由重置。
所以我一直坚持一个原则:tcp_tw_reuse 可以谨慎开启,tcp_tw_recycle 永远不要去碰。
另外,tcp_fin_timeout 这个参数控制 FIN_WAIT_2 状态的等待时间。如果对端迟迟不发 FIN,主动关闭方不可能一直等下去,默认 60 秒后内核会强制关闭。如果你的服务存在大量对端不配合关闭的连接,可以适当调小,但要确认对端确实不会再发数据。
| 状态 | 堆积原因 | 首要动作 |
|---|---|---|
| TIME_WAIT | 主动短连接太多 | 连接池复用,调整端口范围,谨慎开 tw_reuse |
| CLOSE_WAIT | 应用未 close() | 修业务代码,检查资源释放路径 |
| FIN_WAIT_2 | 对端不发 FIN 且本端未强制关闭 | 查找对端异常,调整 tcp_fin_timeout |
| SYN_RECV | 握手未完成、队列溢出 | 查半连接/全连接队列,开 syncookies |
5. 当连接被“静默”切断:半开连接与探活机制
5.1 半开连接:状态是 ESTABLISHED,人却已经不在
前面讲的都是正常建立、正常断开,但真正让线上生产事故变得诡异难查的,往往是连接被“静默”切断。
比如对端服务器突然断电、网线被拔、进程被杀没来得及发 FIN,或者中间网络设备静默清掉了会话。这种情况下,本端 TCP 协议栈并不知道连接已经死了,连接状态还是 ESTABLISHED,内核里一切正常。
这就是半开连接(half-open connection):本端觉得连接还活着,实际上对端早就消失了。
最危险的地方在于,如果业务长期没有数据交互,这条连接会一直占着文件描述符和内存,从任何常规监控上看都是“健康的”。等到下次请求通过连接池拿到这条连接时,数据发出去就石沉大海,直到应用层超时,才报出第一个错。
那晚的支付回调超时,追根溯源就是半开连接引发的连接池污染。
5.2 TCP KeepAlive 的默认行为与参数实验
很多人以为 TCP KeepAlive 是协议栈自动探测连接是否存活的功能,实际上它的默认参数保守到几乎没什么用。
Linux 下有三个关键参数:
bash复制net.ipv4.tcp_keepalive_time = 7200
net.ipv4.tcp_keepalive_intvl = 75
net.ipv4.tcp_keepalive_probes = 9
含义是:连接空闲 7200 秒(2小时)后,内核才开始发第一个 keepalive 探测包;如果探测包没有回应,每 75 秒再发一个;连续 9 次无响应,才判定连接失效。
算一下最坏情况:
text复制7200 + 75 * 9 = 7875秒 ≈ 2小时11分钟
一条连接从真正断开到被内核发现,默认最长要两个多小时。这对绝大多数互联网业务来说太迟钝了,尤其是很多请求超时时间只有几十秒。
可以通过 sysctl 调小,比如:
bash复制sysctl -w net.ipv4.tcp_keepalive_time=600
sysctl -w net.ipv4.tcp_keepalive_intvl=30
sysctl -w net.ipv4.tcp_keepalive_probes=3
如果只想对特定连接生效,就在应用里设置 socket 选项:
c复制int keepalive = 1;
int keepidle = 600;
int keepintvl = 30;
int keepcnt = 3;
setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl));
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));
即使这样调了,KeepAlive 也只能保证 TCP 协议栈这一层“能探测”,但它和业务要求的“对端是否还能正常处理请求”仍然不是一回事。
5.3 NAT/负载均衡的老化时间,才是真正的生死线
互联网链路里几乎不可能只有两台服务器直连,中间通常隔着四层负载均衡、NAT 网关、运营商设备等。这些中间设备为了节省资源,会给每个空闲会话设置一个老化时间,可能是几十秒,也可能是几分钟。
问题来了:中间设备老化时间通常远小于 TCP KeepAlive 默认的 2 小时。“中间设备把会话清掉”这件事,对两端来说都是无感的——设备不会再转发数据,也不会替两端发送 FIN 或 RST。
之后如果客户端继续在这条连接上发数据,会发生什么?
数据包经过中间设备时,设备发现会话表里没有对应的条目,可能会直接丢弃,或者回一个 RST。相当多的网络设备选择“静默丢弃”,于是客户端只能靠 TCP 重传把数据一次次发出去,直到重传耗尽内核参数里配置的次数,才得到连接已死的结果。
所以在公网链路或者经过 NAT 的生产环境中,解决半开连接最可靠的方式不是依赖系统默认的 KeepAlive,而是应用层心跳:定时在连接上发送一个小的、业务可识别的探活包,周期要小于中间设备的老化时间。很多团队直接取 30 秒到 60 秒。
5.4 回到事故现场:主动探活比调内核参数更有效
那晚的支付超时,最终定位结论是这样的:
低峰期时,我们到支付网关之间的连接长时间空闲,中间负载均衡设备上的会话老化,把连接清掉了。后续支付回调高峰来临,连接池里的旧连接被业务请求复用,数据包发出后没有响应,TCP 层一直重传。此时新的连接因为连接池的“创建上限”或“已有空闲连接”策略没有被建立,于是大量请求挂在那条僵死的连接上,表现为大面积超时,在服务端抓包看到的就是反复重传的旧连接数据。
解决方案分两步:
应急时,重启应用让连接池里的半开连接全部清空,恢复快,但只是临时的;
根治时,在应用层加了心跳探活,空闲连接超过一定时间就主动 close 重建;同时把连接池里空闲连接的淘汰时间调小,避免复用“超过 2 分钟没有成功通信”的连接。
这条经验后来陪我解决了很多“延迟到爆炸但抓包抓不到问题”的故障:先问一句,这条连接在故障发生前,是否有足够多的空闲时间?如果有,优先怀疑中间设备静默断链。
6. TCP连接问题排查工具箱:命令、参数与决策思路
6.1 ss 与 netstat 的关键输出解读
ss 现在已经完全取代 netstat 了,输出更全也更快。排查 TCP 连接问题,必须养成读 ss 的习惯。
先看状态汇总:
bash复制ss -s
再看连接明细:
bash复制ss -antp
-a 表示所有状态,-n 不解析名称,-t 只看 TCP,-p 显示进程信息。最后那一列进程名和 PID 在定位“这个连接属于哪个服务”时非常有用。
监听端口的队列情况用:
bash复制ss -lntp
Recv-Q 和 Send-Q 在 LISTEN 状态下分别表示 accept 队列当前积压数和上限。如果当前积压数长期接近上限,说明应用 accept 速度跟不上。
另一条容易忽略但很有用的命令是:
bash复制nstat -az | grep -i listen
它直接展示内核协议栈层面的溢出计数,是判断队列是否溢出的一手证据。
6.2 tcpdump 抓建立/断开过程的追踪模板
抓包是定位 TCP 连接问题的终极武器,比看任何监控都直观。我常用的模板:
抓建立过程:
bash复制tcpdump -nn -i eth0 tcp port 8080 -c 50
如果想分析握手和挥手细节,保存 pcap 文件用 Wireshark 打开:
bash复制tcpdump -nn -i eth0 'tcp[tcpflags] & (tcp-syn|tcp-fin|tcp-rst) != 0' -w tcp_events.pcap
看包的时候按这个思路来:
- 三次握手的三个包 SYN、SYN-ACK、ACK 齐不齐?不齐,缺在哪一段,问题就在哪一段;
- 是否存在大量 SYN 重传?如果有,说明 SYN-ACK 没回来,优先怀疑服务端队列或中间设备;
- 是否频繁出现 RST?RST 往往代表对端拒绝、端口未监听、或者协议栈主动销毁连接;
- FIN 是由客户端发起的还是服务端发起的?这决定你是主动关闭方还是被动关闭方,直接影响接下来要看 TIME_WAIT 还是 CLOSE_WAIT。
Wireshark 里过滤握手包可以直接用:
text复制tcp.flags.syn == 1
过滤连接重置则用:
text复制tcp.flags.reset == 1
6.3 从现象到根因的排查决策链
TCP 连接问题看起来千奇百怪,其实很容易收敛。我按经验给出一条决策链,遇到问题照着走:
第一步,确认连接到底建没建起来。看 ss -s,如果 SYN_RECV 异常,先查两个队列和 syncookies;如果 ESTABLISHED 很多但业务无响应,别急着查队列,先看是否存在半开连接。
第二步,确认连接有没有数据流动。用 ss -antp 看 Recv-Q 和 Send-Q,Recv-Q 堆积是应用读太慢,Send-Q 堆积是对端不读、链路丢包或中间设备丢连接。
第三步,确认断开阶段正不正常。看 TIME_WAIT 和 CLOSE_WAIT 的数量,前者看资源是否耗尽,后者看代码是否泄漏。
第四步,抓包定性。如果前面都是正常但故障仍然存在,用 tcpdump 抓一次完整交互,把所有包按时间排序,看到底是握手、数据还是挥手环节断的。
第五步,回看业务日志的时间线。TCP 层的问题最后一定会表现为请求超时、连接重置、连接池获取失败等业务现象,要把网络抓包时间点和业务异常时间点对上,才能确定因果关系。
6.4 几个经验之谈:别把默认参数当圣旨
做 TCP 连接调优这么多年,我发现最贵的一课是:默认参数是给“通用场景”用的,不是给你这台机器上运行的业务用的。
举个最常见的例子。内网服务之间是低延迟高带宽链路,TCP 初始拥塞窗口却依然按公网场景设置,导致一个大型响应要经过多个 RTT 才能传完。适当调整 initcwnd 可以明显提升内网大包传输效率,前提是你清楚自己的网络环境。
再比如,很多人一看到 TIME_WAIT 多就开 tw_reuse,但真正的问题是应用层短连接过多。开了参数只是延缓端口枯竭,业务量再涨一点还是会出问题。不如直接改连接池,一劳永逸。
还有一些问题根本不是参数能解决的。链路丢包、中间设备老化、应用不释放 socket,这些靠调 sysctl 是调不出来的,必须落到架构和代码层面。
最后分享一个我自己的习惯:日常巡检时除了看 CPU、内存、磁盘,还会把 ss -s 的三组数字—estab、synrecv、timewait—记下来,每天和前一天对比。连接问题很少是瞬间爆发的,大部分都有前兆,只是太多人习惯在故障时才开始看连接状态,错过了提前发现的机会。TCP 连接本身不难理解,难的是从一堆现象里定位它卡在哪个状态、哪个环节。把每个状态和它背后的资源消耗、代码路径对上号,线上再出网络问题,你也能像我那晚一样,先看状态,再抓包,一步步把根因挖出来。
