mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
585 字
2 分钟
为什么 TCP 协议有粘包问题
2023-06-23

TCP 粘包问题是网络编程中最常见的问题之一。发送方连续发送多个数据包,接收方却可能一次收到多个合并的数据,或者一个数据包被拆分成多次接收。这是为什么?

一、TCP 的本质:字节流#

1.1 TCP vs UDP 的核心区别#

flowchart LR subgraph UDP(数据报) U1[数据包 A] U2[数据包 B] U1 -->|独立| R1[接收 A] U2 -->|独立| R2[接收 B] Note: 有明确边界 end subgraph TCP(字节流) T1[数据流 A] T2[数据流 B] T1 -->|合并| R3[混合流] T2 -->|合并| R3 Note: 无边界 end
协议语义边界
UDP数据报有边界,每个 Send 对应一个 Recv
TCP字节流无边界,Send 和 Recv 次数无关

1.2 TCP 字节流的本质#

# TCP 的读写是字节流,不是消息流
# 发送
socket.send(b"Hello") # 5 字节
socket.send(b" World") # 6 字节
# 接收
data = socket.recv(1024) # 可能收到 "Hello World" 或其他组合

二、粘包的原因#

2.1 Nagle 算法#

Nagle 算法合并小数据包以减少网络开销:

flowchart LR subgraph Nagle 算法 A[小数据包] --> B{累积?} B -->|是,等待| C[等待 ACK] B -->|否| D[立即发送] C --> E[合并发送] end

合并规则

  • 如果有未确认的数据,新数据等待 ACK 后发送
  • 如果没有未确认数据,立即发送

2.2 滑动窗口与批量发送#

sequenceDiagram participant S as 发送方 participant N as 网络 participant R as 接收方 S->>N: 数据包 1 S->>N: 数据包 2 S->>N: 数据包 3 N->>R: 可能乱序或合并 Note over R: 接收缓冲区合并数据

滑动窗口允许批量发送,多个数据包可能同时在网络中传输。

2.3 接收缓冲区#

# 接收方看到的
data = socket.recv(1024)
# data 可能是:
# - 部分数据包
# - 一个完整数据包
# - 多个合并的数据包

三、粘包的表现形式#

3.1 粘包(多个包粘在一起)#

# 发送
send(b"Hello")
send(b"World")
# 接收(可能)
recv(1024) -> b"Helloworld" # 粘在一起

3.2 半包(一个包被拆分)#

# 发送
send(b"HelloWorld")
# 接收(可能)
recv(5) -> b"Hello" # 只收到一半
recv(1024) -> b"World" # 另一半

3.3 图解#

flowchart LR subgraph 发送 P1[包 1: ABC] P2[包 2: DEF] end subgraph 网络 N1[ABCDEF] // 粘包 N2[A, BC, DEF] // 半包 end P1 --> N1 P2 --> N1 P1 --> N2 P2 --> N2

四、如何解决粘包问题#

4.1 方案一:固定长度#

# 发送方:固定长度 100 字节,不足补空格
def send_fixed(sock, data):
if len(data) < 100:
data = data + b' ' * (100 - len(data))
sock.send(data)
# 接收方:固定长度接收
def recv_fixed(sock):
data = b''
while len(data) < 100:
chunk = sock.recv(100 - len(data))
data += chunk
return data.strip()

优点:简单 缺点:浪费带宽

4.2 方案二:分隔符#

# 使用换行符作为分隔符
def send_line(sock, data):
sock.sendall(data + b'\n')
def recv_line(sock):
data = b''
while True:
chunk = sock.recv(1)
if chunk == b'\n':
return data
data += chunk

优点:直观 缺点:数据中不能包含分隔符

4.3 方案三:长度前缀(推荐)#

import struct
def send_length_prefixed(sock, data):
# 先发送 4 字节长度
length = len(data)
sock.sendall(struct.pack('!I', length) + data)
def recv_length_prefixed(sock):
# 先读取 4 字节长度
length_data = recv_exact(sock, 4)
length = struct.unpack('!I', length_data)[0]
# 再读取对应长度的数据
return recv_exact(sock, length)
def recv_exact(sock, n):
data = b''
while len(data) < n:
chunk = sock.recv(n - len(data))
if not chunk:
raise ConnectionError("Connection closed")
data += chunk
return data

优点:高效、通用 缺点:需要计算长度

4.4 方案四:消息边界协议#

// Protocol Buffers 自动处理消息边界
message Packet {
required int32 length = 1;
required bytes payload = 2;
}

五、应用层协议设计#

5.1 HTTP/1.1 的处理#

HTTP/1.1 使用Content-Length 头处理粘包:

HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 13
Hello, World!

5.2 WebSocket 的处理#

WebSocket 使用来定义消息边界:

┌────────┬─────────┬───────────┐
│ Opcode │ Length │ Payload │
│ 1 byte │ 1-8B │ Variable │
└────────┴─────────┴───────────┘

5.3 自定义协议#

# 协议格式:Header(4B) + Body(N bytes)
# Header: [Version(1B)][Type(1B)][Length(2B)]
HEADER_SIZE = 4
def send_packet(sock, packet_type, body):
header = bytes([1, packet_type, len(body) >> 8, len(body) & 0xFF])
sock.sendall(header + body)
def recv_packet(sock):
header = recv_exact(sock, HEADER_SIZE)
version, ptype, length = header[0], header[1], header[2] << 8 | header[3]
body = recv_exact(sock, length)
return ptype, body

六、总结#

6.1 粘包问题的本质#

问题原因
粘包Nagle 算法、滑动窗口、缓冲区合并
半包单次 Recv 小于数据包大小

6.2 解决方案#

方案适用场景
固定长度固定大小消息
分隔符文本协议,消息不含分隔符
长度前缀二进制协议,最通用
消息边界协议复杂协议

核心原则应用层必须自己定义消息边界,TCP 只保证字节流的可靠传输,不保证消息边界。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

为什么 TCP 协议有粘包问题
https://blog.souloss.com/posts/why-the-design/why-tcp-has-packet-merging-problem/
作者
Souloss
发布于
2023-06-23
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时