引言

在前面的文章中,我们学习了TCP如何建立连接(三次握手)和断开连接(四次挥手)。今天我们来学习TCP如何在连接建立后高效可靠地传输数据流量控制机制(Flow Control)

为什么需要流量控制?

  • 发送方可能发送得很快,接收方可能处理得很慢
  • 如果发送方不控制速度,接收方的缓冲区会溢出,数据丢失
  • 流量控制让接收方告诉发送方:我能接收多少数据

今天我们来理解:

  • ✅ 滑动窗口(Sliding Window)的工作原理
  • ✅ 接收窗口(rwnd)和发送窗口的关系
  • ✅ 零窗口问题与窗口探测
  • ✅ 如何调整TCP窗口大小以提升性能

第一性原理:为什么需要流量控制?

问题:接收方处理不过来

场景:文件传输

发送方(高性能服务器)          接收方(低性能客户端)
   |                                |
   | 发送1GB数据,速度1Gbps          | 接收速度只有100Mbps
   |---------------------------->  | 接收缓冲区64KB
   |                               |
   | 继续疯狂发送...               | 缓冲区满了!
   |---------------------------->  | ❌ 数据丢失
   |                               |

不同场景的速度差异

场景发送方接收方问题
服务器 → 客户端10Gbps网卡100Mbps网卡速度差100倍
内存 → 磁盘内存写入50GB/s磁盘写入500MB/s速度差100倍
批量导入10万条/秒DB只能处理1万条/秒处理能力差10倍

流量控制的目标让发送方的速度匹配接收方的处理能力


滑动窗口:TCP流量控制的核心机制

核心思想

接收方告诉发送方:“我还有X字节的缓冲空间,你最多发送X字节”

接收方                     发送方
   |                          |
   | TCP头部:Window=8192     |
   |<-------------------------|
   |                          |
含义:
"我的接收缓冲区还有8192字节空间,
你最多发送8192字节"

接收窗口(rwnd)

接收窗口(Receive Window):接收方在TCP头部的Window字段通告给发送方的值

  • 16位整数,范围 0-65535 字节
  • 表示接收方缓冲区的剩余空间
  • 动态变化:随着数据的接收和处理而变化

计算公式

rwnd = 接收缓冲区总大小 - 已接收但未被应用程序读取的数据

示例

接收缓冲区总大小:64KB(65536字节)
已接收但未处理:16KB(16384字节)

rwnd = 65536 - 16384 = 49152字节

发送方收到Window=49152,最多发送49152字节

滑动窗口工作原理

发送方的滑动窗口

发送缓冲区:

|<---已发送已确认--->|<---已发送未确认--->|<---可以发送--->|<---不能发送--->|
  (可以丢弃)         (等待ACK)          (窗口内)      (窗口外)
       ↑                   ↑                    ↑              ↑
     seq=0            seq=100             seq=200         seq=300

                      |<-------发送窗口-------->|
                         (等待ACK + 可发送)

发送窗口 = 已发送未确认 + 可以发送

接收方的滑动窗口

接收缓冲区:

|<---已接收已处理--->|<---已接收未处理--->|<---可以接收--->|<---不能接收--->|
  (已读取)           (缓冲区中)         (窗口内)      (窗口外)
       ↑                   ↑                    ↑              ↑
     ack=0            ack=100             ack=200         ack=300

                      |<-------接收窗口-------->|
                         (未处理 + 可接收)
                              rwnd

接收窗口 = 接收缓冲区总大小 - 已接收未处理

滑动窗口示例

初始状态

接收方缓冲区:64KB
发送方窗口:64KB

发送方                                 接收方
   |                                      |
   | [1] 发送32KB数据(seq=0-32767)     |
   |------------------------------------->|
   |                                      | 接收32KB,放入缓冲区
   |                                      | rwnd = 64KB - 32KB = 32KB
   |                                      |
   | [2] ACK=32768, Window=32768          |
   |<-------------------------------------|
   | 收到ACK,窗口右移32KB                |
   |                                      |
   | [3] 发送32KB数据(seq=32768-65535)  |
   |------------------------------------->|
   |                                      | 接收32KB,缓冲区满!
   |                                      | rwnd = 64KB - 64KB = 0KB
   |                                      |
   | [4] ACK=65536, Window=0              |
   |<-------------------------------------|
   | 收到ACK,但Window=0!                |
   | 停止发送,等待Window更新             |
   |                                      |
   |                                      | 应用程序读取32KB数据
   |                                      | rwnd = 64KB - 32KB = 32KB
   |                                      |
   | [5] ACK=65536, Window=32768          |
   |<-------------------------------------|
   | 窗口重新打开,继续发送               |

