TCP三次握手、队列、滑动窗口与拥塞控制:从抓包到调优的实战指南

📅 发布时间:2026/10/19 16:02:17
TCP三次握手、队列、滑动窗口与拥塞控制:从抓包到调优的实战指南
1. 从一个抓包现场说起为什么这些概念总是搅在一起很多人第一次接触网络协议都是从“三次握手”这四个字开始的。背下来也容易SYN、SYNACK、ACK完事。但真正到了排查线上问题的时候你会发现光知道这三个包根本不够用。连接建不上可能是全连接队列满了连接建上了但特别慢可能是滑动窗口太小丢包之后速度断崖式下跌那是拥塞窗口在起作用而数据包从你的机器到目标机器到底走了哪条路又牵扯到路由表的查找逻辑。这几个概念之所以总被放在一起讲是因为它们本来就是一条链路上的不同环节。三次握手负责“建立连接”全连接队列和半连接队列负责“管理连接”滑动窗口负责“流量控制”拥塞窗口负责“网络拥塞控制”路由过程负责“找到路”。你把它们拆开看每个都不难但合在一起才是一次完整的网络通信。这篇文章我打算按实际排查问题的顺序来组织而不是按教科书的章节顺序。因为我自己学这些东西的时候最大的困惑不是“它是什么”而是“它在哪里起作用、出问题的时候我该看哪个”。所以下面会从握手开始一路讲到队列、窗口和路由中间穿插我实际踩过的坑和常用的排查命令。适合谁看如果你写过网络编程、做过后端服务、或者被“连接超时”“吞吐上不去”这类问题折磨过那这篇内容应该能帮你把零散的知识点串成一条线。如果你是完全的新手也没关系我会尽量用生活化的类比把原理讲清楚再配上具体的命令和参数让你能直接上手验证。2. 三次握手到底在交换什么信息2.1 为什么是三次而不是两次或四次先把这个经典问题说透。三次握手的本质是通信双方各自确认“我的发送能力”和“你的接收能力”都正常并且同步初始序列号。两次为什么不够假设只有 SYN 和 SYNACK。客户端发了 SYN服务端回了 SYNACK从服务端角度看它确认了客户端能发、自己能收也确认了自己能发。但客户端还没告诉服务端“我收到你的 SYNACK 了”。这意味着服务端无法确认客户端能收。如果客户端因为某些原因没收到 SYNACK服务端却已经认为连接建立了就会白白维护一个半开连接。三次握手的最后一个 ACK就是让服务端确认“客户端确实能收”。四次为什么多余因为 SYNACK 把“确认收到你的 SYN”和“我也要同步序列号”合并成了一个包。ACK 和 SYN 可以搭同一趟车没必要分两趟。用生活类比你打电话给客服你说“喂能听到吗”SYN客服说“能听到你能听到我吗”SYNACK你说“能听到”ACK。到这一步双方才确认通话链路双向通畅。如果只有前两步客服不知道你那边是不是聋子。2.2 序列号与确认号的实际含义握手过程中交换的序列号不是随便生成的。初始序列号ISN的选取有一套规则现代系统通常基于时钟和哈希生成目的是防止旧连接的延迟包干扰新连接。具体来说客户端发送的 SYN 包里seq 是客户端的初始序列号比如 seqx。服务端回复的 SYNACK 包里seq 是服务端的初始序列号 seqyack 是 x1表示“我已经收到你的 seqx下次请从 x1 开始发”。客户端最后发的 ACK 包里seq 是 x1ack 是 y1。这里有个容易忽略的点SYN 和 FIN 这两个标志位会各消耗一个序列号。所以确认号要加一。普通数据包不消耗额外序列号确认号就是 seq 加上数据长度。2.3 抓包验证用 tcpdump 看一次完整握手理论说再多不如自己抓一次。在 Linux 上可以用 tcpdump 直接观察tcpdump -i eth0 -nn -S tcp[tcpflags] (tcp-syn|tcp-ack) ! 0 and port 80参数说明-nn不解析主机名和端口名-S显示绝对序列号而不是相对序列号过滤条件只抓带 SYN 或 ACK 标志的包。你会看到类似这样的输出IP 10.0.0.1.54321 10.0.0.2.80: Flags [S], seq 1000, win 64240, options [mss 1460,sackOK,TS val 123 ecr 0,nop,wscale 7], length 0 IP 10.0.0.2.80 10.0.0.1.54321: Flags [S.], seq 2000, ack 1001, win 65535, options [mss 1460,sackOK,TS val 456 ecr 123,nop,wscale 7], length 0 IP 10.0.0.1.54321 10.0.0.2.80: Flags [.], ack 2001, win 64240, length 0注意第一行里的 options这些是握手阶段协商的关键参数。MSS 决定单个包最大能带多少数据SACK 开启选择性确认wscale 是窗口缩放因子。这些参数直接影响后面的传输效率后面讲滑动窗口时会再提到。提示抓包时如果只看到 SYN 没有 SYNACK说明服务端没响应可能是端口没监听、防火墙拦截、或者半连接队列满了。如果看到 SYNACK 但客户端没回 ACK通常是客户端侧的问题比如客户端进程已经退出。3. 半连接队列与全连接队列连接建立背后的两本账3.1 两个队列分别在什么阶段起作用这是很多人容易混淆的地方。服务端收到 SYN 之后连接并没有立刻进入“已建立”状态而是先被放进半连接队列。此时连接处于 SYN_RECV 状态。服务端回了 SYNACK等待客户端的 ACK。当服务端收到客户端的 ACK 后连接从半连接队列移出进入全连接队列状态变为 ESTABLISHED。此时连接已经建立完成等待应用程序调用 accept 把它取走。所以两个队列的分工很明确半连接队列管的是“握手还没完成”的连接全连接队列管的是“握手完成了但应用还没接管”的连接。3.2 队列满了会发生什么半连接队列满了新的 SYN 包会被丢弃。客户端会超时重传 SYN表现为连接建立变慢甚至超时。全连接队列满了服务端通常不会丢弃 ACK而是根据内核参数决定行为。在 Linux 上如果tcp_abort_on_overflow为 0默认值服务端会忽略这个 ACK让客户端以为包丢了触发重传。如果设为 1服务端会直接回 RST客户端立刻收到连接重置。这两种行为各有适用场景。默认的忽略策略更温和给应用一点时间把队列里的连接取走。但如果应用长时间不 accept客户端会一直重传最终超时。设为 1 则快速失败适合对延迟敏感的场景。3.3 关键参数与查看方法半连接队列的大小由net.ipv4.tcp_max_syn_backlog控制。全连接队列的大小由listen()系统调用的 backlog 参数和net.core.somaxconn共同决定取两者中的较小值。查看当前队列情况ss -lnt输出中的 Send-Q 就是全连接队列的最大长度Recv-Q 是当前已经完成握手但还没被 accept 的连接数。如果 Recv-Q 经常接近 Send-Q说明应用 accept 的速度跟不上。查看半连接队列netstat -nat | grep SYN_RECV | wc -l或者用ss -n state syn-recv。注意tcp_max_syn_backlog在较新的内核里并不是硬限制实际还会受到somaxconn和路由缓存等因素影响。调参时不要只盯着一个值。3.4 实操心得队列溢出怎么排查我遇到过一次线上问题服务端偶尔出现连接超时但 CPU 和内存都很正常。后来用ss -lnt发现 Recv-Q 周期性飙高接近 Send-Q。原因是应用层在处理某个耗时操作时阻塞了 accept 循环。解决办法是把 accept 和业务处理拆到不同线程或者调大 backlog。另一个坑是somaxconn的默认值。很多系统默认是 128 或 4096在高并发场景下明显不够。我一般会把它调到 65535同时把应用的 backlog 参数也设成一样的值。注意改完somaxconn需要重启应用才能生效因为它是在 listen 时读取的。4. 滑动窗口流量控制的核心机制4.1 窗口的本质是接收方的缓冲区余量滑动窗口解决的是“发送方发太快接收方处理不过来”的问题。接收方在自己的 TCP 头部里有一个窗口字段告诉发送方“我还能接收多少字节”。发送方根据这个值调整发送速度确保不会把接收方的缓冲区撑爆。这个机制叫流量控制是端到端的。注意它和拥塞控制不是一回事流量控制关心的是接收方的处理能力拥塞控制关心的是网络链路的承载能力。窗口大小不是固定的。接收方每收到一批数据应用层读走一部分缓冲区腾出空间窗口就会变大。如果应用层读得慢缓冲区越来越满窗口就越来越小甚至变成 0。窗口为 0 时发送方会停止发送定期发窗口探测包询问接收方什么时候能腾出空间。4.2 窗口缩放因子为什么必要TCP 头部里的窗口字段只有 16 位最大表示 65535 字节。在早期网络里这够用但在高带宽高延迟的场景下65535 字节的窗口远远不够。举个例子带宽 100Mbps、往返延迟 100ms 的链路带宽时延积是 100Mbps × 0.1s 10Mbit 1.25MB。也就是说要跑满这条链路窗口至少得有 1.25MB。65535 字节连零头都不够。窗口缩放因子在三次握手阶段协商通过 TCP 选项里的 wscale 字段传递。双方各自通告自己的缩放因子实际窗口大小等于头部窗口字段左移缩放因子位。缩放因子最大是 14所以最大窗口可以到 65535 × 2^14约 1GB。4.3 滑动窗口的实际滑动过程假设接收方通告窗口为 4000 字节发送方已经发了 2000 字节收到确认 1000 字节。此时发送方还能发多少窗口右边界是 4000已经发了 2000已确认 1000所以还能发 4000 - 2000 2000 字节。当收到新的确认窗口右边界右移发送方就能发更多数据。这个过程里发送方维护三个指针已发送已确认、已发送未确认、未发送但在窗口内。接收方也维护类似的指针区分已接收已确认、已接收未确认、还能接收多少。4.4 零窗口与窗口探测当接收方缓冲区满时会通告窗口为 0。发送方收到零窗口后停止发送但会启动一个持续计时器定期发送窗口探测包。探测包通常只带一个字节的数据接收方收到后如果缓冲区有空间了就回复当前窗口大小如果还是满的就回复零窗口。这里有个经典问题叫“零窗口死锁”。如果接收方腾出空间后发的窗口更新包丢了发送方还在等接收方以为发送方收到了双方就僵住了。窗口探测机制就是用来打破这个僵局的。提示抓包时如果看到大量[TCP ZeroWindow]和[TCP Window Update]说明接收方应用读取速度跟不上。这时候要么优化应用要么调大接收缓冲区。4.5 接收缓冲区和发送缓冲区的调优Linux 上接收缓冲区由net.ipv4.tcp_rmem控制发送缓冲区由net.ipv4.tcp_wmem控制。每个参数有三个值最小值、默认值、最大值。sysctl net.ipv4.tcp_rmem sysctl net.ipv4.tcp_wmem典型输出是4096 131072 6291456表示最小 4KB默认 128KB最大 6MB。如果应用需要高吞吐可以把最大值调大比如 16MB。但要注意缓冲区不是越大越好太大会增加内存占用也会让延迟变大。自动调优参数net.ipv4.tcp_moderate_rcvbuf默认开启内核会根据连接情况自动调整缓冲区大小。大多数场景下保持开启就行只有在明确知道需要固定大缓冲区时才关闭。5. 拥塞窗口网络拥堵时的自我克制5.1 拥塞窗口和滑动窗口的区别滑动窗口是接收方告诉发送方“我能收多少”拥塞窗口是发送方自己估计“网络能承受多少”。实际发送窗口取两者中的较小值。拥塞窗口没有在 TCP 头部里传递它是发送方自己维护的一个状态变量。发送方通过观察丢包和延迟来判断网络是否拥塞然后调整这个值。5.2 慢启动、拥塞避免、快速重传、快速恢复这是拥塞控制的四个核心阶段。慢启动连接刚建立时拥塞窗口从一个较小的值开始通常是 10 个 MSS 左右每收到一个 ACK 就翻倍。所以是指数增长很快就涨到阈值。拥塞避免当拥塞窗口超过慢启动阈值后改为线性增长每个往返时间加一个 MSS。这样增长速度慢下来避免一下子把网络撑爆。快速重传发送方收到三个重复 ACK就认为某个包丢了立刻重传不用等超时。这比超时重传快得多。快速恢复快速重传之后把慢启动阈值设为当前拥塞窗口的一半拥塞窗口也设为这个值然后进入拥塞避免阶段。这样避免了重新从慢启动开始速度不会掉得太狠。5.3 不同拥塞控制算法的选择Linux 支持多种拥塞控制算法常见的有 cubic、reno、bbr。cubic 是 Linux 默认算法适合大多数场景在高带宽长距离链路上表现不错。reno 是老算法现在很少用。bbr 是后来出现的算法它不依赖丢包来判断拥塞而是测量带宽和往返延迟在有一定丢包的链路上表现更好。查看当前算法sysctl net.ipv4.tcp_congestion_control切换算法sysctl -w net.ipv4.tcp_congestion_controlbbr注意切换算法需要内核支持。可以用sysctl net.ipv4.tcp_available_congestion_control查看可用算法列表。5.4 实操案例丢包导致的吞吐下降我做过一次跨机房的传输测试带宽 1Gbps延迟 30ms但实际吞吐只有 100Mbps 左右。抓包发现有不少重传。用ss -ti查看连接详情看到拥塞窗口经常掉到很低的值。原因是链路上有轻微丢包cubic 算法把丢包当作拥塞信号大幅降低拥塞窗口。换成 bbr 之后吞吐提升到 600Mbps 以上。因为 bbr 不把丢包等同于拥塞而是根据带宽和延迟来调整发送速率。这个案例说明拥塞控制算法的选择对实际性能影响很大。如果你的业务跑在有一定丢包的链路上值得试试 bbr。6. 网络包路由过程数据到底走了哪条路6.1 路由表查找的基本逻辑当一台机器要发送数据包时内核会查找路由表决定从哪个网卡发出去、下一跳是谁。查找规则是“最长前缀匹配”在多个匹配的路由条目中选择子网掩码最长的那条。查看路由表ip route show典型输出default via 10.0.0.1 dev eth0 10.0.0.0/24 dev eth0 proto kernel scope link src 10.0.0.2第一行是默认路由所有不匹配其他条目的包都走这里。第二行是本地子网路由目标地址在 10.0.0.0/24 范围内的包直接通过 eth0 发送不需要经过网关。6.2 数据包的完整旅程一个数据包从发送到到达目标大致经过这些步骤发送方查路由表确定下一跳和出口网卡。如果下一跳的 MAC 地址未知发 ARP 请求获取。然后包被送到网卡加上以太网头部发到链路上。中间经过多个路由器每个路由器都查自己的路由表决定下一跳。路由器的路由表可能比主机复杂得多包含各种动态路由协议学习到的条目。到达目标网络后最后一跳路由器通过 ARP 找到目标主机的 MAC 地址把包送达。目标主机收到包后检查目标 IP 是不是自己。如果是交给上层协议处理如果不是根据ip_forward参数决定是否转发。6.3 Traceroute 的原理和局限traceroute 利用 IP 头部的 TTL 字段来探测路径。发送方发一个 TTL1 的包第一跳路由器收到后 TTL 减为 0丢弃并回复 ICMP 超时消息。发送方就知道了第一跳的地址。然后发 TTL2 的包以此类推。traceroute -n 10.0.0.100-n表示不解析主机名速度更快。traceroute 的局限在于很多路由器不回复 ICMP 超时消息或者回复的优先级很低。所以看到* * *不代表链路断了可能只是路由器不响应。另外去程和回程的路径可能不一样traceroute 只能看到去程。6.4 策略路由和多路由表Linux 支持策略路由可以根据源地址、目标地址、端口等条件选择不同的路由表。这在多网卡、多线路的场景下很有用。查看策略路由规则ip rule show默认规则是查 local 表、main 表、default 表。可以添加自定义规则比如“来自 10.0.0.0/24 的包查表 100”。ip rule add from 10.0.0.0/24 table 100 ip route add default via 10.0.1.1 dev eth1 table 100这样来自 10.0.0.0/24 的包就会走 eth1 出去而不是默认的 eth0。提示策略路由配置错误很容易导致网络不通。改之前先用ip route get确认当前路径改完再验证一次。7. 常见问题速查与排查思路7.1 连接建立相关现象可能原因排查方法连接超时抓包只有 SYN服务端没监听、防火墙拦截、半连接队列满检查服务端监听状态、防火墙规则、netstat -s连接超时有 SYNACK 但无 ACK客户端问题、SYNACK 被中间设备丢弃客户端抓包、检查中间设备连接被重置全连接队列满且tcp_abort_on_overflow1检查ss -lnt的 Recv-Q 和 Send-Q连接建立慢SYN 重传、半连接队列频繁溢出检查tcp_max_syn_backlog和 SYN 重传统计7.2 传输性能相关现象可能原因排查方法吞吐上不去窗口太小、拥塞窗口受限、缓冲区不足ss -ti查看 cwnd 和 rwnd检查缓冲区参数大量重传链路丢包、拥塞控制过于激进netstat -s延迟忽高忽低缓冲区膨胀、队列抖动检查tc qdisc配置考虑减小缓冲区零窗口频繁出现接收方应用读取慢优化应用读取逻辑调大接收缓冲区7.3 路由相关现象可能原因排查方法目标不可达路由表缺少条目、网关配置错误ip route get 目标IP去程和回程路径不一致策略路由、多线路两端分别 traceroute部分网段不通子网掩码配置错误检查ip addr和ip route7.4 几个容易踩的坑第一个坑是只看一个参数就下结论。比如看到全连接队列满了就调大 backlog但实际原因可能是应用 accept 太慢。调大 backlog 只是缓解症状不解决根因。第二个坑是忽略内核版本差异。不同内核版本对队列、窗口、拥塞控制的实现有差异。比如新内核的tcp_max_syn_backlog行为和老版本就不一样。排查时先确认内核版本。第三个坑是抓包位置不对。在客户端抓包和在服务端抓包看到的现象可能完全不同。定位问题时最好两端同时抓对比分析。第四个坑是过度调优。缓冲区调得太大延迟会变高拥塞窗口调得太激进丢包会更严重。调优要基于实际测量不要凭感觉。8. 我个人的一些实操体会这些东西刚学的时候觉得零散用多了就会发现它们是一条链上的。我的习惯是遇到网络问题先抓包看握手是否完成、窗口是否正常、有没有重传。抓包能解决大部分疑惑比看各种统计计数器直观得多。另外ss -ti这个命令强烈推荐。它能把连接的拥塞窗口、往返延迟、重传次数都列出来排查性能问题时非常有用。我基本每次遇到吞吐问题都会先跑一遍。调参方面我的原则是能不调就不调先找应用层的原因。应用层的缓冲区读取、accept 速度、并发模型往往比内核参数影响更大。内核参数调优是最后一步而且每次只调一个调完观察一段时间再决定下一步。最后说一个细节MSS 的协商。如果两端 MSS 不一致或者中间设备改了 MSS可能导致分片影响性能。抓包时留意 SYN 包里的 MSS 选项确保两端协商一致。这个点很小但实际排查中遇到过好几次。