技术博客

行情协议解析:二进制流的快照与增量

第 72 / 141 篇 · 2025-05-24 · secfin · 行情系统协议解析二进制协议

上一篇讲了行情链路每一跳在传什么,这一篇往包里看。行情协议是一门"上古"手艺:定长字段、整型定点数、按位标志,和 FIX 那种 tag=value 的文本协议完全是两个世界。这不是历史包袱,而是带宽和延迟逼出来的最优解——沪深 Level-2 全市场一天几十 GB,逐字节都是钱。

为什么不用 JSON

算笔账。假设一个证券的盘口增量消息 60 字节,JSON 表示同样的字段(字段名、引号、括号)轻松膨胀到 400 字节。全市场 5000 只证券、洪峰时每秒几十万条消息,带宽差出 6~7 倍。更致命的是解析成本:JSON 要逐字节扫、建对象树、哈希查字段;二进制定长记录只要算出偏移量,直接指针强转。

$$ \text{解析一条消息的成本:JSON } O(n) \text{ 次内存分配} \; \gg \; \text{定长二进制 } O(1) \text{ 次指针强转} $$

而且在 C/C++ 实现的接收机上,解析器和发送端共享同一份结构体头文件——内存布局即协议,没有序列化层。代价是协议僵硬:加一个字段要改所有上下游,这就是后面要讲的版本管理问题。

一条增量消息的字节布局

典型结构分头部和正文。头部定长,描述"这是什么消息";正文按消息类型解释。

头部一般是:序号(u32,每频道独立递增)、频道号(u16,交易所把全市场切成多个频道并行发)、发送时间戳(u64 纳秒)、消息类型(u8)、正文长度(u16)。正文按类型不同:委托增量带证券代码、档位价、档位量、更新类型(新增/修改/删除);成交增量带成交价、成交量、主动买/卖标志。

定长布局有个反直觉的坑:对齐填充(padding)。如果发送端结构体按 8 字节对齐编译,字段之间会插入编译器塞的洞,协议文档写的偏移量和线上字节就对不上。工程上的稳妥做法是 #pragma pack(1) 或对每个字段显式 memcpy 到偏移量,两边都别信编译器的默认布局。我自己在接收机上踩过一次:开发机 x86 和接收机 ARM 的默认对齐不同,同一头文件编出两个协议。

价格是定点数,不是 float

行情里看不到 double 价格,全是放大 $10^k$ 倍的整数:

$$ p_{\text{int}} = \left\lfloor p \cdot 10^k \right\rceil, \qquad k = 4 \;\text{或}\; 3 $$

14.32 元在线上就是 int64 的 143200。三个原因。一是精度:double 是二进制浮点,0.1 都存不精确,逐笔累加金额会漂;定点整数做加减乘除全程无舍入误差。二是比较:整数比较一条指令,而且排序、索引天然成立。三是可移植性:浮点格式跨语言跨端有差异(ARM 老平台的 extended precision 是血泪史),整数没有歧义。数量同理,u64 或 u32 原始单位(股/张)。注意汇率型数据(港股通)放大倍数可能是 $10^6$,解析器必须按协议文档逐字段查精度位,不能想当然。

快照与增量:两种消息语义

协议层面最核心的二分是快照消息和增量消息。增量只带变化的档位或成交,字段少、频率高,是盘中流量的主体;快照是某个时刻盘口的全量(十档买一卖一全列出来),在开盘前和盘中定期发,作用是兜底——增量流丢了、乱了、新接入的终端,都靠快照恢复确定性。

本地重建盘口的状态机很直白:收到快照,整体覆盖本地状态;收到增量,按更新类型改对应档位。能不能正确处理"先收了几条增量、再收到一张快照、快照序号还小于增量序号"这类交错,是解析器和异常处理模块的分界线(《行情异常处理》专门讲)。

带宽账:一张十档快照约 300 字节,一条增量平均 50 字节。若某证券平均每秒变动 20 个档位,纯快照模式要 6000 B/s,快照+增量只要 1000 B/s 加摊薄的快照成本。行情洪峰时这个差值再乘十倍,就是专线带宽的采购差额。

版本与兼容

协议会演进,字段要加。通行做法是头部带协议版本号,新字段追加在消息尾部,老版本解析器按旧长度切包,多出来的字节跳过。废弃字段不回填新含义——线上永远有没升级的终端,一个字段两种语义是协议设计的死刑。标志位用 u32 位图,而不是一堆布尔 u8,省空间也避免"占坑字段"。

解析器的最小实现

伪代码不长,但每个分支都是生产事故换来的:

# 接收循环:buf 是 UDP 收到的字节
while off < len(buf):
    seq, channel, ts, mtype, blen = struct.unpack_from("<I H Q B H", buf, off)
    if seq != expected_seq:        # 序列号空洞,转异常处理
        handle_gap(channel, seq, expected_seq)
    body = buf[off + 23 : off + 23 + blen]
    if mtype == MSG_SNAPSHOT:
        apply_snapshot(channel, body)
    else:
        apply_increment(channel, body)
    expected_seq = seq + 1
    off += 23 + blen

小端(<)是境内交易所行情的惯例,和 x86 一致所以"指针强转"可行;但解析代码里显式写字节序,别依赖宿主机碰巧也是小端。expected_seq 的维护是解析器的命门:它是检测丢包、乱序、重复的唯一依据,必须在解析任何正文字段之前先校验。

增量消息字节布局与两类语义头部(定长 23B)SeqNo u324B频道 u162B时间戳 ns u648B类型 u81B正文长 u162B保留6B正文(按消息类型解释)证券代码 u648B更新类型 u8新增/改/删档位价 int64×10^4档位量 u64原始单位成交笔数 u32合并笔数校验和 u32CRC32快照:某时刻盘口全量,开盘前/定期发,带宽大但自恢复,新终端靠它初始化增量:只带变化档位/成交,平均 50B 一条,是盘中流量主体,丢失靠快照兜底解析顺序:先校验 SeqNo(空洞转异常处理),再按类型解释正文,与正文内容无关