零窗口问题与窗口探测

零窗口(Zero Window)

定义:接收方的接收窗口为0,通知发送方停止发送

发生场景

  1. 接收方缓冲区满了
  2. 应用程序处理慢,没有及时读取数据
  3. 发送方收到Window=0,停止发送
发送方                     接收方
   |                          |
   | 发送数据                 |
   |------------------------->| 缓冲区满
   |                          |
   | ACK, Window=0            |
   |<-------------------------|
   | 停止发送,等待...        |
   |                          | 应用程序处理慢...
   |                          |
   | ❓ 如何知道窗口重新打开?|

问题:窗口更新丢失

场景:Window更新报文丢失

发送方                     接收方
   |                          |
   | ACK, Window=0            |
   |<-------------------------|
   | 停止发送                 |
   |                          |
   |                          | 应用程序读取数据
   |                          | rwnd重新打开
   |                          |
   | ACK, Window=8192         |
   |<-----X 丢失!            |
   |                          |
   | 永远等待...              | 永远等待发送方发送...
   |                          |
   | ❌ 死锁!                |

解决方案:窗口探测(Window Probe)

机制:当发送方收到Window=0后,定期发送零窗口探测报文

探测报文

  • 包含1字节的数据
  • 即使接收窗口为0,接收方也必须响应
  • 响应中包含最新的Window值
发送方                     接收方
   |                          |
   | ACK, Window=0            |
   |<-------------------------|
   | 停止发送                 |
   |                          |
   | 定时器:5秒后            |
   | [探测] 1字节数据         |
   |------------------------->|
   |                          | rwnd仍然为0
   | ACK, Window=0            |
   |<-------------------------|
   |                          |
   | 定时器:10秒后           |
   | [探测] 1字节数据         |
   |------------------------->|
   |                          | rwnd打开了!
   | ACK, Window=8192         |
   |<-------------------------|
   | 继续发送                 |

探测间隔

  • 首次:5秒
  • 之后:指数退避(10秒、20秒、40秒…)
  • 最大:60秒

配置参数(Linux):

# 查看窗口探测配置
sysctl net.ipv4.tcp_window_scaling
# 输出:net.ipv4.tcp_window_scaling = 1(启用窗口缩放)

窗口缩放(Window Scaling)

问题:16位窗口字段限制

TCP头部Window字段

  • 16位,最大值65535字节(64KB)
  • 高速网络下,64KB窗口太小!

示例:长距离高速网络

网络速度:1Gbps = 125MB/s
往返延迟(RTT):100ms

带宽延迟积(BDP)= 速度 × RTT
                  = 125MB/s × 0.1s
                  = 12.5MB

但Window字段最大只有64KB!
只能利用:64KB / 12.5MB = 0.5% 的带宽!

解决方案:窗口缩放选项

原理:在三次握手时协商一个缩放因子(Shift Count)

Window Scaling

  • TCP选项(Option)
  • 在SYN报文中协商
  • 缩放因子范围:0-14

计算公式

实际窗口大小 = TCP头部Window字段 × 2^缩放因子

示例

TCP头部:Window=65535(最大值)
缩放因子:14

实际窗口 = 65535 × 2^14
         = 65535 × 16384
         = 1,073,725,440字节
         ≈ 1GB

三次握手中的协商

[第一次握手]
客户端 → 服务器: SYN, Window=65535, Options: [WS=7]
含义:"我支持窗口缩放,我的缩放因子是7"

[第二次握手]
服务器 → 客户端: SYN-ACK, Window=65535, Options: [WS=7]
含义:"我也支持窗口缩放,我的缩放因子是7"

[连接建立后]
实际窗口 = 65535 × 2^7 = 8,388,480字节 ≈ 8MB

启用窗口缩放(Linux):

# 查看
sysctl net.ipv4.tcp_window_scaling
# 输出:net.ipv4.tcp_window_scaling = 1(启用)

# 禁用(不推荐)
sudo sysctl -w net.ipv4.tcp_window_scaling=0

调整TCP窗口大小以提升性能

接收缓冲区配置

Linux系统级配置

# 查看默认接收缓冲区大小
sysctl net.ipv4.tcp_rmem
# 输出:net.ipv4.tcp_rmem = 4096 87380 6291456
#                          ↑最小  ↑默认  ↑最大
# 单位:字节

# 调整接收缓冲区
sudo sysctl -w net.ipv4.tcp_rmem="4096 131072 12582912"
#                                  4KB   128KB    12MB

