引言
在前面的文章中,我们学习了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,通知发送方停止发送
发生场景:
- 接收方缓冲区满了
- 应用程序处理慢,没有及时读取数据
- 发送方收到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
- 取两者的最小值,既不让接收方溢出,也不让网络拥塞
总结
核心要点
流量控制的目标:
- 让发送方的速度匹配接收方的处理能力
- 避免接收方缓冲区溢出导致数据丢失
滑动窗口机制:
- 接收方通过Window字段通告可用缓冲空间
- 发送方根据Window字段控制发送速度
- 窗口动态滑动,随着数据的接收和处理而变化
零窗口问题:
- 接收方Window=0时,发送方停止发送
- 通过窗口探测机制避免死锁
- 定期发送探测报文,获取最新Window值
窗口缩放:
- 解决16位Window字段限制(最大64KB)
- 通过缩放因子扩展窗口大小(最大1GB)
- 在三次握手时协商
与下一篇的关联
本文讲解了TCP的流量控制机制(接收方控制发送方)。下一篇我们将学习TCP的拥塞控制机制(发送方感知网络状况),理解:
- 慢启动、拥塞避免、快速重传、快速恢复
- BBR拥塞控制算法
- 拥塞窗口与流量窗口的关系
- 微服务场景的拥塞问题
思考题
- 为什么需要窗口缩放?什么场景下必须启用?
- 如果接收方一直不读取数据,会发生什么?
- 流量控制和拥塞控制的本质区别是什么?
- 如何根据网络环境(延迟、带宽)调整TCP窗口大小?
下一篇预告:《TCP拥塞控制机制:慢启动与拥塞避免详解》