4. TCP流量控制:滑动窗口协议、窗口大小与通告、零窗口与持续计时器

流量控制这个话题,说白了就是解决一个问题:发送方别把接收方撑死。

接收方的缓冲区是有限的。你发得再快,人家来不及处理,数据就得丢。TCP 的设计者很聪明,搞了一套滑动窗口机制,让双方能协商一个合适的发送节奏。我刚开始调网络性能的时候,总觉得这玩意儿太保守,后来在线上环境吃过亏,才明白它的精妙之处。

4.1 滑动窗口协议:核心思想

滑动窗口,你可以把它想象成一个「发送许可证」的队列。

发送方不需要每发一个包就等一次确认。只要窗口没满,就可以连续发。窗口大小决定了你最多能同时发多少个字节而不等 ACK。

嗯,这里要注意:窗口是字节单位,不是包个数。

核心公式:

可用窗口 = 接收方通告窗口 - (已发送未确认的数据量)

我画了一张图,帮你理清滑动窗口的四个区域:

TCP滑动窗口结构图 TCP 滑动窗口结构 已确认 已发送未确认 可发送(窗口内) 不可发送(窗口外) 左边界(已确认) 右边界(窗口尾) 窗口大小(Win) 说明: 窗口左边界随 ACK 向右滑动,右边界由接收方通告窗口决定。 窗口越大,吞吐量越高,但也要考虑网络拥塞。

窗口滑动的过程是这样的:

  1. 发送方收到 ACK,左边界右移。
  2. 右边界根据接收方通告的窗口大小重新计算。
  3. 新的可用窗口 = 通告窗口 - (已发送未确认)。
  4. 发送方继续发送新数据,直到填满窗口。

我个人习惯把滑动窗口比作「流水线」。流水线越长,生产效率越高。但流水线太长,万一中间某个环节出问题,返工成本也大。TCP 就是在效率和可靠性之间找平衡。

4.2 窗口大小与通告

窗口大小不是发送方自己定的,而是接收方说了算。

接收方在每次回复 ACK 时,都会在 TCP 头部携带一个字段叫 Window Size(窗口大小)。这个值告诉发送方:我当前还有多少缓冲区空间,你最多能再发多少字节。

这个过程叫「窗口通告」。

小技巧:

TCP 头部的窗口字段只有 16 位,最大只能表示 65535 字节。但现代网络动不动就几 MB 的窗口,怎么办?

答案是 Window Scaling 选项(RFC 1323)。通过握手时的选项协商,可以把窗口放大到 1GB 级别。我在调优高带宽链路时,这个选项几乎是必开的。

窗口大小的变化,直接影响发送速率。举个例子:

场景 接收方缓冲区剩余 通告窗口 发送方行为
空闲状态 64 KB 65535 正常发送
应用层读取慢 32 KB 32768 降低发送速率
缓冲区快满 4 KB 4096 几乎停止发送
缓冲区满 0 0 停止发送(零窗口)

你想想看,如果接收方通告了一个很小的窗口,发送方还拼命发,那数据到了也只能被丢弃。TCP 不会干这种傻事。

4.3 零窗口与持续计时器

当接收方缓冲区完全满了,它会通告窗口大小为 0。这就是零窗口状态。

发送方收到零窗口通告后,必须立即停止发送。但问题来了:接收方什么时候才能恢复?

接收方应用层读取了数据,缓冲区腾出空间后,会发送一个非零窗口的 ACK。但如果这个 ACK 丢了,发送方就会一直等下去——这就是经典的死锁场景。

我曾经踩过的坑:

有一次排查一个 TCP 连接卡死的问题,抓包发现接收方发了窗口更新(非零窗口),但发送方没收到。双方就这么干等着,连接挂了整整 5 分钟。后来才发现,是中间某个路由器把那个窗口更新包给丢了。

TCP 的设计者早就想到了这个问题,解决方案就是 持续计时器(Persist Timer)。

持续计时器的工作原理:

  1. 发送方收到零窗口通告后,启动持续计时器(默认 5 秒)。
  2. 计时器超时后,发送方主动发送一个 窗口探测包(Window Probe),通常只有 1 字节数据。
  3. 接收方回复 ACK,通告当前窗口大小。
  4. 如果窗口还是 0,发送方重置计时器,等待下一次探测。
  5. 如果窗口非零,发送方恢复数据传输。

探测间隔会指数退避:5 秒、10 秒、20 秒……最大到 60 秒。这样做既避免了频繁探测浪费带宽,又能保证最终能恢复。

关键点总结:

  • 零窗口是接收方告诉发送方「我吃不下了」。
  • 持续计时器是发送方主动「试探」接收方是否恢复。
  • 窗口探测包只有 1 字节,避免造成新的缓冲区压力。

下面这张图展示了零窗口到恢复的完整流程:

零窗口与持续计时器流程图 零窗口与持续计时器交互流程 发送方 接收方 数据发送 Win=0(零窗口) 停止发送 启动持续计时器 超时(5s) 窗口探测(1字节) 应用层读取数据 Win=4096(窗口更新) 恢复数据发送 持续计时器防止因窗口更新 ACK 丢失导致的死锁,是 TCP 可靠性的重要保障。

持续计时器的超时时间,不同操作系统实现略有差异。Linux 下初始是 5 秒,Windows 下也是 5 秒。但探测间隔的退避策略基本一致。

调优建议:

如果你在抓包时发现频繁出现零窗口,说明接收方应用层处理能力不足。这时候调大接收缓冲区(SO_RCVBUF)只能缓解症状,根本办法是优化应用层的读取逻辑。

我遇到过一台 Redis 服务器,因为慢查询导致 TCP 接收缓冲区频繁满,客户端不断触发零窗口。最后优化了查询,问题才彻底解决。

4.4 滑动窗口与流量控制的实战意义

理解了滑动窗口和零窗口机制,你在排查网络问题时就有了方向:

  • 吞吐量低:先看窗口大小是否被限制。如果通告窗口很小,说明接收方是瓶颈。
  • 连接卡死:抓包看是否有零窗口通告,以及持续计时器是否正常工作。
  • 延迟抖动:窗口频繁变化,说明双方处理能力不匹配。

我个人习惯在排查性能问题时,先用 tcpdump 抓一段流量,然后用 Wireshark 的「TCP Stream Graph」里的「Window Scaling」图,一眼就能看出窗口变化趋势。如果窗口曲线像锯齿一样剧烈波动,那肯定有问题。

流量控制不是 TCP 的独角戏,它和后面的拥塞控制是搭档。窗口大小由接收方决定,但发送方实际能发的数据量,还要受拥塞窗口的限制。这部分我们后面会详细讲。