# 持久化配置
echo "net.ipv4.tcp_rmem = 4096 131072 12582912" >> /etc/sysctl.conf

Java应用级配置

// 设置Socket接收缓冲区
Socket socket = new Socket();
socket.setReceiveBufferSize(256 * 1024);  // 256KB

// 查看实际生效的值
int actualSize = socket.getReceiveBufferSize();
System.out.println("接收缓冲区:" + actualSize + "字节");

// ServerSocket也可以设置
ServerSocket serverSocket = new ServerSocket();
serverSocket.setReceiveBufferSize(256 * 1024);

发送缓冲区配置

Linux系统级配置

# 查看默认发送缓冲区大小
sysctl net.ipv4.tcp_wmem
# 输出:net.ipv4.tcp_wmem = 4096 16384 4194304
#                          ↑最小  ↑默认  ↑最大

# 调整发送缓冲区
sudo sysctl -w net.ipv4.tcp_wmem="4096 65536 8388608"
#                                  4KB   64KB    8MB

Java应用级配置

// 设置Socket发送缓冲区
Socket socket = new Socket();
socket.setSendBufferSize(256 * 1024);  // 256KB

int actualSize = socket.getSendBufferSize();
System.out.println("发送缓冲区:" + actualSize + "字节");

最佳实践:根据网络环境调整

场景1:内网高速传输

# 内网:1Gbps网络,延迟1ms
# BDP = 125MB/s × 0.001s = 125KB

# 建议配置
net.ipv4.tcp_rmem = 4096 131072 8388608  # 接收:4KB 128KB 8MB
net.ipv4.tcp_wmem = 4096 131072 8388608  # 发送:4KB 128KB 8MB
net.ipv4.tcp_window_scaling = 1          # 启用窗口缩放

场景2:跨地域传输

# 跨国:100Mbps网络,延迟200ms
# BDP = 12.5MB/s × 0.2s = 2.5MB

# 建议配置
net.ipv4.tcp_rmem = 4096 262144 16777216  # 接收:4KB 256KB 16MB
net.ipv4.tcp_wmem = 4096 262144 16777216  # 发送:4KB 256KB 16MB
net.ipv4.tcp_window_scaling = 1           # 必须启用

场景3:低延迟场景(微服务)

# 微服务内网:延迟<1ms,但连接数多
# 建议配置
net.ipv4.tcp_rmem = 4096 65536 2097152    # 接收:4KB 64KB 2MB
net.ipv4.tcp_wmem = 4096 65536 2097152    # 发送:4KB 64KB 2MB

注意:窗口越大,占用内存越多。高并发场景要平衡性能和内存占用。


实战案例:排查窗口问题

案例1:用ss命令查看TCP窗口

# ss命令可以查看TCP连接的详细信息
ss -tin

# 输出示例
State      Recv-Q Send-Q Local Address:Port  Peer Address:Port
ESTAB      0      0      192.168.1.100:8080 192.168.1.200:54321
         cubic wscale:7,7 rto:204 rtt:4/2 ato:40 mss:1448 pmtu:1500
         rcvmss:1448 advmss:1448 cwnd:10 bytes_acked:1234 bytes_received:5678
         send 28.8Mbps lastsnd:1234 lastrcv:5678
         pacing_rate 57.6Mbps delivery_rate 28.8Mbps
         busy:1234ms rwnd_limited:0ms(0.0%) sndbuf_limited:0ms(0.0%)

# 关键字段解释
wscale:7,7          # 窗口缩放因子(发送方:接收方)
rwnd_limited:0ms    # 受接收窗口限制的时间(0ms说明没有受限)
sndbuf_limited:0ms  # 受发送缓冲区限制的时间

案例2:用netstat查看缓冲区

# 查看接收队列和发送队列
netstat -ant

# 输出示例
Proto Recv-Q Send-Q Local Address     Foreign Address   State
tcp        0      0 192.168.1.100:8080 192.168.1.200:54321 ESTABLISHED
      ↑Recv-Q  ↑Send-Q
      已接收未处理  已发送未确认

# Recv-Q过大:接收方处理慢,接收缓冲区积压
# Send-Q过大:发送方发送快,对方接收窗口小或网络慢

案例3:用tcpdump抓包分析窗口

# 抓包查看Window字段
sudo tcpdump -i any port 8080 -nn -S

# 输出示例
IP 192.168.1.100.54321 > 192.168.1.200.8080: Flags [.], seq 1000, ack 2000, win 65535, length 0
                                                                           ↑Window字段

# win 65535:接收窗口为65535字节
# win 0:零窗口,对方停止发送

