TCP 流量控制:滑动窗口的工程细节
TCP 的流量控制解决一个朴素的问题:发送方不能比接收方处理得更快。如果发送方不受限制地灌数据,接收方缓冲区溢出就会丢包——丢包导致重传——重传浪费带宽和时间。滑动窗口(Sliding Window)机制让接收方通过通告”我还能接收多少字节”来控制发送方的速率。
这听起来简单,但工程细节远比描述复杂。窗口缩放(Window
Scaling)为什么是必须的?零窗口(Zero
Window)出现时到底发生了什么?发送窗口、接收窗口、拥塞窗口——三个窗口的关系是什么?net.ipv4.tcp_rmem
和 net.core.rmem_max 到底谁说了算?
本文从工程视角剖析 TCP 流量控制的每一个细节,配合
ss、Wireshark 和内核参数的实测验证。
一、滑动窗口的精确语义
发送方的四个区域
发送方的字节流被分为四个区域:
已确认 已发送未确认 可以发送 不能发送
──────── ─────────────── ────────── ──────────────
← (1) →← (2) →← (3) →← (4) →
seq 1000 seq 2000 seq 5000 seq 6000
↑ ↑
SND.UNA SND.UNA + SND.WND
(最早未确认的) (发送窗口的右边界)
- (1) 已发送且已确认:可以释放缓冲区了
- (2) 已发送但未确认:在”发送窗口”内,等待 ACK
- (3) 可以发送但还没发:在”发送窗口”内,可以立即发送
- (4) 不能发送:超出了窗口,必须等窗口滑动
发送窗口的大小 = min(接收窗口, 拥塞窗口):
# 发送窗口 = min(rwnd, cwnd)
#
# rwnd (Receiver Window) — 接收方通告的,表示"我还能接收多少"
# cwnd (Congestion Window) — 发送方自己计算的,表示"网络能承受多少"
#
# 两者取最小值——既不能超过接收方的处理能力,也不能超过网络的承载能力
# 用 ss -ti 观察这三个窗口
ss -ti dst 10.0.0.2
# 关键字段:
# wscale:7,7 ← 窗口缩放因子(发送方,接收方)
# rcv_space:29200 ← 接收缓冲区当前可用空间
# cwnd:10 ← 拥塞窗口(单位:MSS 数)
# send 77.1Mbps ← 根据 cwnd 和 RTT 估算的发送速率接收方的窗口通告
接收方在每个 ACK 中携带一个 16 位的 Window 字段,告诉发送方”从 ACK 号开始,我还能接收多少字节”:
# 用 tcpdump 观察窗口通告
tcpdump -i eth0 -nn 'tcp and dst port 80' -c 10
# 输出示例:
# .54321 > .80: . ack 5001 win 29200
# .54321 > .80: . ack 6001 win 28200 ← 窗口缩小(收了 1000 字节但应用还没读走)
# .54321 > .80: . ack 7001 win 29200 ← 窗口恢复(应用读走了数据)
# .54321 > .80: . ack 8001 win 0 ← 零窗口!(应用没来读数据)
# win 字段的含义:
# win 29200 → 从 ack 号开始,还能接收 29200 字节
# 如果启用了窗口缩放(wscale=7),实际窗口 = 29200 << 7 = 3,737,600 字节窗口滑动的过程
时间点 1:初始状态
已确认 [----] 已发送未确认 [####] 可发送 [....] 不可发送
seq: 1000 2000-5000 5001-6000 6001+
接收方发送 ACK=3001, win=3000
含义:"3000 之前都收到了,从 3001 开始还能接收 3000 字节"
时间点 2:窗口右滑
已确认 [--------] 已发送未确认 [##] 可发送 [....] 不可发送
seq: 1000-3000 3001-5000 5001-6000 6001+
窗口从 [2001,6000] 滑动到 [3001,6000]
左边界右移(因为有新的确认),右边界不变
时间点 3:应用读取了数据,窗口扩大
ACK=3001, win=5000
窗口变为 [3001,8000]——右边界也右移了
二、窗口缩放(Window Scaling)
为什么 16 位窗口不够用
TCP 头中的 Window 字段只有 16 位,最大值 65535 字节(64KB)。这在现代网络中完全不够:
# 带宽-延迟积(BDP)决定了需要多大的窗口
# BDP = 带宽 × RTT
# 场景 1:数据中心内部
# 25 Gbps × 0.1ms = 312.5 KB
# 64KB 远远不够!
# 场景 2:跨国链路
# 1 Gbps × 200ms = 25 MB
# 64KB 只能利用 0.26% 的带宽!
# 场景 3:跨太平洋
# 10 Gbps × 150ms = 187.5 MB
# 没有窗口缩放,这条链路几乎不可用
# 计算最大吞吐量(受窗口限制):
# 吞吐量 = 窗口大小 / RTT
# 64KB / 200ms = 320 KB/s = 2.56 Mbps
# 在 1Gbps 链路上只能跑 2.56 Mbps——浪费了 99.7% 的带宽窗口缩放的工作方式
RFC 7323 定义的窗口缩放选项在 SYN/SYN+ACK 中协商,之后每个包的 Window 字段都左移 N 位:
# 窗口缩放因子在三次握手时协商
# SYN: options [wscale 7] ← 客户端宣布自己的缩放因子
# SYN+ACK: options [wscale 7] ← 服务端宣布自己的缩放因子
# 协商完成后:
# 客户端发送的 win 字段 × 2^(服务端的wscale) = 实际窗口
# 服务端发送的 win 字段 × 2^(客户端的wscale) = 实际窗口
# 例如 wscale=7:
# win=229 → 实际窗口 = 229 × 128 = 29312 字节
# win=65535 → 实际窗口 = 65535 × 128 = 8,388,480 字节 ≈ 8MB
# 最大缩放因子 = 14
# 最大窗口 = 65535 × 2^14 = 1,073,725,440 字节 ≈ 1GB
# 检查系统默认的窗口缩放因子
# Linux 根据接收缓冲区大小自动计算
# 缓冲区越大,wscale 越大
sysctl net.ipv4.tcp_rmem
# 4096 131072 6291456
# 最大 6MB → wscale ≈ 7(2^7=128, 65535×128≈8MB > 6MB)窗口缩放的关键限制:
# 1. 只能在 SYN 中协商,连接建立后不能改
# 如果 SYN 没带 wscale,整条连接窗口上限 64KB
# 2. 不兼容的旧设备会忽略 wscale 选项
# 此时连接回退到 64KB 窗口——性能骤降
# 3. 中间设备可能剥离 TCP 选项
# 一些旧的防火墙/NAT 设备会去掉 wscale
# 表现为:连接建立成功,但吞吐量异常低
# 诊断:抓包检查 SYN 中是否有 wscale
tcpdump -i eth0 -nn 'tcp[tcpflags] == tcp-syn' -v -c 2 2>&1 | grep wscale
# 如果没有 wscale,检查中间设备三、零窗口(Zero Window)
零窗口的含义
当接收方的缓冲区满了(应用没有及时读取数据),它会通告
win=0——告诉发送方”不要再发了”。
# 零窗口的典型场景:
# 1. 接收方应用处理慢
# 例如:数据库在做全表扫描,无暇处理新数据
# 内核接收缓冲区被填满 → win=0
# 2. 接收方应用阻塞
# 例如:应用在等锁、在 GC 停顿中
# 暂时不调用 read() → 缓冲区填满
# 3. 有意的流量控制
# 例如:消息队列消费者主动停止消费
# 通过不读 socket 来实现背压(backpressure)零窗口探测(Zero Window Probe)
发送方收到 win=0
后,不能就此”死等”——如果接收方后来恢复了窗口但通告窗口更新的
ACK
丢失了,双方就会永久死锁。所以发送方会定期发送”零窗口探测包”:
# 零窗口探测的行为:
# 1. 发送方收到 win=0
# 2. 启动 persist timer
# 3. 定时发送 1 字节的探测包
# 4. 接收方收到探测后回复当前窗口
# 5. 如果窗口仍为 0,继续探测
# 6. 如果窗口 > 0,恢复发送
# 探测间隔:指数退避
# 初始:RTO(通常 200ms-1s)
# 之后每次翻倍,上限 60 秒
# 用 ss 观察零窗口状态
ss -to state established
# timer:(persist,15sec,3)
# persist — 当前在 persist timer(零窗口探测)中
# 15sec — 距下次探测的时间
# 3 — 已探测次数
# 用 tcpdump 抓零窗口探测
tcpdump -i eth0 -nn 'tcp' -v 2>&1 | grep 'win 0'
# 看到 win 0 的包就是零窗口通告
# 紧跟其后的 1 字节数据包就是零窗口探测
# 全局统计
nstat -az | grep -i persist
# 没有直接的 persist 统计,但可以通过 Wireshark 过滤:
# tcp.analysis.zero_window
# tcp.analysis.zero_window_probe
# tcp.analysis.zero_window_probe_ack零窗口的诊断与解决
# 1. 找到零窗口的连接
ss -tnp | awk '$2 > 0 || $3 > 0 {print}'
# Recv-Q 很大 → 接收缓冲区积压(接收方来不及读)
# Send-Q 很大 → 发送缓冲区积压(对方窗口为 0)
# 详细查看
ss -tnpi
# 找 timer:(persist,...) 的连接
# 2. 确定是哪个应用导致的
ss -tnp state established | awk '$2 > 100000'
# 找 Recv-Q > 100KB 的连接
# 输出中的进程信息告诉你是哪个应用
# 3. 分析应用为什么不读数据
# 用 strace 观察应用是否在调用 read()/recv()
strace -p <PID> -e trace=read,recvfrom,recvmsg -f 2>&1 | head -20
# 如果没有 read 调用 → 应用被阻塞在其他地方
# 如果有 read 但速度慢 → 应用处理能力不足
# 4. 临时缓解:增大接收缓冲区
sysctl -w net.ipv4.tcp_rmem="4096 524288 16777216"
# 治标不治本——缓冲区大了只是延迟零窗口的出现
# 根本原因是应用处理速度跟不上数据到达速度四、Silly Window Syndrome(SWS)
什么是 SWS
Silly Window Syndrome(糊涂窗口综合征)是一种性能退化:接收方每次只腾出很小的窗口空间,发送方就发很小的包——大量带宽被 TCP/IP 头部开销浪费:
# SWS 的恶性循环:
# 1. 接收缓冲区满 → win=0
# 2. 应用读走 100 字节 → win=100
# 3. 发送方发送 100 字节数据(TCP/IP 头 40 字节 + 数据 100 字节)
# → 有效负载率 = 100/140 = 71%
# 4. 接收方收到后缓冲区又满了 → win=0
# 5. 应用又读走 50 字节 → win=50
# 6. 发送方发 50 字节(头 40 + 数据 50)
# → 有效负载率 = 50/90 = 55%
# 越来越多的带宽被浪费在协议头上接收方的 SWS 防治(Clark 算法)
# 接收方的解法:不要通告小窗口
# David Clark 算法:
# 当缓冲区有少量空间时,继续通告 win=0
# 直到空间达到以下条件之一再通告真实窗口:
# - 空间 >= MSS(至少能装一个完整段)
# - 空间 >= 缓冲区大小的一半
# Linux 的实现:
# 接收方不会通告小于 MSS 的窗口(除非整个缓冲区只有这么大)
# 这个行为是内核自动处理的,不需要配置发送方的 SWS 防治(Nagle 算法)
# Nagle 算法(RFC 896):
# 如果有未确认的数据在飞行中,不要发送小包
# 具体规则:
# if (有未确认的数据)
# 缓存新数据,直到:
# (1) 积累到 MSS 大小,或者
# (2) 之前的数据都被确认了
# else
# 立即发送(不管大小)
# Nagle 算法在大多数场景下是好的——减少小包数量
# 但在延迟敏感的场景中是灾难性的:
# Nagle + Delayed ACK 的交互问题:
# 发送方:发 100 字节(小包),等确认
# 接收方:收到小包,启动 Delayed ACK 定时器(等 40ms 或等下一个包来 piggyback)
# 发送方:有未确认数据,不能发新数据 → 等
# 接收方:40ms 后发 ACK
# 发送方:收到 ACK,发下一个小包
# → 每个小包间隔 40ms!
# 关闭 Nagle(在应用层设置 TCP_NODELAY)
# C: setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
# Python: sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
# Go: conn.SetNoDelay(true) // net.TCPConn
# TCP_NODELAY vs TCP_CORK:
# TCP_NODELAY:关闭 Nagle,有数据就立即发
# TCP_CORK:像瓶塞一样堵住,攒够数据后一次性发
# 典型用法:先 TCP_CORK=1,写多次小数据,然后 TCP_CORK=0 一次性发出
# 适合 sendfile() 前后需要附加头尾数据的场景(如 HTTP 响应头 + 文件体)Nagle 与 Delayed ACK 交互的实测
这个问题值得用实际数据说明——它是 TCP 小包延迟最常见的根因之一:
# 实验:发送两个小包,观察延迟差异
# === 场景 A:Nagle 开启(默认),Delayed ACK 开启(默认) ===
# 时间线:
# T=0.000ms 发送方: write(100 bytes) → 立即发送(无飞行中数据)
# T=0.200ms 接收方: 收到 100 bytes,启动 Delayed ACK 定时器
# T=0.200ms 发送方: write(100 bytes) → Nagle 阻塞(有未确认数据)
# T=40.200ms 接收方: Delayed ACK 超时,发送 ACK
# T=40.400ms 发送方: 收到 ACK,Nagle 放行,发送第二个 100 bytes
# 两个 write 之间延迟 ≈ 40ms!
# === 场景 B:TCP_NODELAY 开启 ===
# T=0.000ms 发送方: write(100 bytes) → 立即发送
# T=0.200ms 接收方: 收到 100 bytes
# T=0.200ms 发送方: write(100 bytes) → 立即发送(Nagle 关闭)
# T=0.400ms 接收方: 收到 100 bytes,ACK piggyback
# 两个 write 之间延迟 ≈ 0.2ms(网络 RTT)
# === 场景 C:TCP_CORK(批量发送) ===
# T=0.000ms 发送方: TCP_CORK=1
# T=0.000ms 发送方: write(100 bytes) → 被 cork 住
# T=0.000ms 发送方: write(100 bytes) → 被 cork 住
# T=0.000ms 发送方: TCP_CORK=0 → 200 bytes 作为一个段发出
# T=0.200ms 接收方: 收到 200 bytes
# 只产生 1 个包,最高效
# 用 strace 验证 Nagle 行为
strace -e trace=write,sendto -tt -p <PID> 2>&1 | head -20
# 观察 write/sendto 的时间间隔
# 如果有固定的 ~40ms 间隔模式,很可能是 Nagle + Delayed ACK 问题什么时候应该开启/关闭 Nagle
| 场景 | 建议 | 原因 |
|---|---|---|
| 交互式协议(SSH、游戏) | TCP_NODELAY | 延迟敏感,小包是常态 |
| 请求-响应式(HTTP、Redis) | TCP_NODELAY | 每次请求完整发送,不需要攒包 |
| 批量传输(文件传输、备份) | 保持 Nagle | 自然是大包,Nagle 减少开销 |
| 日志传输 | 保持 Nagle 或 TCP_CORK | 多条日志合并发送更高效 |
| 数据库查询 | TCP_NODELAY | 查询延迟敏感 |
五、接收缓冲区与自动调优
Linux 的接收缓冲区参数体系
Linux 有三层接收缓冲区控制,它们的关系经常被误解:
# === 第 1 层:每个 socket 的缓冲区 ===
# tcp_rmem 控制 TCP socket 的接收缓冲区(自动调优的范围)
sysctl net.ipv4.tcp_rmem
# 4096 131072 6291456
# 最小值 默认值 最大值(自动调优的上限)
# 4KB 128KB 6MB
# === 第 2 层:系统级的缓冲区上限 ===
sysctl net.core.rmem_max
# 212992 ≈ 200KB(很多系统的默认值,太小了!)
# 这是 setsockopt(SO_RCVBUF) 能设置的最大值
sysctl net.core.rmem_default
# 212992 ← 没有自动调优时的默认接收缓冲区
# === 关键关系 ===
# 如果应用没有调用 setsockopt(SO_RCVBUF):
# → 使用 tcp_rmem 的自动调优(推荐)
# → 缓冲区在 tcp_rmem[0] 到 tcp_rmem[2] 之间动态调整
# → 不受 rmem_max 限制!
# 如果应用调用了 setsockopt(SO_RCVBUF, N):
# → 禁用自动调优!
# → 缓冲区固定为 min(N × 2, rmem_max)
# → 注意:内核会把设置值翻倍(额外空间用于管理开销)
# 这就是为什么:
# 1. 通常不要在应用中设置 SO_RCVBUF,让内核自动调
# 2. 如果必须手动设置,确保 rmem_max 足够大
# 3. 调优 tcp_rmem 的第三个值比调 rmem_max 更重要自动调优的工作方式
# 启用/禁用自动调优
sysctl net.ipv4.tcp_moderate_rcvbuf
# 默认 1(启用)
# 内核根据每条连接的实际需求自动调整缓冲区大小
# 自动调优的逻辑:
# 1. 新连接以 tcp_rmem[1](默认值)启动
# 2. 如果数据到达速率高,内核增大缓冲区
# 3. 增大的上限是 tcp_rmem[2]
# 4. 如果系统内存紧张(tcp_mem 限制),可能缩小缓冲区
# 系统级的 TCP 内存限制
sysctl net.ipv4.tcp_mem
# 184512 246016 369024(单位:页面,不是字节!)
# 低水位 压力线 硬上限
# 一页 = 4096 字节
# 低水位 = 184512 × 4096 ≈ 720MB
# 硬上限 = 369024 × 4096 ≈ 1.4GB
# 当 TCP 总内存使用超过"压力线"时:
# 内核开始限制缓冲区增长
# 当超过"硬上限"时:
# 新连接可能被丢弃、现有连接缓冲区被缩小
# 监控 TCP 内存使用
cat /proc/net/sockstat
# TCP: inuse 1234 orphan 56 tw 789 alloc 1300 mem 12345
# mem 12345 ← 当前 TCP 内存使用(页面数)
# 12345 × 4096 ≈ 48MB发送缓冲区参数
# 发送缓冲区参数结构相同
sysctl net.ipv4.tcp_wmem
# 4096 16384 4194304
# 最小 默认 最大(自动调优上限)
sysctl net.core.wmem_max
# 212992 ← 同样需要调大
# 发送缓冲区与 Send-Q 的关系:
ss -tnp
# State Recv-Q Send-Q Local Address:Port Peer Address:Port
# ESTAB 0 45000 10.0.0.1:54321 10.0.0.2:80
# ↑
# Send-Q > 0 说明数据在发送缓冲区等待发出
# 原因:对端窗口小、网络拥塞、或者应用写入太快推荐的缓冲区调优配置
# === 数据中心内部(RTT < 1ms, 带宽 10-100Gbps)===
sysctl -w net.ipv4.tcp_rmem="4096 131072 16777216" # 最大 16MB
sysctl -w net.ipv4.tcp_wmem="4096 131072 16777216"
sysctl -w net.core.rmem_max=16777216
sysctl -w net.core.wmem_max=16777216
# === 跨国链路(RTT 100-300ms, 带宽 1-10Gbps)===
sysctl -w net.ipv4.tcp_rmem="4096 524288 67108864" # 最大 64MB
sysctl -w net.ipv4.tcp_wmem="4096 524288 67108864"
sysctl -w net.core.rmem_max=67108864
sysctl -w net.core.wmem_max=67108864
# 计算依据:
# BDP = 10Gbps × 200ms = 250MB
# 缓冲区 >= BDP 才能充分利用带宽
# 但考虑到不是所有连接都需要这么大,tcp_rmem[2] = 64MB 是合理的折中
# === 高并发短连接(Web Server,万级连接)===
# 不需要很大的缓冲区,但要小心总内存消耗
sysctl -w net.ipv4.tcp_rmem="4096 87380 6291456"
sysctl -w net.ipv4.tcp_wmem="4096 16384 4194304"
# 每条连接平均 ~100KB 缓冲区
# 10万连接 × 100KB ≈ 10GB TCP 缓冲区内存缓冲区大小速查表
| 场景 | RTT | 带宽 | BDP | tcp_rmem[2] 建议 | 说明 |
|---|---|---|---|---|---|
| 本机回环 | <0.1ms | 无限 | — | 默认 | 不是瓶颈 |
| 数据中心内 | 0.1-1ms | 10-100Gbps | 1.25MB-12.5MB | 16MB | 覆盖大部分场景 |
| 同城机房 | 1-5ms | 1-10Gbps | 1.25MB-6.25MB | 16MB | 同上 |
| 同国跨城 | 10-50ms | 100Mbps-1Gbps | 1.25MB-6.25MB | 16MB | 同上 |
| 跨洲际 | 100-300ms | 100Mbps-1Gbps | 12.5MB-37.5MB | 64MB | 需要大缓冲区 |
| 卫星链路 | 500-700ms | 10-100Mbps | 5MB-8.75MB | 16MB | RTT 极大 |
六、Wireshark 中的窗口分析
关键的 Wireshark 过滤器
# 捕获文件分析
tshark -r capture.pcap -Y 'tcp.analysis.window_full' -T fields \
-e frame.number -e ip.src -e ip.dst -e tcp.window_size_value
# Window Full — 发送方已发满了接收方的窗口
# 这说明发送受到了流量控制的限制
# Zero Window — 接收方通告零窗口
tshark -r capture.pcap -Y 'tcp.analysis.zero_window'
# Window Update — 接收方更新了窗口(通常是零窗口恢复后)
tshark -r capture.pcap -Y 'tcp.analysis.window_update'
# 计算每条流的窗口大小分布
tshark -r capture.pcap -Y 'tcp.stream == 0' -T fields \
-e tcp.window_size_value | sort -n | uniq -cWireshark 的 Window Scaling 注意事项
# Wireshark 需要看到三次握手才能正确解析窗口缩放
# 如果抓包没有包含 SYN/SYN+ACK:
# → Wireshark 不知道 wscale 因子
# → 显示的窗口大小是未缩放的(比实际值小 128 倍!)
# 解决方法:
# 1. 确保抓包包含完整的三次握手
# 2. 或者在 Wireshark 中手动设置:
# Edit → Preferences → Protocols → TCP
# → 取消勾选 "Allow subdissector to reassemble TCP streams"
# 在 tshark 中使用已缩放的窗口大小:
tshark -r capture.pcap -T fields -e tcp.window_size
# tcp.window_size — 已缩放的窗口大小
# tcp.window_size_value — 原始 Window 字段值
# tcp.window_size_scalefactor — 缩放因子IO Graph 分析窗口变化
# 用 tshark 生成窗口大小的时间序列(用于画图)
tshark -r capture.pcap -Y 'tcp.stream == 0 and ip.src == 10.0.0.2' \
-T fields -e frame.time_relative -e tcp.window_size \
| head -20
# 输出示例:
# 0.000000 29200
# 0.001234 29200
# 0.005678 28000 ← 窗口开始缩小
# 0.012345 15000
# 0.025000 0 ← 零窗口!
# 0.525000 0 ← 持续 500ms
# 0.526000 29200 ← 窗口恢复
# 对应 Wireshark GUI:
# Statistics → IO Graphs
# Y Axis: tcp.window_size
# 可以直观看到窗口的变化趋势七、流量控制与拥塞控制的关系
三个窗口的交互
流量控制和拥塞控制是 TCP 的两个独立机制,但它们通过”有效发送窗口”关联:
flowchart TD
A["应用 write()"] --> B["发送缓冲区<br>(Send Buffer)"]
B --> C{"有效发送窗口<br>= min(rwnd, cwnd)"}
C --> D["网络"]
D --> E["接收缓冲区<br>(Receive Buffer)"]
E --> F["应用 read()"]
F -.->|"读取速度决定"| G["rwnd 通告"]
G -.->|"ACK 携带 win 字段"| C
D -.->|"丢包/延迟反馈"| H["拥塞控制算法"]
H -.->|"计算 cwnd"| C
# 有效发送窗口 = min(rwnd, cwnd)
#
# rwnd(接收窗口):接收方告诉你的 → 防止淹没接收方
# cwnd(拥塞窗口):自己估算的 → 防止淹没网络
#
# 谁小谁生效:
# - rwnd < cwnd → 流量控制是瓶颈(接收方处理慢)
# - cwnd < rwnd → 拥塞控制是瓶颈(网络带宽不足或丢包)
# 判断瓶颈在哪里:
ss -ti dst 10.0.0.2
# cwnd:10 rcv_space:29200 mss:1448
#
# 拥塞窗口的字节数 = cwnd × mss = 10 × 1448 = 14480
# 接收窗口 = rcv_space ≈ 29200
# 14480 < 29200 → cwnd 是瓶颈 → 拥塞控制限制了吞吐量
#
# 如果 rcv_space 很小(< cwnd × mss)→ 流量控制是瓶颈
# → 增大接收缓冲区或加快应用读取速度发送速率的计算
# 理论发送速率 = 有效窗口 / RTT
# 有效窗口 = min(rwnd, cwnd × mss)
# 用 ss -ti 中的 send 字段直接看:
ss -ti dst 10.0.0.2 | grep send
# send 77.1Mbps
# 这个值 = min(cwnd × mss, rwnd) × 8 / rtt
# 如果 send 远低于链路带宽,排查:
# 1. cwnd 太小 → 拥塞控制问题(丢包?慢启动未结束?)
# 2. rwnd 太小 → 接收缓冲区不够
# 3. RTT 太高 → 物理限制或路由问题
# 实际的 delivery_rate 是更准确的指标:
ss -ti dst 10.0.0.2 | grep delivery_rate
# delivery_rate 75.0Mbps
# 这是基于 ACK 反馈计算的实际交付速率八、流量控制问题的实战诊断
案例 1:文件传输速度只有预期的 1/10
# 症状:scp 传输速度只有 10MB/s,但 iperf3 测到 100MB/s
# 1. 检查传输中的连接状态
ss -ti dst 文件服务器IP
# cwnd:250 rcv_space:29200 wscale:7,7 rtt:0.5/0.1
# 2. 计算:
# cwnd 字节 = 250 × 1448 = 362000(362KB)
# rcv_space = 29200 → 实际 = 29200 << 7 = 3,737,600(约 3.6MB)
# 有效窗口 = min(362KB, 3.6MB) = 362KB → cwnd 限制
# 理论速率 = 362KB / 0.5ms = 724 MB/s → cwnd 不是瓶颈
# 等等,rcv_space 真的够吗?
# 检查对端(接收方)的缓冲区
# 在接收方执行:
sysctl net.core.rmem_max
# 212992 → 约 200KB!
# 3. 确认瓶颈
# 接收方的 rmem_max 只有 200KB
# 但 scp 在内部调用了 setsockopt(SO_RCVBUF)
# 设置的值被 rmem_max 限制在 200KB
# 而且设置 SO_RCVBUF 禁用了自动调优!
# → 接收窗口锁定在约 200KB
# 4. 修复(在接收方)
sysctl -w net.core.rmem_max=16777216
# 或者更好:不要让应用设置 SO_RCVBUF,让自动调优工作
# 验证修复效果
# 传输速度从 10MB/s 提升到 100MB/s案例 2:数据库连接间歇性卡顿
# 症状:应用日志显示某些数据库查询耗时 500ms+(正常 <10ms)
# 1. 抓包分析
tcpdump -i eth0 -nn host DB_IP -w /tmp/db.pcap
# 2. 用 tshark 搜索零窗口
tshark -r /tmp/db.pcap -Y 'tcp.analysis.zero_window' -T fields \
-e frame.time_relative -e ip.src -e ip.dst -e tcp.window_size
# 0.123456 DB_IP APP_IP 0 ← 数据库服务器通告零窗口
# 0.623456 DB_IP APP_IP 29200 ← 500ms 后恢复
# 3. 分析原因
# 数据库服务器在 GC 停顿时不读 socket
# 接收缓冲区填满 → 零窗口
# 应用发送的查询被零窗口阻塞 → 卡 500ms
# 4. 进一步验证——检查 GC 日志时间是否吻合
# 零窗口出现的时刻(frame.time_relative)对照数据库 GC 日志
# 如果两者吻合,确认是 GC 导致的零窗口
# 5. 零窗口频率统计
tshark -r /tmp/db.pcap -Y 'tcp.analysis.zero_window' | wc -l
# 如果一分钟内出现数十次零窗口,说明问题严重
# 6. 解决方案
# (a) 调优数据库 GC(减少 stop-the-world 时间)
# JVM: 使用 ZGC/Shenandoah 替代 G1,控制 pause < 10ms
# (b) 增大数据库服务器的 tcp_rmem(延迟零窗口出现)
# sysctl -w net.ipv4.tcp_rmem="4096 262144 16777216"
# (c) 应用端设置查询超时 + 重试(容忍偶发延迟)
# 连接池配置合理超时:query_timeout=200ms, retry=1
# (d) 监控:对 tcp.analysis.zero_window 设置 Prometheus 告警案例三:跨机房文件传输吞吐量只有预期的 1/10
# 症状:从北京机房向上海机房传文件,带宽 1Gbps,但 scp 只跑到 100Mbps
# 1. 测量 RTT
ping -c 10 REMOTE_HOST
# rtt min/avg/max/mdev = 20.123/20.456/20.789/0.234 ms
# 2. 计算 BDP(带宽延迟积)
# BDP = 1Gbps × 20ms = 20,000,000 bits = 2.5MB
# 需要至少 2.5MB 的窗口才能填满管道
# 3. 检查当前的接收缓冲区上限
sysctl net.ipv4.tcp_rmem
# 4096 131072 6291456 ← 最大 6MB,看起来够了
# 4. 但检查 net.core.rmem_max
sysctl net.core.rmem_max
# 212992 ← 只有 208KB!这是硬上限!
# 5. 实际最大窗口被 rmem_max 限制为 ~208KB
# 理论最大吞吐 = 208KB / 20ms = 10.4MB/s ≈ 83Mbps → 接近观测值
# 6. 修复
sysctl -w net.core.rmem_max=16777216 # 16MB
sysctl -w net.core.wmem_max=16777216
sysctl -w net.ipv4.tcp_rmem="4096 262144 16777216"
sysctl -w net.ipv4.tcp_wmem="4096 262144 16777216"
# 7. 验证
iperf3 -c REMOTE_HOST -t 10
# [ 5] 0.00-10.00 sec 1.12 GBytes 960 Mbits/sec ← 接近线速这个案例说明了一个常见陷阱:tcp_rmem[2]
够大不代表有效——rmem_max 才是硬上限。
诊断清单
# 流量控制问题的快速诊断
# 1. 查看 Recv-Q 和 Send-Q
ss -tnp | awk '$2 > 0 || $3 > 0'
# Recv-Q 大:接收方读取慢(本地问题)
# Send-Q 大:发送方被对端窗口限制(对端问题)
# 2. 检查缓冲区配置
sysctl net.ipv4.tcp_rmem
sysctl net.ipv4.tcp_wmem
sysctl net.core.rmem_max
sysctl net.core.wmem_max
# 3. 检查自动调优是否生效
sysctl net.ipv4.tcp_moderate_rcvbuf
# 如果为 0,手动开启
# 4. 检查 TCP 内存压力
cat /proc/net/sockstat | grep TCP
# 如果 mem 接近 tcp_mem 的压力线,缓冲区会被限制
# 5. 抓包看窗口变化
tcpdump -i eth0 -nn 'tcp' -c 100 | grep 'win [0-9]'
# 关注 win 0 和 win 值异常小的包九、结论
TCP 流量控制是一个”简单概念、复杂实现”的典型案例。滑动窗口的核心思想——接收方控制发送方的速率——只需要一句话就能解释清楚。但工程细节——窗口缩放、零窗口探测、缓冲区自动调优、Silly Window 防治——每一个都可能成为性能瓶颈。
几个关键认知:
窗口缩放不是可选的。 在任何带宽 ×延迟积超过 64KB 的链路上(几乎所有现代网络),没有窗口缩放意味着严重的性能浪费。确保
tcp_timestamps=1(窗口缩放和时间戳通常一起使用),检查中间设备没有剥离 TCP 选项。不要手动设置 SO_RCVBUF,除非你确切知道自己在做什么。 手动设置会禁用 Linux 的接收缓冲区自动调优。如果必须设置,确保
rmem_max足够大。大多数情况下,调优tcp_rmem的三个值并让内核自动管理是最好的选择。零窗口是症状,不是病因。 零窗口说明接收方来不及处理数据。增大缓冲区只是延迟问题出现,不是解决问题。根因通常是应用层处理瓶颈——GC 停顿、锁竞争、I/O 阻塞。
发送速率 = min(rwnd, cwnd) / RTT。 当吞吐量不达预期时,用这个公式判断瓶颈在哪里。
ss -ti提供了cwnd、rcv_space、rtt所有需要的数据——不需要猜。
流量控制和拥塞控制是 TCP 的两大速率控制机制。理解了流量控制的”接收方限速”,下一篇我们来看拥塞控制的”网络限速”——从 Reno 到 CUBIC 的演进。
下一篇:TCP 拥塞控制经典算法:从 Reno 到 CUBIC
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【网络工程】内核网络参数调优:sysctl 全景与实战
Linux 内核网络参数是系统网络性能的基础旋钮。本文从 /proc/sys/net/ 的参数体系出发,系统讲解收发缓冲区自动调优、TCP Backlog 队列、conntrack 连接追踪表、SYN Flood 防护参数、TIME_WAIT 管理,以及参数调优的系统化方法论——先基准、再调整、后验证。
【网络工程】TCP 连接管理:三次握手、四次挥手与状态机
深入剖析 TCP 连接的完整生命周期——三次握手的每个细节、四次挥手的工程陷阱、11 个状态的实测观察,以及 TIME_WAIT 堆积、SYN Flood 防御、端口复用等生产环境高频问题的系统化解决方案。
【网络工程】TCP 可靠传输:序列号、确认与重传机制
从工程视角剖析 TCP 可靠传输的核心机制——序列号与确认的精确语义、RTO 计算的数学基础、快速重传与 SACK 的工程价值、DSACK 的重复检测,以及重传对延迟的放大效应与实际诊断方法。
【网络工程】TCP 拥塞控制经典算法:从 Reno 到 CUBIC
TCP 拥塞控制是互联网流量管理的核心机制。本文从 AIMD 的数学直觉出发,逐步剖析 Reno、NewReno、BIC、CUBIC 的演进动机与工程差异,通过内核参数观测和实测数据帮助读者理解拥塞窗口行为、选择合适的拥塞控制算法。