3. 粘包拆包问题演示:用 Python socket 写一个拆包 Demo

上一章我们聊了 TCP 的流式本质,说白了就是「数据没有边界」。这一章咱们直接动手,写个简单的客户端和服务端,亲眼看看拆包到底长什么样。

我个人习惯是「先看现象,再讲原理」。所以这一章不搞复杂框架,就用 Python 自带的 socket 模块,模拟一个典型的拆包场景。

3.1 场景设定:服务端一次收不完

假设客户端要发送一条 10000 字节的消息。服务端每次只读 1024 字节。你想想看,服务端一次 recv 肯定收不完,需要多次读取才能拼完整。这就是拆包。

我在项目中遇到过类似情况:早期做游戏服务器时,客户端上报玩家操作数据包,服务端用默认的 4096 缓冲区去读,结果大包被拆成好几段,逻辑层直接解析失败。嗯,从那以后我就养成了「先收完再解析」的习惯。

3.2 服务端代码:故意限制缓冲区

import socket

def start_server(host='127.0.0.1', port=8888):
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind((host, port))
    server.listen(5)
    print(f'[服务端] 监听 {host}:{port}')

    conn, addr = server.accept()
    print(f'[服务端] 收到连接: {addr}')

    # 故意只读 1024 字节,模拟拆包
    buffer_size = 1024
    data = b''
    while True:
        chunk = conn.recv(buffer_size)
        if not chunk:
            break
        data += chunk
        print(f'[服务端] 收到 {len(chunk)} 字节,累计 {len(data)} 字节')

    print(f'[服务端] 最终收到总字节数: {len(data)}')
    conn.close()
    server.close()

if __name__ == '__main__':
    start_server()

3.3 客户端代码:发送一个大包

import socket

def start_client(host='127.0.0.1', port=8888):
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client.connect((host, port))

    # 构造 10000 字节的消息
    message = b'A' * 10000
    print(f'[客户端] 发送 {len(message)} 字节')

    client.sendall(message)
    print('[客户端] 发送完成')

    client.close()

if __name__ == '__main__':
    start_client()

3.4 运行效果:亲眼看看拆包

先启动服务端,再启动客户端。你会看到类似这样的输出:

[服务端] 监听 127.0.0.1:8888
[服务端] 收到连接: ('127.0.0.1', 54321)
[服务端] 收到 1024 字节,累计 1024 字节
[服务端] 收到 1024 字节,累计 2048 字节
[服务端] 收到 1024 字节,累计 3072 字节
[服务端] 收到 1024 字节,累计 4096 字节
[服务端] 收到 1024 字节,累计 5120 字节
[服务端] 收到 1024 字节,累计 6144 字节
[服务端] 收到 1024 字节,累计 7168 字节
[服务端] 收到 1024 字节,累计 8192 字节
[服务端] 收到 1024 字节,累计 9216 字节
[服务端] 收到 784 字节,累计 10000 字节
[服务端] 最终收到总字节数: 10000

看到了吗?客户端一次 sendall 发送了 10000 字节,但服务端分了 10 次才收完。前 9 次每次 1024 字节,最后一次 784 字节。这就是典型的拆包现象。

核心结论:TCP 是流式协议,没有消息边界。服务端 recv 返回的数据量,取决于底层缓冲区、网络状况和接收方设置的 buffer_size。你永远不能假设一次 recv 就能拿到完整消息。

3.5 为什么会出现拆包?

拆包的根本原因,我总结为三点:

  • 发送方缓冲区限制:操作系统 TCP 发送缓冲区有大小限制,大包可能被拆成多个 segment 发送。
  • 接收方缓冲区限制:服务端 recv 指定的 buffer_size 小于消息长度,一次读不完。
  • 网络 MTU 限制:IP 层最大传输单元(通常 1500 字节),超过 MTU 的 TCP segment 会被分片。

你想想看,如果客户端发送的是两个独立的小包,比如先发 100 字节,再发 200 字节,服务端一次 recv(1024) 可能把两个包一起读走。这就是粘包。拆包和粘包,本质是同一个问题的两面。

避坑指南:我曾经在写一个文件上传服务时,默认 recv(4096) 去读文件内容,结果大文件被拆成几十段,我直接用 data = conn.recv(4096) 去解析,解析器直接报错。后来改成循环 recv 直到收满指定长度,问题才解决。

3.6 拆包带来的实际问题

拆包如果不处理,直接后果就是:

问题 表现 后果
消息不完整 只收到消息的一部分 解析失败,程序崩溃
消息错位 两次 recv 的数据拼在一起 解析出错误数据
性能下降 频繁 recv 导致上下文切换 吞吐量降低

说白了,拆包不是 bug,它是 TCP 的固有特性。但如果你不处理它,它就会变成 bug。

3.7 本章核心流程图

下面这张图展示了拆包的完整过程:客户端发送 10000 字节,经过 TCP 协议栈和网络传输,服务端分 10 次 recv 才收完。

拆包现象流程图 客户端 sendall(10000字节) TCP 协议栈 拆成多个 segment 服务端 recv(1024) × 10次 拆包过程详解 第1次 recv:收到 1024 字节(0-1023) 第2次 recv:收到 1024 字节(1024-2047) ... 第9次 recv:收到 1024 字节(8192-9215) 第10次 recv:收到 784 字节(9216-9999) 累计 10000 字节,消息完整

3.8 小结:拆包不可怕,可怕的是不处理

这一章我们用最朴素的代码演示了拆包现象。说白了,拆包就是 TCP 把大消息切成了小块,服务端需要多次 recv 才能收全。

下一章我们会演示粘包现象——两个独立的小包被合并成一个 recv 读走。到时候你会发现,粘包和拆包其实是同一个硬币的两面。

重要提醒:千万不要在生产代码里用一次 recv 去解析消息。一定要循环 recv,直到收满预期长度,或者根据自定义协议边界来判断消息完整性。否则,拆包会让你 debug 到怀疑人生。