跳至内容

TCP 粘包问题

TCP 是基于字节流的协议,没有消息边界概念。当发送端连续发送多条消息,或接收端读取不及时时,可能把多条数据合并成一条接收——这就是粘包(Sticky Packet)

粘包产生原因

  • 发送端:Nagle 算法会将多次间隔短、数据小的 send 合并成一个大包发出。
  • 接收端recv(n) 每次最多读 n 字节,多条数据堆积在缓冲区时,一次 recv 可能跨越多条消息。

UDP 不会粘包:UDP 面向消息,每个数据报有独立边界,recvfrom 一次只取一个完整数据报。

何时需要解决

  • 需要解决:实时通讯、命令/响应协议——必须知道每条消息的边界。
  • 不需要解决:文件传输——只要最终把所有字节拼起来就行,无需分辨边界。

解决方案:固定长度报头(struct)

struct.pack("i", n) 将整数 n 打包成固定 4 字节,接收方先读 4 字节得到消息长度,再读该长度的数据,从而精确分割消息。

基础版(纯长度报头)

服务端

import socket
import struct

def run_server(host: str = "127.0.0.1", port: int = 9010) -> None:
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind((host, port))
    server.listen(5)
    print(f"服务器监听 {host}:{port}")

    conn, addr = server.accept()
    print(f"客户端连接: {addr}")

    while True:
        raw_cmd = conn.recv(1024)
        if not raw_cmd:
            break
        cmd = raw_cmd.decode("utf-8")
        print(f"收到命令: {cmd}")

        reply = f"已执行: {cmd}".encode("utf-8")

        # 先发 4 字节长度,再发真实数据
        conn.send(struct.pack("i", len(reply)))
        conn.sendall(reply)

    conn.close()
    server.close()

if __name__ == "__main__":
    run_server()

客户端

import socket
import struct

def run_client(host: str = "127.0.0.1", port: int = 9010) -> None:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
        client.connect((host, port))

        commands = ["ls -la", "pwd", "date"]
        for cmd in commands:
            client.send(cmd.encode("utf-8"))

            # 先读 4 字节获取消息长度
            raw_len = client.recv(4)
            msg_len = struct.unpack("i", raw_len)[0]

            # 按长度循环读取,防止单次 recv 不足
            data = b""
            while len(data) < msg_len:
                chunk = client.recv(min(1024, msg_len - len(data)))
                if not chunk:
                    break
                data += chunk

            print(f"服务端回复: {data.decode('utf-8')}")

if __name__ == "__main__":
    run_client()

进阶版(JSON 报头 + 真实数据)

对于复杂场景(文件传输、多字段元数据),可将报头设计为 JSON 字典:

import socket
import struct
import json
from pathlib import Path

def send_with_header(conn: socket.socket, payload: bytes, metadata: dict) -> None:
    """发送:4字节报头长度 + JSON报头 + 真实数据。"""
    head_json = json.dumps(metadata, ensure_ascii=False).encode("utf-8")
    # 先发报头长度(4 字节)
    conn.send(struct.pack("i", len(head_json)))
    # 再发 JSON 报头
    conn.send(head_json)
    # 最后发真实数据
    conn.sendall(payload)

def recv_with_header(conn: socket.socket) -> tuple[dict, bytes]:
    """接收:先读报头长度,再读 JSON 报头,最后读真实数据。"""
    raw = conn.recv(4)
    head_len = struct.unpack("i", raw)[0]
    head_json = conn.recv(head_len).decode("utf-8")
    metadata = json.loads(head_json)

    data_len = metadata["size"]
    data = b""
    while len(data) < data_len:
        chunk = conn.recv(min(4096, data_len - len(data)))
        if not chunk:
            break
        data += chunk
    return metadata, data

struct 格式速查

import struct

# pack(fmt, value) → bytes,unpack(fmt, bytes) → tuple
struct.pack("i", 12345)          # int  → 4 bytes
struct.pack("I", 12345)          # unsigned int → 4 bytes
struct.pack("q", 9_999_999_999)  # long long → 8 bytes(更大范围)

tup = struct.unpack("i", struct.pack("i", 12345))
print(tup[0])   # 12345

# calcsize 查询格式字节数
print(struct.calcsize("i"))   # 4
print(struct.calcsize("q"))   # 8

"i" 的范围约 ±21 亿,对于大文件传输建议用 "q"(±9.2 × 10^18)。

最后更新于