4、二进制数据格式入门:struct模块打包/解包、字节序(Big/Little Endian)、定长与变长编码
各位同学,欢迎来到第四讲。
前面我们聊了文本格式的局限,也看了内存里数据的样子。今天咱们直接上手,把Python对象变成二进制字节流。说白了,就是让数据“瘦身”,变成机器最爱的模样。
这一章,我会带你搞定三个核心问题:怎么打包解包、字节序到底是个啥、以及定长和变长编码怎么选。这些都是高频交易数据传输的基石,搞不懂后面寸步难行。
4.1 struct模块:打包与解包
Python自带的struct模块,就是干这个的。它能把Python的整数、浮点数、字节串,按照指定格式,打包成二进制字节流。反过来,也能把字节流解包成Python对象。
我个人习惯用struct.pack()和struct.unpack()这两个函数。先看个最简单的例子:
import struct
# 打包:把一个整数和一个浮点数打包成二进制
packed_data = struct.pack('<if', 42, 3.14)
print(packed_data) # 输出:b'*\x00\x00\x00\xc3\xf5H@'
# 解包:把二进制数据解包回来
unpacked_data = struct.unpack('<if', packed_data)
print(unpacked_data) # 输出:(42, 3.14)
你看,'<if'这个字符串就是格式说明符。'<'表示小端字节序,'i'表示一个4字节有符号整数,'f'表示一个4字节浮点数。打包后,42变成了b'*\x00\x00\x00',3.14变成了b'\xc3\xf5H@'。
'i'是有符号整数,'I'是无符号整数。我刚开始用的时候,经常搞混,导致解包出来的数据完全不对。
解包时,返回的是一个元组。哪怕只打包了一个值,解包后也是元组。这个细节要注意,不然容易踩坑。
4.2 字节序:Big Endian vs Little Endian
字节序,说白了就是多字节数据在内存里怎么排列。比如一个4字节的整数0x12345678:
- Big Endian(大端):高位字节在低地址。内存里看到的是
12 34 56 78。就像我们写数字一样,从左到右是高位到低位。 - Little Endian(小端):低位字节在低地址。内存里看到的是
78 56 34 12。Intel和AMD的CPU都用这个。
为什么会这样?嗯,这跟CPU设计有关。小端模式在做某些算术运算时,硬件实现更简单。但网络传输标准用的是大端,也就是我们常说的“网络字节序”。
我在项目中遇到过一个大坑。有一次,我们跟一个交易所对接,他们发过来的行情数据是大端,我这边默认用小端解析。结果所有价格都翻了好几倍,差点把交易逻辑搞崩。从那以后,我每次对接新协议,第一件事就是确认字节序。
'<'(小端)和'>'(大端)显式指定,不要依赖默认值。
struct模块里,字节序的指定方式如下:
| 字符 | 字节序 | 说明 |
|---|---|---|
@ |
本地 | 跟系统一致,通常是小端 |
= |
本地 | 跟系统一致,但不对齐 |
< |
小端 | Little Endian |
> |
大端 | Big Endian |
! |
网络 | 大端,等同于> |
我个人建议,永远显式指定字节序。别偷懒用@,否则换个机器跑,结果可能就不一样了。
4.3 定长编码 vs 变长编码
定长编码,就是每个字段占固定字节数。比如一个价格字段,永远用8字节双精度浮点数。好处是解析快,位置固定,CPU可以随机访问。坏处是浪费空间,比如价格只有1.23,你也得用8字节存。
变长编码,就是根据数据大小动态调整字节数。比如用Varint编码,小整数只占1个字节,大整数占多个字节。好处是省空间,坏处是解析慢,因为不知道下一个字段从哪开始。
在高频交易场景里,怎么选?
- 定长编码:适合固定长度的消息头、时间戳、价格等。解析速度快,延迟可控。
- 变长编码:适合字符串、不定长数组、或者数值范围很大的字段。节省带宽,但解析开销大。
我曾经在一个项目里,为了省带宽,把所有的价格字段都改成了变长编码。结果呢?解析时间增加了30%,延迟抖动也变大了。后来我们做了个折中:对大部分字段用定长,只有那些确实会很大的字段(比如订单ID)才用变长。
4.4 实战:设计一个简单的二进制协议
光说不练假把式。咱们来设计一个简单的行情数据包,包含:
- 消息类型(1字节,定长)
- 时间戳(8字节,定长,纳秒级)
- 价格(8字节,定长,双精度浮点数)
- 数量(4字节,定长,无符号整数)
- 股票代码(变长,字符串)
代码实现如下:
import struct
def pack_market_data(msg_type, timestamp, price, volume, symbol):
# 定长部分:消息类型 + 时间戳 + 价格 + 数量
fixed_part = struct.pack('<BQdI', msg_type, timestamp, price, volume)
# 变长部分:股票代码,先编码成UTF-8字节串
symbol_bytes = symbol.encode('utf-8')
# 用2字节存储字符串长度(最大65535字节)
symbol_length = struct.pack('<H', len(symbol_bytes))
return fixed_part + symbol_length + symbol_bytes
def unpack_market_data(data):
# 先解固定部分:1 + 8 + 8 + 4 = 21字节
fixed_size = 21
msg_type, timestamp, price, volume = struct.unpack('<BQdI', data[:fixed_size])
# 再解变长部分:先读2字节长度
symbol_length = struct.unpack('<H', data[fixed_size:fixed_size+2])[0]
# 然后读字符串内容
symbol = data[fixed_size+2:fixed_size+2+symbol_length].decode('utf-8')
return msg_type, timestamp, price, volume, symbol
# 测试
packed = pack_market_data(1, 123456789012345678, 100.50, 500, 'AAPL')
print(f'打包后长度:{len(packed)} 字节')
unpacked = unpack_market_data(packed)
print(f'解包结果:{unpacked}')
你看,定长部分解析起来多简单,直接按偏移量切分就行。变长部分就得先读长度,再读内容,多了一步操作。
4.5 性能对比:定长 vs 变长
咱们做个简单的性能测试,看看差距有多大:
import timeit
# 定长版本:所有字段固定长度
def pack_fixed():
return struct.pack('<BQdI10s', 1, 1234567890, 100.5, 500, b'AAPL')
# 变长版本:字符串变长
def pack_variable():
symbol = b'AAPL'
return struct.pack('<BQdIH', 1, 1234567890, 100.5, 500, len(symbol)) + symbol
# 测试100万次
fixed_time = timeit.timeit(pack_fixed, number=1000000)
variable_time = timeit.timeit(pack_variable, number=1000000)
print(f'定长打包:{fixed_time:.4f} 秒')
print(f'变长打包:{variable_time:.4f} 秒')
结果很明显,定长比变长快不少。在高频交易里,每微秒都很宝贵。所以我的建议是:能用定长,就别用变长。
好了,这一章的内容就到这里。你想想看,二进制数据格式其实没那么神秘,就是按照约定好的规则,把数据塞进字节流里。下一章,我们会聊更高级的序列化工具,比如Protocol Buffers和FlatBuffers,它们能帮你更高效地处理复杂数据结构。
记住,动手写代码才是最好的学习方式。去试试吧!