3、数据包捕获:使用Python接收原始数据流、数据缓冲与粘包处理
好,咱们进入实战环节。前面讲了行情数据的格式,也讲了怎么从网卡抓包。但真正把数据从网络里「捞」出来,送到你的策略里,中间还有几个坑要填。
说白了,网络不保证你每次 recv() 拿到的都是一个完整的行情包。它可能给你半个包,也可能一次塞给你三个包。这就是所谓的粘包和拆包问题。
我刚开始做量化那会儿,就吃过这个亏。记得有一次回测跑得挺漂亮,一上实盘就报错。查了半天,发现是数据包没对齐,解析出来的价格全是乱码。嗯,从那以后,我再也不敢小看数据缓冲这步了。
3.1 原始数据流的接收方式
Python 里接收网络数据,最常用的就是 socket 模块。对于行情这种低延迟场景,我建议直接用 TCP 或 UDP 的原始 socket。
先看一个最简单的 TCP 接收示例:
import socket
def create_tcp_connection(host, port):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
sock.connect((host, port))
return sock
sock = create_tcp_connection('192.168.1.100', 8888)
buffer = b''
while True:
chunk = sock.recv(4096)
if not chunk:
break
buffer += chunk
# 后续处理...
这里有个细节:TCP_NODELAY 这个选项。我个人的习惯是,做行情接收时一定要开。它禁用了 Nagle 算法,避免因为小包合并而增加延迟。你想想看,行情数据本来就讲究毫秒级,再让系统帮你攒一攒再发,黄花菜都凉了。
3.2 数据缓冲区的设计
为什么需要缓冲区?因为网络数据是流式的。你调用 recv() 时,底层可能只到了 200 字节,但一个完整的行情包可能是 300 字节。这时候你就得先把这 200 字节存起来,等剩下的 100 字节到了再拼起来处理。
我常用的缓冲区设计有两种:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 字节串拼接 | 实现简单,代码直观 | 频繁拼接产生大量临时对象 | 低频行情,每秒几百笔 |
| bytearray 预分配 | 内存复用,GC压力小 | 需要管理读写指针 | 高频行情,每秒上万笔 |
对于大多数场景,字节串拼接就够了。但如果你做的是 Level-2 行情,每秒几万笔数据进来,我建议用 bytearray。我曾经在一个项目中,就因为频繁的 bytes 拼接导致 GC 停顿,行情延迟从 50 微秒飙到了 2 毫秒。嗯,这个教训挺深刻的。
来看一个 bytearray 缓冲区的实现:
class RingBuffer:
def __init__(self, size=65536):
self.buffer = bytearray(size)
self.read_pos = 0
self.write_pos = 0
self.size = size
def write(self, data):
length = len(data)
# 检查剩余空间
available = self.size - (self.write_pos - self.read_pos)
if length > available:
raise BufferError('缓冲区满了,需要扩容')
# 写入数据
end = self.write_pos + length
if end <= self.size:
self.buffer[self.write_pos:end] = data
else:
# 环形写入
first_part = self.size - self.write_pos
self.buffer[self.write_pos:] = data[:first_part]
self.buffer[:length - first_part] = data[first_part:]
self.write_pos = end % self.size
def read(self, length):
if length > self.available():
return None
if self.read_pos + length <= self.size:
data = bytes(self.buffer[self.read_pos:self.read_pos + length])
else:
first_part = self.size - self.read_pos
data = bytes(self.buffer[self.read_pos:] +
self.buffer[:length - first_part])
self.read_pos = (self.read_pos + length) % self.size
return data
def available(self):
return self.write_pos - self.read_pos
3.3 粘包处理的核心逻辑
粘包,说白了就是你一次 recv() 拿到的数据里包含了多个完整的包,或者一个包被拆成了多次 recv()。怎么解决?核心思路就四个字:定界分包。
常见的定界方式有三种:
- 固定长度:每个包都是 100 字节,直接按长度切分。简单粗暴,但浪费带宽。
- 长度前缀:包头里写明了包体长度。这是最常用的方式。
- 特殊分隔符:比如用 \r\n 结尾。适合文本协议,二进制协议不推荐。
行情协议绝大多数用的是第二种——长度前缀。比如深交所的行情,前 4 个字节就是包体长度。
来看一个通用的粘包处理函数:
def parse_packets(buffer, header_size=4):
"""
从缓冲区中解析出完整的包
返回: (完整包列表, 剩余未处理数据)
"""
packets = []
offset = 0
buffer_len = len(buffer)
while offset + header_size <= buffer_len:
# 读取包头中的长度字段
packet_length = int.from_bytes(
buffer[offset:offset + header_size],
byteorder='little'
)
# 检查是否收到了完整的包
if offset + header_size + packet_length > buffer_len:
break # 包还没收全,等下一次 recv()
# 提取完整包
full_packet = buffer[offset:offset + header_size + packet_length]
packets.append(full_packet)
offset += header_size + packet_length
# 剩余未处理的数据
remaining = buffer[offset:]
return packets, remaining
3.4 完整的数据接收循环
把上面这些串起来,就是一个完整的数据接收循环:
import socket
import struct
class MarketDataReceiver:
def __init__(self, host, port):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.sock.connect((host, port))
self.buffer = b''
def recv_loop(self):
while True:
try:
chunk = self.sock.recv(65536)
if not chunk:
print('连接已断开')
break
self.buffer += chunk
packets, self.buffer = parse_packets(self.buffer)
for packet in packets:
self.handle_packet(packet)
except socket.timeout:
continue
except Exception as e:
print(f'接收异常: {e}')
break
def handle_packet(self, packet):
# 这里交给具体的解码模块处理
# 比如解析出 股票代码、最新价、成交量 等
pass
这个循环看起来简单,但有几个地方要注意:
- recv 的超时设置:不要无限阻塞,设个 100ms 的超时,方便做心跳检测。
- 异常处理:网络环境复杂,断线重连是常态。我一般会加个重试机制,最多重试 3 次,每次间隔 1 秒。
- 缓冲区清理:如果某个包一直没收全,缓冲区会越来越大。我建议设个最大长度,超过就丢弃。
3.5 核心逻辑流程图
下面这张图,把整个数据接收、缓冲、粘包处理的流程串起来了。你可以对照着代码看,会更清楚。
3.6 避坑指南
最后,分享几个我踩过的坑:
- 不要假设 recv() 一次就能收全。TCP 是流协议,它不保证一次 recv() 就是一个完整的应用层包。我见过太多新手直接 recv(100) 然后当完整包处理,结果数据全是乱的。
- 小心内存泄漏。如果某个包一直没收全,缓冲区会无限增长。我一般设个上限,比如 10MB,超过就丢弃并重新连接。
- 多线程接收要加锁。如果你用多线程同时 recv(),缓冲区操作必须加锁。不过我个人建议用单线程 + 异步 IO,省心很多。
公众号:蓝海数据掘金营,微信deep3321