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 才收完。
3.8 小结:拆包不可怕,可怕的是不处理
这一章我们用最朴素的代码演示了拆包现象。说白了,拆包就是 TCP 把大消息切成了小块,服务端需要多次 recv 才能收全。
下一章我们会演示粘包现象——两个独立的小包被合并成一个 recv 读走。到时候你会发现,粘包和拆包其实是同一个硬币的两面。
重要提醒:千万不要在生产代码里用一次 recv 去解析消息。一定要循环 recv,直到收满预期长度,或者根据自定义协议边界来判断消息完整性。否则,拆包会让你 debug 到怀疑人生。