土法炼钢兴趣小组的算法知识备份

【网络工程】TCP 流量控制:滑动窗口的工程细节

文章导航

分类入口
network
标签入口
#tcp#flow-control#sliding-window#window-scaling#buffer-tuning

目录

TCP 流量控制:滑动窗口的工程细节

TCP 的流量控制解决一个朴素的问题:发送方不能比接收方处理得更快。如果发送方不受限制地灌数据,接收方缓冲区溢出就会丢包——丢包导致重传——重传浪费带宽和时间。滑动窗口(Sliding Window)机制让接收方通过通告”我还能接收多少字节”来控制发送方的速率。

这听起来简单,但工程细节远比描述复杂。窗口缩放(Window Scaling)为什么是必须的?零窗口(Zero Window)出现时到底发生了什么?发送窗口、接收窗口、拥塞窗口——三个窗口的关系是什么?net.ipv4.tcp_rmemnet.core.rmem_max 到底谁说了算?

本文从工程视角剖析 TCP 流量控制的每一个细节,配合 ss、Wireshark 和内核参数的实测验证。

一、滑动窗口的精确语义

发送方的四个区域

发送方的字节流被分为四个区域:

          已确认     已发送未确认    可以发送      不能发送
        ──────── ─────────────── ────────── ──────────────
        ←  (1)  →←    (2)      →←   (3)  →←    (4)      →

        seq 1000  seq 2000        seq 5000  seq 6000
                  ↑                         ↑
             SND.UNA                   SND.UNA + SND.WND
         (最早未确认的)            (发送窗口的右边界)

发送窗口的大小 = 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 -c

Wireshark 的 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 防治——每一个都可能成为性能瓶颈。

几个关键认知:

  1. 窗口缩放不是可选的。 在任何带宽 ×延迟积超过 64KB 的链路上(几乎所有现代网络),没有窗口缩放意味着严重的性能浪费。确保 tcp_timestamps=1(窗口缩放和时间戳通常一起使用),检查中间设备没有剥离 TCP 选项。

  2. 不要手动设置 SO_RCVBUF,除非你确切知道自己在做什么。 手动设置会禁用 Linux 的接收缓冲区自动调优。如果必须设置,确保 rmem_max 足够大。大多数情况下,调优 tcp_rmem 的三个值并让内核自动管理是最好的选择。

  3. 零窗口是症状,不是病因。 零窗口说明接收方来不及处理数据。增大缓冲区只是延迟问题出现,不是解决问题。根因通常是应用层处理瓶颈——GC 停顿、锁竞争、I/O 阻塞。

  4. 发送速率 = min(rwnd, cwnd) / RTT。 当吞吐量不达预期时,用这个公式判断瓶颈在哪里。ss -ti 提供了 cwndrcv_spacertt 所有需要的数据——不需要猜。

流量控制和拥塞控制是 TCP 的两大速率控制机制。理解了流量控制的”接收方限速”,下一篇我们来看拥塞控制的”网络限速”——从 Reno 到 CUBIC 的演进。


上一篇:TCP 可靠传输:序列号、确认与重传机制

下一篇:TCP 拥塞控制经典算法:从 Reno 到 CUBIC

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2025-07-30 · network

【网络工程】内核网络参数调优:sysctl 全景与实战

Linux 内核网络参数是系统网络性能的基础旋钮。本文从 /proc/sys/net/ 的参数体系出发,系统讲解收发缓冲区自动调优、TCP Backlog 队列、conntrack 连接追踪表、SYN Flood 防护参数、TIME_WAIT 管理,以及参数调优的系统化方法论——先基准、再调整、后验证。

2025-07-17 · network

【网络工程】TCP 拥塞控制经典算法:从 Reno 到 CUBIC

TCP 拥塞控制是互联网流量管理的核心机制。本文从 AIMD 的数学直觉出发,逐步剖析 Reno、NewReno、BIC、CUBIC 的演进动机与工程差异,通过内核参数观测和实测数据帮助读者理解拥塞窗口行为、选择合适的拥塞控制算法。


By .