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 拥塞控制:别把网络挤爆了
滑动窗口管的是「接收方能吃多少」,拥塞控制管的是「网络能扛多少」。这两个概念经常被混淆,其实分工很明确。
拥塞控制有四个经典阶段,我画了一张图帮你理清脉络:
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 后:
- cwnd 减半(但至少为 2 个 MSS)
- ssthresh 设为减半后的 cwnd
- 进入拥塞避免阶段,线性增长
对比一下超时重传:如果超时了,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 这些实战中天天碰到的细节。