4、TCP性能调优(上):滑动窗口、拥塞控制

TCP 性能调优,说白了就是跟「窗口」和「拥塞」这两件事死磕。我做了这么多年网络优化,见过太多应用层代码写得飞起,结果被 TCP 底层活活拖死的案例。今天咱们先把上半场讲透——滑动窗口和拥塞控制的核心机制。

4.1 滑动窗口:TCP 的流量水龙头

先问一个问题:为什么 TCP 不每发一个包就等对方 ACK?

你想想看,如果那样做,网络延迟 100ms,你每秒只能发 10 个包,带宽利用率惨不忍睹。滑动窗口就是为了解决这个问题的——它允许发送方在收到 ACK 之前,连续发多个包。

4.1.1 窗口大小的决定因素

滑动窗口的大小由两个因素共同决定:

  • 接收窗口(rwnd):接收方告诉发送方「我还有多少缓冲区」,这是流量控制的依据。
  • 拥塞窗口(cwnd):发送方自己估算的「网络目前能承受多少数据」,这是拥塞控制的依据。

实际能发送的数据量,取两者中的较小值:

有效窗口 = min(rwnd, cwnd)

核心要点:滑动窗口不是越大越好。窗口太大,接收方缓冲区溢出;窗口太小,带宽跑不满。我见过一个案例,某团队把 socket 接收缓冲区设成 64KB,结果在 1Gbps 链路上死活跑不满 200Mbps——就是因为 rwnd 成了瓶颈。

4.1.2 窗口缩放因子

早期的 TCP 头部里,窗口字段只有 16 位,最大只能表示 65535 字节。这在百兆网络时代还行,到了千兆、万兆时代就完全不够用了。

解决方案是 TCP 窗口缩放选项(Window Scale):

  • 三次握手时协商一个缩放因子(0~14)
  • 实际窗口 = 头部窗口值 × 2^缩放因子
  • 最大可达 1GB(65535 × 2^14)

调优建议:我个人习惯在服务器上把 net.core.rmem_max 和 net.core.wmem_max 设为 16MB 以上,然后让应用程序通过 setsockopt 设置 SO_RCVBUF 和 SO_SNDBUF。这样既能利用窗口缩放,又不会无脑占用内存。

4.2 拥塞控制:别把网络挤爆了

滑动窗口管的是「接收方能吃多少」,拥塞控制管的是「网络能扛多少」。这两个概念经常被混淆,其实分工很明确。

拥塞控制有四个经典阶段,我画了一张图帮你理清脉络:

TCP 拥塞控制四阶段流程图 TCP 拥塞控制四阶段流程 慢启动 cwnd 指数增长 拥塞避免 cwnd 线性增长 快重传 收到3个重复ACK 快恢复 cwnd 减半 + 线性增长 回到拥塞避免 超时重传 cwnd 重置为1 关键说明: • 慢启动:每收到一个 ACK,cwnd 翻倍(指数增长),直到 ssthresh • 拥塞避免:每收到一个 ACK,cwnd 增加 1/cwnd(线性增长) • 快重传:收到 3 个重复 ACK 立即重传,不等超时 • 快恢复:cwnd 减半 + 3,然后进入拥塞避免;超时则 cwnd 重置为 1

4.2.1 慢启动:试探性地加速

连接刚建立时,发送方不知道网络状况,所以从很小的 cwnd 开始(通常是 10 个 MSS)。每收到一个 ACK,cwnd 就翻倍。

举个例子:

  • 初始 cwnd = 10 个包
  • 收到 10 个 ACK 后,cwnd = 20
  • 再收到 20 个 ACK 后,cwnd = 40
  • ……指数增长,直到达到 ssthresh(慢启动阈值)

注意:慢启动不是真的「慢」,它是指数增长,实际上非常快。我曾经在 10ms 延迟的链路上测试过,从 10 个包涨到 1000 个包只需要 7 个 RTT——也就是 70ms。真正慢的是后面的拥塞避免阶段。

4.2.2 拥塞避免:谨慎地线性增长

当 cwnd 达到 ssthresh 后,TCP 进入拥塞避免阶段。增长方式从指数变成线性:每个 RTT 只增加 1 个 MSS。

为什么?因为到了这个阶段,网络已经接近饱和了。再指数增长,分分钟把路由器缓存打爆。线性增长是一种「试探性」的缓慢增加,直到出现丢包。

4.2.3 快重传:不等超时,立即重传

