三次握手排障实战:从幽灵故障到全连接队列溢出

发布时间:2026/8/29 22:51:54
三次握手排障实战:从幽灵故障到全连接队列溢出 凌晨两点二十三分告警群突然炸了。某核心服务的健康检查连续失败上游调用方报超时网关层出现大量 5xx。我登录跳板机的时候心里还在打鼓进程在不在端口有没有监听负载是不是又被打满了结果一查进程活着端口也在听CPU 和内存都波澜不惊可就是有客户端连不进来过了几分钟又自己恢复了。这种“幽灵故障”最折磨人。当时我盯着屏幕上的 netstat 输出脑子里转了一圈TCP 连接建立不了说明三次握手都走不完。而三次握手走不完翻来覆去就那几个原因——握手包丢了、内核队列满了、应用层 accept 不过来。就是这套被很多人当成“八股文”背下来的知识最后帮我在十分钟内定位到了根因。这篇文章就聊聊那次的排查过程以及三次握手里面真正能救命的细节。1. 三次握手不只是一段背诵材料它是一张排障地图很多人觉得三次握手就是“SYN、SYN-ACK、ACK”六个字母面试前背一背就过去了。但线上出了事故你回头看每个阶段的状态和现象会发现它就是一张现成的排障地图——握手走了哪一步、卡在哪一步对应的问题域完全不同。1.1 握手每一步背后的真实动作三次握手的本质是通信双方在正式传数据之前先做一次“能力协商”和“序列号同步”。我习惯把这三步拆成下面这么看第一次握手客户端发送 SYN 报文随机一个初始序列号 client_isn同时把 SYN 标志位置 1。这一步是客户端在说我想建立连接我从这个序号开始编号。第二次握手服务端收到 SYN 后如果愿意建立连接会回复 SYN-ACK 报文。它也在报文里带上自己的初始序列号 server_isn同时用 ACK 号确认客户端的序列号ack client_isn 1。这一步是服务端在说我收到了你的请求我也准备好了我的起始序号是 xxx。第三次握手客户端收到 SYN-ACK 后再回一个 ACK 报文确认服务端的序列号ack server_isn 1。这一步是客户端在说我收到了你的确认我们开始吧。注意一个细节第二次握手同时携带了 SYN 和 ACK 两个标志。很多人抓包的时候看到 SYN,ACK 两个标志同时置位以为这是两个报文其实这是一个报文。1.2 为什么必须是三次而不是两次或四次这个问题看起来像纯理论但线上真的有对应的场景。假设我们只握手两次客户端发出的 SYN 报文在网络里堵了一会儿客户端等不及重传了一个 SYN服务端收到重传的 SYN 后建立了连接很快又收到第一个迟到的 SYN于是又建立了一个新连接。两次握手的情况下服务端无法区分这个重复的 SYN 是不是上一次连接的残留只能默默再开一个连接造成资源浪费。三次握手就不一样——第三次 ACK 是客户端对服务端 SYN 的确认如果服务端收到迟到的 SYN 并回复了 SYN-ACK但客户端早就没有这个连接了自然不会回 ACK服务端等不到确认就会放弃这个半开连接。至于为什么不是四次其实三次已经达到了双向确认的目标。客户端确认了服务端的收发能力服务端也确认了客户端的收发能力再增加一次纯粹是浪费往返时间。1.3 握手过程中藏着的两个核心队列这是最容易忽略、但线上事故最常出现的点。Linux 内核在处理三次握手的过程中为每个连接准备了两个队列半连接队列SYN Queue服务端收到 SYN 后连接进入 SYN_RCVD 状态此时连接对象放在半连接队列里等待第三次 ACK 确认。这个队列的长度内核参数是 net.ipv4.tcp_max_syn_backlog部分场景下还跟 somaxconn 有关。全连接队列Accept Queue第三次握手完成连接进入 ESTABLISHED 状态但还没被应用层 accept() 取走时连接放在全连接队列里。这个队列的长度上限取决于应用调用 listen(fd, backlog) 时传入的 backlog同时受内核参数 net.core.somaxconn 的封顶限制。两个队列都有限额满了之后新连接就会异常。判断“满了没”线上最常用的命令是 ss -lnt看 Recv-Q 和 Send-Q 两列。对于 LISTEN 状态下的 socketRecv-Q 表示当前全连接队列里等待 accept 的连接数Send-Q 表示全连接队列的最大长度。如果 Recv-Q 长时间等于 Send-Q说明队列已经满了新来的握手就会被内核直接丢弃。把这两个队列记在心里后面排查事故的时候基本就是按图索骥。2. 线上事故复盘三次握手卡在第二步回到开头那场事故。告警是健康检查失败健康检查本质上就是发起一个 TCP 连接到指定端口。既然进程活着、端口在听、CPU 和内存都正常那问题大概率就出在内核协议栈的握手处理上。2.1 抓包确认卡点到底在哪我的排查习惯是先用 tcpdump 抓包看协议栈的实际行为而不是盲目重启服务。在故障机执行了这么一条命令tcpdump -i eth0 -nn tcp port 8080 and (tcp[tcpflags] (tcp-syn|tcp-ack) ! 0) -w /tmp/handshake.pcap这个过滤条件把只跟握手相关的 SYN、ACK 报文留下来。故障持续期间抓了几分钟然后慢慢分析。看到的典型现象是这样的13:41:02.331211 IP 10.0.3.15.52001 10.0.1.20.8080: Flags [S], seq 2057338211 13:41:03.331552 IP 10.0.3.15.52001 10.0.1.20.8080: Flags [S], seq 2057338211 13:41:05.332108 IP 10.0.3.15.52001 10.0.1.20.8080: Flags [S], seq 2057338211客户端在反复重传 SYN服务端始终没有回 SYN-ACK。这个现象直接告诉我们握手请求到了服务端但服务端内核没有完成第二次握手。当时我下意识看了一眼服务端抓包发现 SYN 报文确实到达了网卡说明包没有被防火墙丢掉是内核协议栈在处理时出了问题。2.2 队列溢出第一时间看 ss 和内核计数既然 SYN 到了内核却回不了 SYN-ACK最可能的嫌疑就是半连接队列满了或者 SYN 被某种限流逻辑丢弃。我执行了ss -lnt sport :8080 nstat -az | grep -i listenss 输出里 Recv-Q 和 Send-Q 都是 128而且 Recv-Q 长时间顶着上限。这里要解释一下对于 LISTEN 状态的 socket这个输出反映的是全连接队列的状态。全连接队列满的情况下内核丢弃的是第三次握手的 ACK 报文表现出来也会是客户端一直重传 SYN——因为客户端视角中连接始终没有建立成功。另外 nstat 里的 ListenOverflows 和 ListenDrops 两个计数能更直接地看到内核丢了多少握手包。那次故障机器上 ListenOverflows 的数值高得离谱说明丢包一直在发生只是之前连接数没到临界值所以没有暴露。2.3 为什么队列会满对症才能下药查到这个层面问题已经从“网络不通”缩小到了“应用层 accept 太慢”。我再往下看发现是服务端某个线程池卡住了具体原因是依赖的下游 Redis 出现毛刺导致一批请求处理时间过长线程池被占满新的连接虽然完成了握手却没有人调用 accept() 把它们取走。全连接队列被占满之后后续的握手请求就被内核丢弃健康检查自然失败。这个案例特别典型三次握手本身没有坏是握手之后的“取件”环节堵住了。如果对握手队列没有概念很容易陷入“重启大法好”的循环里或者花几个小时去排查网络设备、安全组、防火墙方向完全跑偏。2.4 临时缓解与根治当时为了快速恢复临时把全连接队列调大了一些给应用层争取了处理时间sysctl -w net.core.somaxconn1024应用代码里 listen 的 backlog 参数同步调整到位后队列容量从 128 变成了 1024。这个操作只是缓解并没有解决线程池被占满的问题。真正的根治动作分了两步给下游 Redis 调用加上超时熔断避免单个依赖抖动拖垮整个线程池调整线程池大小和任务队列策略让请求在排队时先超时失败而不是无限制地积压。这轮事故让我彻底认识到三次握手不是面试题而是线上排障的“第一性原理”。3. 排障工具箱把三次握手知识落成可执行命令说真的纸上谈兵谁都会真到现场手忙脚乱的大有人在。下面这套是我自己常用的排查步骤每一步都对应三次握手中的一个环节操作起来不复杂但非常管用。3.1 抓包定位单手筛选握手报文tcpdump 是排查握手问题最直接的武器。我常用的过滤组合有这几个# 抓所有到达 8080 端口的 TCP 握手相关报文 tcpdump -i any -nn tcp port 8080 and (tcp-syn or tcp-ack) # 只看 SYN 报文 tcpdump -i any -nn tcp port 8080 and tcp-syn and not tcp-ack # 只看 SYN-ACK 报文 tcpdump -i any -nn tcp port 8080 and tcp-syn and tcp-ack # 只看纯 ACK第三次握手 tcpdump -i any -nn tcp port 8080 and tcp-ack and not tcp-syn抓包的时候有个细节要注意一定要在服务端抓也要在客户端抓。只抓一端有时候会误判。比如客户端重传 SYN服务端没回 SYN-ACK如果你只在客户端抓包会以为是服务端没收到但如果到服务端一看SYN 报文根本没到网卡那问题就出在中间链路或者防火墙身上。另一个常见的小技巧是使用 -c 参数控制抓包数量或者 -w 落盘后拿回本地慢慢分析避免在现场用肉眼盯刷屏。3.2 看状态机netstat 和 ss 的正确打开方式排查连接状态是基本功。我一般用 ss 而不是 netstat因为 ss 直接读内核信息速度快输出也更容易理解# 查看某个端口所有连接的完整状态 ss -antp sport :8080 # 统计各种 TCP 状态的数量 ss -ant | awk {print $1} | sort | uniq -c # 重点关注 SYN_RCVD、ESTABLISHED、CLOSE_WAIT 的数量出现大量 SYN_RCVD 的时候大概率是半连接队列满或者遭受了 SYN 攻击出现大量 CLOSE_WAIT 的时候大概率是应用层没有正确关闭 socket这个虽然属于四次挥手的范畴但跟握手一样是线上最常见的问题点。3.3 内核计数和参数量化和调整两手抓除了抓包和状态机内核协议栈自己会记录一些关键事件通过 nstat 可以直接读取nstat -az | grep -iE listen|syn|retrans|drop重点关注ListenOverflows 和 ListenDrops全连接队列溢出次数对应三次握手 ACK 被丢弃的情况。SyncookiesSent半连接队列满之后内核触发 SYN Cookie 机制的次数等于说正常的半连接队列已经装不下了。TCPRetransFail、TCPTimeouts连接超时和重传失败通常意味着网络丢包严重或对端异常。如果确认是队列问题常用的内核参数调整有这几个参数作用建议net.core.somaxconn全连接队列上限的全局封顶值默认 128高并发服务建议至少 1024net.ipv4.tcp_max_syn_backlog半连接队列上限默认 1024 或 2048视 SYN 到达速率调整net.ipv4.tcp_syn_retries客户端 SYN 重传次数默认 6内网环境建议调小加速失败感知net.ipv4.tcp_synack_retries服务端 SYN-ACK 重传次数默认 5可根据网络情况调整需要提醒一句盲目调大参数不是万能的。队列调大只是给应用层争取处理时间如果应用本身不 accept队列再大也只是延迟爆炸。4. 从三次握手延伸出去的线上问题清单每次聊三次握手我都会顺带把周边相关的问题也串一遍。因为线上事故从来不是严格按照教科书分类出现的很多时候是握手、挥手、重传、保活几个问题交叉在一起。4.1 握手包丢了SYN 重传怎么看客户端发出 SYN 后如果迟迟收不到 SYN-ACK内核会按照指数退避策略重传 SYN。tcp_syn_retries 控制重传次数默认 6 次总耗时可能超过一分钟。也就是说一个握手失败的业务请求客户端可能会傻等一分多钟才报错。所以内网服务之间调用我通常会把 tcp_syn_retries 调小到 3 左右加快失败感知速度让上层负载均衡更快把流量切到健康节点。4.2 半连接队列满了SYN Cookie 是保护机制半连接队列满的时候如果内核开启了 tcp_syncookies默认开启会临时启用 SYN Cookie 机制不把连接放进半连接队列而是通过加密计算生成一个 Cookie 放到 SYN-ACK 里返回。客户端回 ACK 的时候带上这个 Cookie服务端验证通过就直接建立连接。注意SYN Cookie 只能缓解 SYN 洪水对正常业务连接来说一旦触发了 SYN Cookie说明半连接队列设计不合理或者真的有异常流量。这时候去翻 nstat 里的 SyncookiesSent 计数能帮你快速确认。4.3 抓包看到 acked unseen segment 是什么鬼有时候抓包会看到 TCP Dup ACK、TCP ACKed unseen segment 这样的提示。这通常意味着报文乱序或抓包点不在收发两端只能看到一部分报文。比如你在中间交换机上抓包SYN 和 ACK 走的路径不一致就可能出现“ACK 确认了一个抓包里没见过的序号”。遇到这种提示不要急着怀疑内核先确认抓包位置和分析工具是否完整。我当时处理过类似的误判在镜像口抓包看到一堆 ACKed unseen segment差点误判为内核协议栈 bug最后才发现是镜像流量过滤掉了部分报文。4.4 CLOSE_WAIT 和四次挥手另一个线上大户三次握手之外四次挥手也是线上事故的高发区。最常见的现象是服务端出现大量 CLOSE_WAIT 状态的连接本质是服务端收到了客户端的 FIN但应用层没有调用 close() 关闭 socket。连接一直不释放文件描述符被耗尽新连接就无法建立。排查方法很简单ss -antp | grep CLOSE_WAIT | head -20看到大量 CLOSE_WAIT直接查代码里 socket 关闭路径。常见的坑有异常分支忘记 close、HTTP keep-alive 连接长期占用、线程池拒绝任务后连接未释放。4.5 连接被中间设备静默切断TCP Keepalive 的必要性还有一种事故看起来像是握手问题其实是空闲连接被中间设备比如云平台的 NAT 网关、负载均衡器静默回收了。客户端以为自己还连着发数据时才发现连接早就没了表现为对端没有任何响应然后触发 TCP 重传。解决思路是开启 TCP Keepalive 机制让内核定期发送探测包维持连接的活性。实际配置时可以调整三个参数sysctl -w net.ipv4.tcp_keepalive_time600 sysctl -w net.ipv4.tcp_keepalive_intvl30 sysctl -w net.ipv4.tcp_keepalive_probes3这样空闲 600 秒后开始探测每 30 秒探一次连续 3 次没回应就断开连接。应用层自己实现心跳的话效果更好因为 keepalive 默认走内核协议栈粒度比较粗。5. 常见问题速查表与排障心得下面把线上跟三次握手相关的常见问题整理成一张速查表方便遇到问题的时候快速对照。这张表是我自己在实践中一点点攒下来的不一定覆盖所有场景但解决大概率问题绰绰有余。现象可能原因排查命令处理思路客户端不断重传 SYN服务端无 SYN-ACK半连接队列满、防火墙丢包、服务端未监听tcpdump 双向抓包、ss 看 SYN_RCVD、nstat 看 ListenDrops调大 tcp_max_syn_backlog、排查防火墙策略、确认服务监听正常服务端回 SYN-ACK客户端不回 ACK客户端半连接状态被丢弃、客户端本地 backlog 满抓包确认 ACK 是否发出、看客户端 ListenOverflows检查客户端状态调整客户端相关队列参数全连接队列满连接无法建立accept 没被调用、backlog 太小ss -lnt 看 Recv-Q 与 Send-Q、nstat 看 ListenOverflows调大 backlog、优化应用线程池、排查应用阻塞出现大量 SYN_RCVD握手无法完成、可能被 SYN 攻击ss -ant 统计状态开 syncookies、限制单 IP SYN 速率、检查业务流量出现大量 TIME_WAIT主动关闭连接的一方、短连接业务ss -ant 统计状态调整 tcp_tw_reuse 内核参数、业务层面复用连接大量 CLOSE_WAIT应用未正确关闭 socketss -antp 看进程和连接修代码、确保所有返回路径都释放连接握手测试用 telnet 能通但业务报超时握手没问题问题在协议交互或应用层telnet 成功后发一个简单请求看响应把排查重点从传输层转向应用层另外分享几个实战中踩过的坑第一不要只看客户端状态。客户端连接一直 SYN_SENT就以为服务端有问题结果跑到服务端一看SYN 包根本没到。中间链路把你全套带偏了。第二ss 输出里的 Recv-Q 和 Send-Q 在 LISTEN 状态下的含义和非 LISTEN 状态完全不同。很多人习惯性地拿“Send-Q 是发送缓冲区余量”去套 LISTEN 状态直接理解反了。LISTEN 状态下这两列分别表示全连接队列当前长度和最大长度这是判断是否溢出的关键。第三调整内核参数之后记得确认应用层的 backlog 参数。listen(fd, backlog) 里的 backlog 才是全连接队列的主要决定因素net.core.somaxconn 只是封顶。只调系统参数不调应用参数白忙活一场。第四遇到连接问题先抓包再改配置。我在刚开始排查的时候经常凭直觉先调参数结果方向错了反而把现场搞乱了。抓包只需要几秒钟但它能直接告诉你卡在哪一步省掉的排查时间是以小时计的。6. 写在最后那次线上事故之后我重新理解了“八股文”这三个字。三次握手的知识确实老套面试官爱问大家也爱背但真正到了凌晨两点的告警群里能让你稳住心态、按图索骥的恰恰是这些最基础的东西。TCP 协议栈很复杂队列、状态机、重传、拥塞控制、保活机制环环相扣但每一次连接建立的起点就是那三次看起来不起眼的握手。把这个起点彻底吃透很多故障在你眼里就不是一团迷雾而是一条清晰的路先看走到哪一步再看卡在哪一步然后对症处理。最后再分享一个实用的小习惯我习惯在每台服务器上放一个固定的排障脚本里面就是 tcpdump 常用抓包命令、ss 状态统计、nstat 关键计数这几个东西的封装。出了事儿登录上去一条命令跑完先把现场数据收集下来再开始分析。这套流程帮我处理过不少“偶发”“幽灵”类问题也推荐给你试试。