案例4:微服务接口响应慢排查

现象:用户服务调用订单服务,接口响应时间从50ms增加到500ms

排查步骤

步骤1:查看TCP连接状态

# 在用户服务上执行
ss -tin | grep order-service

# 发现
rwnd_limited:1234ms(10.0%)  # 10%的时间受接收窗口限制!

步骤2:查看接收队列

netstat -ant | grep order-service:8080

# 输出
tcp  65535  0  user-service:54321  order-service:8080  ESTABLISHED
     ↑Recv-Q很大!

# 说明:用户服务接收慢,缓冲区积压

步骤3:分析原因

// 用户服务代码
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
    // 1. 查询用户基本信息
    User user = userService.getById(id);

    // 2. 调用订单服务获取订单列表
    List<Order> orders = restTemplate.getForObject(
        "http://order-service/api/orders?userId=" + id,
        List.class
    );

    // ❌ 问题:在处理用户信息时,订单服务的响应已经到达
    // 但应用程序还没读取,导致接收缓冲区积压
    Thread.sleep(100);  // 模拟耗时操作

    user.setOrders(orders);
    return user;
}

步骤4:解决方案

// 方案1:异步处理
@GetMapping("/users/{id}")
public CompletableFuture<User> getUser(@PathVariable Long id) {
    CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(() ->
        userService.getById(id));

    CompletableFuture<List<Order>> ordersFuture = CompletableFuture.supplyAsync(() ->
        restTemplate.getForObject("http://order-service/api/orders?userId=" + id, List.class));

    return userFuture.thenCombine(ordersFuture, (user, orders) -> {
        user.setOrders(orders);
        return user;
    });
}

// 方案2:增大接收缓冲区
@Bean
public RestTemplate restTemplate() {
    HttpComponentsClientHttpRequestFactory factory =
        new HttpComponentsClientHttpRequestFactory();

    HttpClient httpClient = HttpClients.custom()
        .setDefaultSocketConfig(SocketConfig.custom()
            .setRcvBufSize(256 * 1024)  // 增大接收缓冲区到256KB
            .build())
        .build();

    factory.setHttpClient(httpClient);
    return new RestTemplate(factory);
}

流量控制 vs 拥塞控制

核心区别

特性流量控制(Flow Control)拥塞控制(Congestion Control)
目标避免接收方缓冲区溢出避免网络拥塞
控制主体接收方控制发送方发送方感知网络状态
机制滑动窗口(rwnd)拥塞窗口(cwnd)
决定因素接收方处理能力网络带宽和延迟
窗口大小接收窗口(rwnd)拥塞窗口(cwnd)

实际发送窗口

公式

实际发送窗口 = min(rwnd, cwnd)
             = min(接收窗口, 拥塞窗口)

示例

rwnd = 64KB(接收方告诉我的)
cwnd = 32KB(我自己感知网络状况决定的)

实际发送窗口 = min(64KB, 32KB) = 32KB

含义:
- 接收方能接收64KB
- 但网络可能拥塞,我只发送32KB
- 取两者的最小值,既不让接收方溢出,也不让网络拥塞

总结

核心要点

  1. 流量控制的目标

    • 让发送方的速度匹配接收方的处理能力
    • 避免接收方缓冲区溢出导致数据丢失
  2. 滑动窗口机制

    • 接收方通过Window字段通告可用缓冲空间
    • 发送方根据Window字段控制发送速度
    • 窗口动态滑动,随着数据的接收和处理而变化
  3. 零窗口问题

    • 接收方Window=0时,发送方停止发送
    • 通过窗口探测机制避免死锁
    • 定期发送探测报文,获取最新Window值
  4. 窗口缩放

    • 解决16位Window字段限制(最大64KB)
    • 通过缩放因子扩展窗口大小(最大1GB)
    • 在三次握手时协商

与下一篇的关联

本文讲解了TCP的流量控制机制(接收方控制发送方)。下一篇我们将学习TCP的拥塞控制机制(发送方感知网络状况),理解:

  • 慢启动、拥塞避免、快速重传、快速恢复
  • BBR拥塞控制算法
  • 拥塞窗口与流量窗口的关系
  • 微服务场景的拥塞问题

思考题

  1. 为什么需要窗口缩放?什么场景下必须启用?
  2. 如果接收方一直不读取数据,会发生什么?
  3. 流量控制和拥塞控制的本质区别是什么?
  4. 如何根据网络环境(延迟、带宽)调整TCP窗口大小?

下一篇预告:《TCP拥塞控制机制:慢启动与拥塞避免详解》