半夜两点被值班电话吵醒,那个月的第二次了。支付回调大面积超时,用户卡在“支付中”出不来,技术群里已经开始有合作方找过来问情况。我登上机器后没有立刻看业务日志,而是先跑了三条命令: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 |
半连接队列满时的一种保护机制 |
| 全连接队列满时的行为 |