传统的 TCP 靠超时重传,但超时时间通常几百毫秒,太慢了。快重传的改进是:

  • 接收方收到乱序包时,会立即发送重复 ACK(确认之前收到的最后一个连续包)
  • 发送方收到 3 个重复 ACK,就认为该包已丢失,不等超时立即重传

嗯,这里有个细节要注意:为什么是 3 个?因为 1 个重复 ACK 可能是包乱序导致的,2 个也可能是乱序,但 3 个基本可以确认是丢包了。这是经验值,实践证明很有效。

4.2.4 快恢复:丢包后的优雅降级

快恢复是跟快重传配合使用的。收到 3 个重复 ACK 后:

  1. cwnd 减半(但至少为 2 个 MSS)
  2. ssthresh 设为减半后的 cwnd
  3. 进入拥塞避免阶段,线性增长

对比一下超时重传:如果超时了,cwnd 直接重置为 1 个 MSS,然后重新慢启动。这代价太大了,尤其是对长肥网络(高带宽高延迟链路)来说,从 1 重新涨回来要花好几个 RTT。

实战经验:我在优化一个跨国数据传输服务时,发现丢包率只有 0.1%,但吞吐量只有理论值的 30%。排查后发现是超时重传导致的——每次丢包都触发超时,cwnd 重置为 1。后来调整了 tcp_retries1 和 tcp_retries2 参数,让快重传/快恢复机制更敏感,吞吐量直接翻倍。

4.3 四个阶段的参数对比

阶段 触发条件 cwnd 变化 ssthresh 变化 典型场景
慢启动 连接建立 / 超时重传后 指数增长(×2 per RTT) 不变 连接初期、超时恢复
拥塞避免 cwnd ≥ ssthresh 线性增长(+1 per RTT) 不变 正常传输阶段
快重传 收到 3 个重复 ACK 不变(立即重传丢失包) 不变 轻度丢包
快恢复 快重传之后 减半 + 3,然后线性增长 设为减半后的 cwnd 轻度丢包后的恢复

4.4 调优实践:几个关键参数

理论讲完了,来点实际的。以下是我在 Linux 服务器上常用的 TCP 调优参数:

# 增大 TCP 缓冲区范围(让窗口缩放生效)
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216

# 设置 TCP 自动缓冲区调优的初始值、最小值、最大值
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216

# 启用窗口缩放(默认就是开启的,但确认一下)
net.ipv4.tcp_window_scaling = 1

# 启用快重传和快恢复(默认开启)
net.ipv4.tcp_sack = 1

# 设置 TCP 重传次数(减少超时等待)
net.ipv4.tcp_retries1 = 3
net.ipv4.tcp_retries2 = 5

避坑指南:我曾经在一台高并发服务器上把 tcp_retries2 设成 2,结果网络抖动时大量连接被过早断开,业务方投诉说「连接不稳定」。后来改回 5,问题解决。重传次数不是越少越好,要平衡响应速度和稳定性。

4.5 不同拥塞控制算法的选择

Linux 支持多种拥塞控制算法,不同场景选不同的算法:

算法 特点 适用场景
Cubic Linux 默认算法,基于三次函数增长 通用场景,尤其是长肥网络
BBR Google 开发,基于带宽和 RTT 建模 高延迟、有丢包的网络(如跨国链路)
Reno 经典算法,线性增长 + 乘性减 低延迟、低丢包的内网环境
BIC Cubic 的前身,二分法搜索 已基本被 Cubic 取代

我个人习惯在数据中心内部用 Cubic,跨公网用 BBR。BBR 对丢包不敏感,不会因为偶尔的丢包就把 cwnd 砍半,这对公网环境非常友好。

切换算法很简单:

# 查看当前可用算法
sysctl net.ipv4.tcp_available_congestion_control

# 设置 BBR
sysctl net.ipv4.tcp_congestion_control=bbr

# 持久化到 /etc/sysctl.conf
echo "net.ipv4.tcp_congestion_control=bbr" >> /etc/sysctl.conf

注意:BBR 需要 Linux 内核 4.9+ 才支持。如果你还在用 CentOS 7(内核 3.10),需要升级内核或者用其他方式加载 BBR 模块。我踩过这个坑,生产环境升级内核一定要先在测试环境验证。

好了,TCP 性能调优的上半场就到这里。滑动窗口和拥塞控制是 TCP 性能的基石,理解透了才能知道瓶颈在哪。下半场我们会聊更深入的话题——Nagle 算法、延迟 ACK、TCP_NODELAY 这些实战中天天碰到的细节。