资讯动态

TCP三次握手、滑动窗口、拥塞窗口与队列溢出排查实战

发布时间:2026/10/19 13:42:41 来源:尧图企业网站定制
网络编程里有一类知识平时写业务代码时感觉不到它的存在可一旦线上出现连接超时、握手失败、吞吐上不去、服务端大量连接堆积排查到最后往往都会落到这几个词上三次握手、滑动窗口、拥塞窗口、路由过程、全连接队列、半连接队列。我自己在排查连接类故障时最深的体会是——很多人能背出三次握手是SYN、SYN-ACK、ACK但真到了抓包现场看到一串重传和队列溢出就不知道从哪下手了。这篇就把这几个概念串成一条完整的链路讲清楚从客户端发起连接到数据真正流动起来中间每一步发生了什么、内核在维护哪些队列、窗口是怎么动态变化的以及这些机制在真实排障中怎么用。内容会偏底层但我会尽量用生活化的类比和实际抓包场景来解释适合有一定网络基础、想把这几个概念真正吃透的后端开发、运维和测试同学。如果你只是应付面试看完能答得比别人深一层如果你正在排查连接问题这篇里的排查思路可以直接拿去用。1. 从一次连接建立说起三次握手到底在交换什么1.1 为什么是三次而不是两次或四次先把这个经典问题讲透因为后面所有队列、窗口的讨论都建立在连接状态机之上。三次握手的本质是通信双方各自确认我的发送能力和我的接收能力都正常并且双方对初始序列号ISN达成一致。用生活场景类比你打电话给朋友你说喂能听到吗SYN朋友回能听到你能听到我吗SYN-ACK你说能听到ACK。到这一步双方才都确认了双向通道畅通。为什么两次不够因为如果只有两次服务端无法确认客户端收到了自己的SYN-ACK。考虑一个场景客户端发了一个SYN因为网络延迟卡了很久客户端超时重发了一个新SYN旧SYN后来才到达服务端。如果只有两次握手服务端收到旧SYN就直接建立连接并分配资源而客户端早已用新SYN建立了另一条连接这条旧连接就成了服务端单方面维护的僵尸连接白白占用资源。第三次ACK的作用就是让服务端确认客户端确实收到了我的确认并且愿意用这个序列号继续。为什么四次不必要因为SYN和ACK可以在同一个报文里发送服务端的SYN-ACK把我收到你的SYN和我也要发起连接合并了所以三次就够。这里有个容易被忽略的细节初始序列号不是从0或1开始的而是随机生成的。早期协议设计里ISN是周期性递增的后来发现这会导致严重的安全问题——攻击者可以预测序列号伪造报文。现代内核用基于时钟的算法生成ISN大致是每4微秒加1同时叠加一个随机偏移。这也是为什么抓包时看到的seq值都是很大的随机数。1.2 抓包视角下的握手全过程光看理论不够实际抓包时你会看到更丰富的信息。一次正常的握手在抓包工具里是这样客户端 - 服务端 SYN seq1000, win64240, mss1460 服务端 - 客户端 SYN-ACK seq5000, ack1001, win65535, mss1460 客户端 - 服务端 ACK seq1001, ack5001, win65535注意几个点。第一SYN报文虽然不携带应用数据但它占用一个序列号所以服务端回复的ack是1001而不是1000。第二握手阶段双方就在交换MSS最大报文段长度和窗口大小这些参数直接决定了后续数据传输的效率。第三SYN和FIN这类控制报文都会消耗一个序列号这是TCP序列号计算里最容易搞错的地方。我在排查连接问题时习惯先看握手阶段的RTT往返时间。如果SYN到SYN-ACK的间隔明显偏大说明网络链路或服务端处理有问题如果SYN发出后没有回应反复重传那基本是链路不通或被中间设备拦截。这些判断都依赖对握手过程的准确理解。1.3 握手阶段的超时与重传机制客户端发出SYN后不会无限等待。Linux默认的初始重传超时是1秒之后每次翻倍重传次数由tcp_syn_retries控制默认是6次。也就是说如果服务端一直不响应客户端大约会在127秒后放弃1248163264。这个时间在实际业务里往往太长很多场景需要调小。服务端这边收到SYN后发出SYN-ACK如果收不到客户端的ACK会重传SYN-ACK次数由tcp_synack_retries控制默认5次。这个参数和半连接队列的存活时间直接相关后面讲队列时会再展开。提示tcp_syn_retries和tcp_synack_retries这两个参数在排查连接建立慢类问题时非常关键建议先用sysctl确认当前值再结合抓包判断是客户端放弃太早还是服务端重传太多。2. 半连接队列与全连接队列服务端到底在维护什么2.1 两个队列的分工与状态流转这是我认为最值得讲清楚的一块因为线上连接建立失败的问题十有八九和这两个队列有关。服务端在握手过程中维护两个队列半连接队列SYN Queue存放收到SYN、发出SYN-ACK、但还没收到最终ACK的连接。这些连接处于SYN_RECV状态。全连接队列Accept Queue存放已经完成三次握手、处于ESTABLISHED状态、但还没被应用层accept()取走的连接。状态流转是这样的客户端SYN到达内核创建连接请求放入半连接队列并回SYN-ACK客户端ACK到达连接从半连接队列移出放入全连接队列应用调用accept()连接从全连接队列取出交给应用。这里有个关键点全连接队列满了之后新完成的连接怎么办取决于tcp_abort_on_overflow参数。默认是0意思是内核会默默丢弃这个ACK让客户端以为握手没完成从而触发客户端重传ACK。如果设成1内核直接回RST客户端立刻收到连接重置。这两种行为在排障时表现完全不同一个表现为连接超时一个表现为连接被拒。2.2 队列长度由谁决定backlog的真相很多人以为listen(fd, backlog)里的backlog就是全连接队列的长度其实不完全是。Linux里全连接队列的最大长度取的是min(backlog, somaxconn)其中somaxconn是系统级参数默认值在不同内核版本里不一样较新的内核默认是4096。半连接队列的长度计算更复杂它和tcp_max_syn_backlog以及全连接队列长度都有关。在较新的内核里半连接队列的实际容量大致是max(64, min(somaxconn, tcp_max_syn_backlog))再结合一些系数。这个细节之所以重要是因为你可能把backlog调得很大但半连接队列依然很小导致高并发下SYN被丢弃。我踩过的一个坑某次压测时QPS上不去抓包发现大量SYN发出后没有SYN-ACK响应。查了半天以为是网络问题最后发现是tcp_max_syn_backlog没调半连接队列在瞬时高并发下溢出内核直接丢弃了新到的SYN。把tcp_max_syn_backlog和somaxconn一起调大后问题消失。2.3 队列溢出的现场特征与排查命令队列溢出不是无迹可寻的内核会记录统计信息。用netstat -s或ss -s能看到关键计数计数项含义溢出方向SYNs to LISTEN sockets dropped半连接队列溢出丢弃的SYN半连接队列满times the listen queue of a socket overflowed全连接队列溢出次数全连接队列满SYN cookies sent启用SYN Cookie的次数半连接队列压力大排查时我一般按这个顺序走先用ss -lnt看监听套接字的Send-Q全连接队列上限和Recv-Q当前排队数如果Recv-Q长期接近Send-Q说明应用accept太慢全连接队列快满了。再看netstat -s里的溢出计数是否在增长。最后结合tcp_abort_on_overflow判断客户端会看到超时还是RST。注意ss -lnt里Recv-Q对监听套接字来说表示当前已完成握手但未被accept的连接数这个值和全连接队列直接对应是排查时最直观的指标。3. 滑动窗口流量控制如何让发送方不淹没接收方3.1 窗口的本质是接收方的缓冲区余量三次握手解决了能不能连滑动窗口解决的是发多快。核心思想很简单接收方告诉发送方我还能接收多少字节发送方据此控制发送节奏不能超过这个量。这个机制存在的根本原因是发送方和接收方的处理速度可能不匹配。如果发送方拼命发接收方缓冲区满了来不及处理多出来的数据只能丢弃然后触发重传反而更慢。滑动窗口就是让接收方主动告诉发送方你慢点我这边快满了。窗口大小在TCP头部里占16位最大65535字节。这个值在现代高带宽网络里明显不够用所以有了窗口缩放选项Window Scaling在握手阶段协商一个缩放因子最大可以把窗口放大到约1GB。抓包时如果看到win64240这种值通常已经应用了缩放。3.2 发送窗口、接收窗口与可用窗口的关系这里要区分几个概念很多人会混淆接收窗口rwnd接收方通告的、自己还能接收的字节数由接收方缓冲区剩余空间决定。拥塞窗口cwnd发送方根据网络状况估算的、可以发送的字节数由拥塞控制算法维护。发送窗口实际能发送的数据量取min(rwnd, cwnd)。也就是说发送方受两个约束接收方的处理能力rwnd和网络的承载能力cwnd。哪个小就听哪个的。这个设计很巧妙——流量控制和拥塞控制各管一头互不干扰。发送窗口内部又分四段已发送已确认、已发送未确认、可发送未发送、不可发送。随着ACK不断到达窗口像一条滑动的带子一样向前移动这就是滑动窗口名字的由来。3.3 零窗口与窗口探测的实战场景当接收方缓冲区彻底满了会通告一个零窗口win0发送方必须停止发送。但这里有个死锁风险如果接收方后来腾出了空间发了一个窗口更新报文而这个报文丢了发送方就会一直等下去。解决办法是窗口探测Zero Window Probe。发送方在收到零窗口后会启动一个定时器定期发送一个字节的探测报文强制接收方回应当前的窗口大小。这个探测的间隔由tcp_keepalive相关参数之外的机制控制实际表现是发送方每隔一段时间发一个小包。我在排查一个传输卡死的问题时就是靠抓包看到发送方反复发1字节的探测包而接收方一直回零窗口最终定位到是接收方应用读取太慢缓冲区长期打满。这类问题从应用层日志完全看不出来只有抓包才能看到窗口的拉锯。4. 拥塞窗口发送方如何猜测网络的承受极限4.1 拥塞控制要解决的是网络中间的问题滑动窗口管的是接收方拥塞窗口管的是网络本身。网络里的路由器、交换机缓冲区有限如果所有连接都拼命发中间设备会拥塞、丢包整条链路效率暴跌。拥塞控制的目标是让发送方在不造成网络崩溃的前提下尽量跑满带宽。这里的关键难点是发送方看不到网络内部状态。它只能通过ACK是否按时到达、有没有丢包来间接推断网络是否拥塞。这就像你在一条看不见尽头的路上开车只能通过前车是否刹车来判断前方是否堵车。4.2 慢启动、拥塞避免、快重传、快恢复经典的四阶段模型值得完整过一遍慢启动连接刚建立时cwnd从一个很小的值开始现代内核通常是10个MSS每收到一个ACK就把cwnd翻倍。所以cwnd呈指数增长1、2、4、8、16……直到达到慢启动阈值ssthresh。拥塞避免cwnd超过ssthresh后改为每个RTT只加1个MSS变成线性增长。这个阶段增长慢是为了在接近网络极限时小心试探。快重传如果发送方连续收到3个重复ACK说明某个报文丢了但后续报文到达了立即重传丢失的报文不用等超时。这大大加快了恢复速度。快恢复快重传后把ssthresh设为当前cwnd的一半cwnd设为新的ssthresh然后进入拥塞避免。这比回到慢启动起点要温和得多。如果发生超时重传说明网络状况更糟cwnd会直接降到1重新慢启动。这个超时惩罚很重所以现代内核普遍采用更激进的算法。4.3 现代拥塞控制算法为什么换掉了Reno经典的Reno算法在高带宽、长延迟网络里表现不佳因为它把丢包一律当成拥塞信号。但现代网络里丢包原因很多可能是链路误码不一定是拥塞。基于这个认识出现了几类新算法CUBICLinux默认算法。用三次函数代替线性增长cwnd在接近上次拥塞点时长得很慢远离时长得快整体更适应高带宽网络。BBR不依赖丢包判断拥塞而是主动测量带宽和RTT估算出网络的实际瓶颈。在有一定丢包但带宽充足的链路上BBR往往比CUBIC快很多。选哪个算法要看场景。内网低延迟环境CUBIC足够跨地域高延迟链路BBR通常更有优势。切换算法用sysctl net.ipv4.tcp_congestion_controlbbr前提是内核编译了对应模块。提示查看当前可用算法用sysctl net.ipv4.tcp_available_congestion_control查看当前生效的用sysctl net.ipv4.tcp_congestion_control。切换前建议先在测试环境验证不同算法对业务的影响可能超出预期。5. 网络包路由过程数据从应用到对端经历了什么5.1 发送端的封装与路由决策一个数据包从应用层发出要经过层层封装应用数据加上TCP头变成段加上IP头变成包加上以太网头变成帧。每一层都添加自己的控制信息。发送端最关键的一步是路由决策。内核查路由表决定这个包从哪个网卡出去、下一跳是谁。如果目标在同一子网直接ARP解析目标MAC如果不在同一子网发给默认网关。这个判断靠的是子网掩码和路由表的最长前缀匹配。路由表可以用ip route查看。一个典型的路由表里默认路由0.0.0.0/0优先级最低只有其他路由都不匹配时才用。这就是为什么配置多条路由时更具体的网段会覆盖默认路由。5.2 中间链路的转发与NAT包离开本机后经过一系列路由器转发。每经过一跳源和目标IP不变除非有NAT但MAC地址会重新封装成下一跳的地址。TTL每跳减1减到0就丢弃并回ICMP超时这个机制防止包在网络里无限循环。NAT网络地址转换是另一个关键环节。私网地址在出口被转换成公网地址回包时再转换回来。NAT设备维护一张转换表记录哪个内部连接对应哪个外部端口。这张表有超时时间如果连接长时间没流量表项会被清除后续包就无法正确回送。这也是为什么长连接需要定期发心跳保活——不只是为了检测对端存活也是为了刷新中间NAT设备的表项。5.3 接收端的解封装与分发包到达接收端后网卡通过DMA把数据写入内核缓冲区触发中断。内核逐层解封装以太网层检查MACIP层检查目标IP和校验和TCP层根据四元组源IP、源端口、目标IP、目标端口找到对应的socket把数据放入该socket的接收缓冲区唤醒等待的应用进程。这里有个性能关键点高并发下中断和协议栈处理会成为瓶颈。所以有了各种优化手段比如多队列网卡、RSS接收端缩放、NAPI中断转轮询等。这些机制让多个CPU核心分担网络处理避免单核打满。理解这个完整链路的价值在于当出现丢包、延迟、连接异常时你能判断问题出在哪一层。是网卡驱动、协议栈、路由、NAT还是应用每一层的排查手段都不同。6. 把这些机制串起来一次完整传输的排障视角6.1 从连接建立到数据传输的完整时间线把前面所有内容串成一条线一次完整的TCP交互是这样的客户端发SYN进入SYN_SENT。服务端收到SYN放入半连接队列回SYN-ACK。客户端收到SYN-ACK回ACK进入ESTABLISHED。服务端收到ACK连接移入全连接队列。应用accept连接交给应用处理。数据传输开始滑动窗口和拥塞窗口共同决定发送速率。数据经过路由、NAT到达对端逐层解封装。对端ACK返回窗口滑动循环继续。任何一个环节出问题都会在抓包里留下痕迹。排障的本质就是根据现象反推是哪个环节。6.2 常见故障现象与对应环节的对照现象可能环节排查方向连接超时无SYN-ACK半连接队列满或链路不通查tcp_max_syn_backlog、抓包看SYN是否到达连接被RST全连接队列满且开了abort查tcp_abort_on_overflow、应用accept速度传输中途卡死零窗口或NAT表项老化抓包看窗口值、检查心跳间隔吞吐上不去拥塞窗口受限或窗口太小查拥塞算法、窗口缩放是否生效大量重传链路丢包或拥塞看重传率、判断是链路还是拥塞问题这张表是我多年排障经验的浓缩实际遇到问题时先对号入座能省下大量瞎猜的时间。6.3 几个容易被忽视的参数与调优建议最后分享几个实战中真正有用的调优点net.core.somaxconn全连接队列上限高并发服务建议调到65535。默认值往往偏小是连接被拒的常见原因。net.ipv4.tcp_max_syn_backlog半连接队列上限配合somaxconn一起调。注意这个值不是越大越好过大会让SYN Flood攻击更容易耗尽内存。net.ipv4.tcp_syncookies半连接队列满时启用SYN Cookie能在一定程度上抵御SYN Flood。默认开启但要注意它和窗口缩放的兼容性问题。net.ipv4.tcp_window_scaling窗口缩放高带宽长延迟链路必须开启否则窗口上限65535会严重限制吞吐。net.ipv4.tcp_slow_start_after_idle空闲后是否重新慢启动。对于长连接但流量间歇的场景关掉它能避免每次空闲后重新爬坡。调这些参数的原则是先测量再调整调完验证。不要凭感觉改每个参数背后都有明确的机制改之前想清楚它影响的是哪个环节。我在实际使用中发现很多连接类问题的根因并不在参数本身而在于对机制的理解不到位。比如看到全连接队列溢出就去调大backlog但如果根因是应用accept太慢调大队列只是把问题推迟迟早还会爆。真正有效的做法是结合ss、netstat -s和抓包把现象和机制对应起来找到那个真正的瓶颈点。这套思路用熟了大部分网络问题都能在几分钟内定位到方向。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