TCP三次握手第三次ACK丢失:状态机、重传与半连接队列深度解析

📅 发布时间:2026/10/20 16:04:08
TCP三次握手第三次ACK丢失:状态机、重传与半连接队列深度解析
1. 从一次线上超时说起为什么“第三次握手丢包”值得单独拎出来讲很多人第一次接触TCP三次握手都是在教科书或者面试八股里。标准答案背得滚瓜烂熟客户端发SYN服务端回SYNACK客户端再回一个ACK连接建立。然后呢然后就没有然后了。教科书不会告诉你如果这最后一步的ACK在网络上丢了接下来会发生什么。我最早被这个问题问住是在一次线上故障复盘会上。当时有个服务偶发连接超时抓包看下来客户端明明已经发了第三次握手的ACK服务端却像没收到一样反复重传SYNACK最后客户端直接RST把连接掐了。当时团队里几个人争论了半天有人说“ACK丢了就丢了反正连接已经建立了”有人说“服务端没收到ACK就不会认为连接建立”。这两种说法其实都对了一半但都没说到点子上。这个问题的价值在于它逼着你去理解TCP一个非常核心的设计哲学连接状态是双方各自维护的不是共享的。客户端和服务端对“连接是否建立”这件事判断依据完全不同。第三次握手的ACK丢失恰恰是暴露这个不对称性的最佳案例。搞懂它你对TCP的理解会从“背流程”进阶到“懂状态机”。这篇文章适合谁看如果你已经知道三次握手的基本流程但对“丢包之后会怎样”“为什么有的场景下连接看起来建立了却用不了”“SYN Flood和这个有什么关系”这些问题还模棱两可那这篇就是写给你的。我会从状态机、重传机制、实际抓包、内核参数几个层面把它拆透最后给出几种真实场景下的应对思路。2. 第三次握手ACK丢失后两端到底各自在想什么2.1 客户端我任务完成了进入ESTABLISHED先站在客户端视角。客户端发送第三次握手的ACK之后它的TCP状态机立刻从SYN_SENT切换到ESTABLISHED。注意这个切换是本地行为它不需要等待任何来自服务端的确认。也就是说从客户端内核的角度看连接已经建立完毕可以开始发送数据了。这一点非常关键。很多人误以为“三次握手要全部走完双方才进入ESTABLISHED”实际上客户端在发出ACK的那一刻就认为自己连上了。如果此时应用程序调用send()数据会正常进入发送缓冲区然后被封装成PSHACK报文发出去。那如果这个ACK真的丢了呢客户端并不知道。它不会因为“ACK可能丢了”而做任何特殊处理因为TCP的可靠传输是靠数据报文本身的确认机制来保证的而不是靠握手ACK。换句话说客户端接下来发的第一个数据报文其实就顺带承担了“确认连接可用”的角色。2.2 服务端我还在SYN_RCVD等着那个ACK再看服务端。服务端发出SYNACK之后状态从LISTEN切换到SYN_RCVD有些实现里叫SYN_RECEIVED。在这个状态下服务端已经为这个连接分配了资源——内核里有一块叫“半连接队列”或者“SYN队列”的东西专门存这些还没完全建立的连接。服务端在SYN_RCVD状态等的是什么就是那个第三次握手的ACK。如果等不到它会认为自己的SYNACK可能丢了于是触发超时重传。重传的不是SYN而是SYNACK。每次重传的间隔会翻倍具体次数由内核参数决定。这里有个容易混淆的点服务端重传SYNACK客户端收到之后会怎么反应客户端此时已经是ESTABLISHED状态了收到一个SYNACK它会觉得“这啥情况我都连上了你还给我发SYNACK”按照RFC规范客户端应该回一个RST或者如果它认为这是个重复的旧报文可能直接忽略。实际行为取决于具体实现和报文序号。2.3 一个关键的不对称谁先能发数据把两端的状态放在一起看就出现了明显的不对称角色发出第三次ACK后状态能否立即发数据对连接建立的判断依据客户端ESTABLISHED能自己发了ACK就算建立服务端SYN_RCVD不能accept未返回必须收到ACK才算建立这个不对称带来一个很实际的后果客户端可能已经开始发数据了服务端还在傻等ACK。如果客户端发的数据报文先到了服务端服务端会怎么处理它会检查这个报文的ACK标志和序号。如果序号在预期范围内服务端会认为“哦虽然我没收到纯ACK但这个数据报文确认了我的SYN那连接就算建立了”于是状态切换到ESTABLISHED把连接移入全连接队列等待accept。所以你看第三次握手的ACK丢失并不一定导致连接失败。如果客户端紧接着发了数据这个数据报文可以“顺便”完成握手确认。真正会出问题的是客户端发完ACK之后迟迟不发数据而服务端的重传又一直等不到响应的情况。3. 服务端重传SYNACK的完整时间线拆解3.1 重传次数和间隔由谁决定服务端在SYN_RCVD状态下等待ACK这个等待不是无限期的。Linux内核里有两个关键参数控制这个行为net.ipv4.tcp_synack_retries控制SYNACK的重传次数默认值是5。重传间隔采用指数退避初始RTO重传超时大约是1秒然后2秒、4秒、8秒、16秒这样翻倍。按默认值5次重传算下来总等待时间大约是12481631秒加上最后一次等待差不多要30多秒服务端才会放弃这个半连接。这个时间窗口相当长足够客户端做很多事情了。你可以用下面的命令查看当前系统的设置# 查看SYNACK重传次数 cat /proc/sys/net/ipv4/tcp_synack_retries # 查看半连接队列相关参数 cat /proc/sys/net/ipv4/tcp_max_syn_backlog cat /proc/sys/net/core/somaxconn3.2 每次重传时服务端的状态变化服务端并不是简单地“重发同一个包”。每次重传SYNACK它内部的计时器和计数器都在变化。如果重传次数达到上限还没收到ACK服务端会直接销毁这个半连接释放资源。此时如果客户端再发数据过来服务端会回一个RST因为它在自己的连接表里已经找不到这个连接了。这里有个细节值得注意服务端重传SYNACK时SYN序号和之前是一样的但ACK序号确认客户端的SYN也保持不变。客户端如果此时处于ESTABLISHED收到这个重复的SYNACK不同内核的处理略有差异。较新的Linux内核会检查这个SYNACK是否属于一个已知的连接如果是可能会忽略或者回RST。3.3 抓包验证一次真实的丢包重传过程光说理论不够直观我们来看一次实际抓包的简化版。假设客户端IP是10.0.0.1服务端是10.0.0.2端口80。No. Time Source Destination Protocol Info 1 0.000000 10.0.0.1 10.0.0.2 TCP 54321 → 80 [SYN] Seq0 2 0.000200 10.0.0.2 10.0.0.1 TCP 80 → 54321 [SYN, ACK] Seq0 Ack1 3 0.000300 10.0.0.1 10.0.0.2 TCP 54321 → 80 [ACK] Seq1 Ack1 ← 这个ACK丢了 4 1.000500 10.0.0.2 10.0.0.1 TCP [TCP Retransmission] 80 → 54321 [SYN, ACK] Seq0 Ack1 5 3.001200 10.0.0.2 10.0.0.1 TCP [TCP Retransmission] 80 → 54321 [SYN, ACK] Seq0 Ack1 6 7.002800 10.0.0.2 10.0.0.1 TCP [TCP Retransmission] 80 → 54321 [SYN, ACK] Seq0 Ack1你可以看到第3个包之后服务端在第1秒、第3秒、第7秒分别重传了SYNACK间隔正好是1、2、4秒的指数退避。如果客户端一直没响应这个重传会继续到第5次然后服务端放弃。提示用tcpdump抓包时加上-S参数可以显示绝对序号方便对照。命令示例tcpdump -i eth0 -S -nn tcp port 80 and host 10.0.0.2。4. 客户端收到重复SYNACK时的几种真实反应4.1 情况一客户端已ESTABLISHED且无数据要发这是最“安静”的情况。客户端发完ACK后进入ESTABLISHED但应用程序还没调用send()所以没有数据报文。此时服务端重传的SYNACK到达客户端客户端内核会怎么处理在Linux上如果客户端处于ESTABLISHED状态收到一个SYNACK它会检查这个报文的四元组是否匹配已有连接。如果匹配且序号看起来是重复的旧报文内核通常会丢弃这个报文不做任何响应。它不会回RST因为回RST会把自己已经建立的连接也干掉这显然不合理。于是局面就僵住了客户端觉得自己连上了服务端还在重传。直到服务端重传次数耗尽销毁半连接。之后客户端如果发数据服务端回RST客户端才发现“咦连接怎么断了”。4.2 情况二客户端已ESTABLISHED且立即发了数据这是最常见的“自愈”情况。客户端发完ACK后马上调用send()发了一个HTTP请求。这个数据报文带着ACK标志到达服务端服务端一看序号对得上立刻把连接状态推进到ESTABLISHED移入全连接队列。握手实际上通过数据报文“补完”了。这也是为什么很多真实场景下第三次握手ACK丢失并不会导致明显故障——因为客户端通常不会干等着它会立刻发数据。数据报文本身就是最好的确认。4.3 情况三客户端还没收到SYNACK就超时重传了SYN还有一种情况是客户端侧的SYN丢了或者SYNACK丢了导致客户端根本没进入ESTABLISHED。这时候客户端会重传SYN。如果服务端此时在SYN_RCVD状态收到重复的SYN它会重传SYNACK。这种交互会持续到某一方放弃。这种情况和“第三次ACK丢失”容易混淆但本质不同。区分方法是看客户端状态如果客户端已经发过ACK那它一定是ESTABLISHED如果客户端还在重传SYN那它还在SYN_SENT。4.4 用ss命令观察两端状态差异在实际排查时ss命令比netstat更快更清晰。你可以在两端分别执行# 查看所有TCP连接状态 ss -tan # 只看SYN_RCVD状态的连接服务端 ss -tan state syn-recv # 统计各状态连接数 ss -s如果服务端上看到大量SYN_RCVD而客户端对应连接已经是ESTABLISHED那基本可以确定是第三次握手ACK丢失或者被中间设备拦截了。5. 半连接队列溢出ACK丢失被放大后的连锁反应5.1 半连接队列和全连接队列的区别要理解ACK丢失的严重后果必须搞清楚两个队列半连接队列SYN Queue存放收到SYN但还没收到第三次ACK的连接状态是SYN_RCVD。全连接队列Accept Queue存放已经完成三次握手、等待应用程序accept()的连接状态是ESTABLISHED。服务端收到SYN后先在半连接队列里占个位置。收到第三次ACK后移到全连接队列。如果半连接队列满了新的SYN会被丢弃或者在某些内核版本里直接回RST。5.2 当大量ACK丢失时会发生什么假设服务端遭遇了某种网络异常导致大量第三次握手的ACK丢失。那么半连接队列里会堆积大量SYN_RCVD状态的连接。这些连接每个都要占用内存而且会触发重传。如果攻击者故意只发SYN不回ACK这就是经典的SYN Flood攻击原理。即使不是攻击正常的网络抖动导致ACK批量丢失也会让半连接队列迅速膨胀。一旦队列满了新的正常连接请求就会被拒绝表现为“服务端明明没挂但新连接就是连不上”。5.3 相关内核参数的调优思路针对这种情况Linux提供了一些防护和调优参数# 开启SYN Cookies当半连接队列满时用cookie机制代替队列 net.ipv4.tcp_syncookies 1 # 半连接队列最大长度 net.ipv4.tcp_max_syn_backlog 8192 # 全连接队列最大长度也受listen的backlog参数影响 net.core.somaxconn 65535 # SYNACK重传次数适当降低可以加快半连接释放 net.ipv4.tcp_synack_retries 3SYN Cookies的原理值得多说一句当半连接队列满时服务端不再为每个SYN分配资源而是把连接信息编码进自己的SYNACK序号里。等客户端回ACK时服务端从ACK序号里解码出连接信息直接建立连接。这样就不需要维护半连接队列了。代价是SYNACK里不能携带某些TCP选项比如窗口缩放但对大多数场景影响不大。注意tcp_syncookies是防护手段不是常规优化手段。正常情况下应该保证半连接队列足够大而不是依赖syncookies。6. 中间设备插一脚NAT、防火墙和负载均衡的影响6.1 NAT超时导致ACK被丢弃很多客户端位于NAT设备后面。NAT设备维护一张映射表把内网地址端口映射到公网地址端口。这张表是有超时时间的。如果客户端发完SYN后NAT映射因为某种原因提前失效那么服务端重传的SYNACK到达NAT时NAT找不到对应的内网主机就会丢弃这个包。客户端自然收不到重传也就不会响应。更隐蔽的情况是客户端发的第三次ACK在NAT设备上被丢弃了因为NAT映射表项刚好在这一刻过期。这种问题极难排查因为两端抓包都看不到异常。6.2 防火墙的“半连接”策略有些防火墙会对TCP连接做状态检测。它看到SYN和SYNACK但没看到第三次ACK可能就认为这个连接不完整主动阻断后续报文。或者防火墙自身维护连接表表项超时时间比TCP握手时间长导致ACK到达时表项已删除。这类问题的典型表现是内网直连没问题一过防火墙就偶发连接失败。排查时可以在防火墙两侧同时抓包对比ACK是否真的通过了。6.3 负载均衡器背后的真实服务端在负载均衡场景下客户端其实是和负载均衡器完成三次握手负载均衡器再和后端服务端建立连接。如果负载均衡器采用“延迟绑定”策略等收到客户端数据后才选后端那么第三次ACK丢失对客户端来说可能完全无感因为负载均衡器会自己处理重传。但如果负载均衡器采用“立即绑定”策略第三次ACK丢失可能导致负载均衡器和后端之间的连接状态不一致。这类问题通常需要看负载均衡器自身的连接跟踪表。7. 应用层如何感知和应对这种“隐形故障”7.1 连接超时设置是第一道防线应用程序在调用connect()时应该设置合理的超时时间。如果第三次ACK丢失且客户端没有立即发数据connect()可能会一直阻塞到服务端重传耗尽。默认的TCP连接超时可能长达几十秒甚至几分钟这对用户体验是灾难性的。在Linux上可以用非阻塞socket配合select/poll/epoll来实现自定义超时int sockfd socket(AF_INET, SOCK_STREAM, 0); // 设置为非阻塞 fcntl(sockfd, F_SETFL, O_NONBLOCK); int ret connect(sockfd, ...); if (ret 0 errno EINPROGRESS) { // 用select等待可写设置超时时间 fd_set wfds; FD_ZERO(wfds); FD_SET(sockfd, wfds); struct timeval tv {5, 0}; // 5秒超时 ret select(sockfd 1, NULL, wfds, NULL, tv); if (ret 0) { // 超时或出错关闭连接 close(sockfd); } }7.2 应用层心跳和重试策略对于长连接场景应用层心跳是检测“假死连接”的有效手段。如果第三次ACK丢失导致服务端最终销毁了连接而客户端还以为连接存在心跳报文发过去会收到RST客户端就能及时发现并重连。重试策略上建议采用指数退避加随机抖动避免大量客户端同时重试造成雪崩。比如第一次重试等1秒第二次等2秒第三次等4秒每次加一个0到1秒的随机值。7.3 日志和监控中应该关注什么在监控层面除了关注连接数、成功率还应该特别关注服务端SYN_RCVD状态的连接数突增客户端connect()耗时P99指标TCP重传率可以通过netstat -s里的重传计数计算半连接队列溢出计数netstat -s | grep -i SYNs to LISTEN这些指标能帮你在用户投诉之前就发现第三次握手层面的异常。8. 几个容易踩的认知误区和实操建议8.1 误区一“ACK丢了连接就建不起来”这是最常见的误解。实际上只要客户端后续发了数据连接大概率能建立。真正建不起来的情况是客户端发完ACK后长时间不发数据同时服务端重传耗尽。所以“ACK丢失”本身不是致命问题“ACK丢失客户端沉默”才是。8.2 误区二“服务端重传SYNACK时客户端会回RST”这个说法在特定条件下成立但不是普遍规律。现代Linux内核在ESTABLISHED状态下收到重复SYNACK更倾向于忽略而不是回RST。回RST反而会破坏已经建立的连接不符合TCP的保守原则。8.3 实操建议抓包时同时抓两端排查这类问题最有效的方法是在客户端和服务端同时抓包然后对比。重点看客户端是否真的发出了第三次ACK有时候是网卡驱动或虚拟化层丢的服务端是否收到了这个ACK服务端重传的SYNACK客户端是否收到客户端收到重传后做了什么响应只有两端对照才能定位丢包发生在哪个环节。8.4 一个实用技巧用hping3模拟ACK丢失如果你想在测试环境复现这个问题可以用hping3构造只发SYN不回ACK的场景# 发送SYN但不完成握手模拟半连接 hping3 -S -p 80 -c 100 --rand-source 10.0.0.2然后在服务端用ss -tan state syn-recv观察半连接堆积情况。这能帮你验证服务端的半连接队列配置是否合理。8.5 关于tcp_synack_retries的取舍降低tcp_synack_retries可以加快半连接释放但也会让正常但慢速的客户端更容易失败。我的经验是内网环境可以设小一点比如3公网环境保持默认5或者稍微调大。如果服务端面向的是移动网络用户网络抖动大重传次数太少反而会增加连接失败率。9. 从协议细节回到工程判断把第三次握手ACK丢失这件事拆到最后你会发现它考验的不是你对某个RFC条文的记忆而是你对TCP状态机“各自为政”这个设计哲学的理解。客户端和服务端就像两个各自记账的会计他们通过报文对账但账本从来不共享。ACK丢失就是一次对账失败但只要后续有数据往来账就能对上。我在实际工作中遇到这类问题排查顺序通常是先确认两端状态是否一致再看半连接队列有没有异常然后检查中间设备最后才去翻内核参数。大部分情况下问题出在中间设备或者应用层的超时设置上而不是TCP协议本身。如果你只能记住一句话那就记住这个第三次握手的ACK丢失不可怕可怕的是丢失之后客户端什么都不发而服务端还在傻等。理解了这一点你就能在设计系统时提前规避掉大部分握手层面的坑。