2. 行情消息结构剖析

要做行情流去重与序列校验,第一步不是写代码,而是把“一条行情消息到底由什么构成”拆清楚。不同协议的外壳差异很大,但内核通常都围绕四类信息组织:业务标识(这条行情描述哪个标的、哪个报价)、序列标识(这条消息在流中的位置)、时间信息(交易所生成时间、发送时间、接收时间)、状态与载荷(快照/增量、买卖盘、成交等)。去重与校验的判据,几乎全部从这四类字段中推导出来。

一、三类常见协议的消息字段组织方式

FIX 协议采用“标签=值”的文本或定长编码,字段以 SOH 分隔。行情场景常用 35=D 类消息或专门的行情消息,关键字段包括:MsgType(35)、MsgSeqNum(34)、SendingTime(52)、Symbol(55)、QuoteID(117)、QuoteEntryID(299)、BidPx(132)、OfferPx(133)、TransactTime(60)。其中 34 是会话级序列号,由会话双方按消息逐条递增维护;117/299 是业务级唯一标识,用于标识某一笔报价及其明细。二者语义完全不同:前者保证“会话内不丢不乱”,后者保证“业务对象可定位”。

FAST 协议面向低延迟,采用模板 ID(TemplateID)加字段指令(PMap)的压缩编码,字段本身不携带名字,靠模板定义还原。序列号通常放在报文头的 SeqNum 字段,时间戳多为交易所撮合时间。由于 FAST 大量使用增量压缩与复制算子,同一条逻辑消息在解码前后形态差异很大,去重必须在解码还原之后按业务键进行,而不能对原始字节做哈希。

二进制私有协议最常见的是“定长头 + 变长体”:头部含 Magic、版本、消息类型、消息长度、序列号、时间戳(多为纳秒或微秒整数),体部按约定偏移放置标的代码、行情类型、价格数量。它的优点是解析快,缺点是字段语义完全依赖协议文档,序列号可能是全局递增,也可能是按通道或按标的分别递增,必须逐协议确认。

判断序列号语义时,先问三个问题:它是会话级还是业务级?是全局单调还是分通道单调?重启或切换后会重置还是续接?这三个答案直接决定校验逻辑怎么写。

二、唯一标识、时间戳与序列号的语义分层

把字段按“层”理解,能避免大量误用。第一层是传输层序列,如 FIX 的 MsgSeqNum、私有协议的 seq,作用是检测丢包与乱序,通常要求严格连续。第二层是业务层标识,如 QuoteID、QuoteEntryID、成交编号,作用是定位一条具体行情,允许跳号,但同一标识不应重复出现。第三层是时间层,包括交易所时间(TransactTime/SendingTime)、网关发送时间、本地接收时间,三者往往存在毫秒级甚至更大偏差,去重时不能简单用“时间相同即重复”。

一个典型误区是把 MsgSeqNum 当作去重键。实际上重传机制下,同一条业务消息可能以新的序列号再次发送,此时序列号不同但业务内容相同,按序列号去重会漏判;反过来,序列号连续也不代表业务不重复。正确做法是:序列号用于完整性校验,业务键用于重复判定,两条线分开走。

行情消息字段分层与去重/校验分工 FIX 协议 35 / 34 / 52 / 55 / 117 FAST 协议 TemplateID + PMap + SeqNum 二进制私有协议 定长头 + 变长体 解码还原为统一字段视图 原始字节 → 结构化字段(去重在此之后进行) 传输层:序列号 MsgSeqNum / seq 要求连续、可检测丢包乱序 用途:完整性校验 业务层:唯一标识 QuoteID / QuoteEntryID / 成交编号 允许跳号,同一标识不应重复 用途:重复判定 时间层:时间戳 TransactTime / SendingTime / 接收时间 多源存在偏差,不可单独判重 用途:时效判断、乱序辅助 序列校验:期望值 vs 实际值 缺口 → 请求重传;回退 → 判定乱序或重启 去重判定:业务键 + 版本/时间 键相同且内容一致 → 丢弃;内容更新 → 覆盖 输出:有序、无重复的行情流

三、把字段语义落到去重与校验规则上

以 FIX 行情为例,假设会话期望序列号为 1001,实际收到 1003,说明 1001、1002 缺失,应触发重传请求而不是直接丢弃;若收到 999,则可能是对端重启导致序列号回退,需要走序列重置协商(如 35=4 的 SequenceReset)。与此同时,业务侧维护一个以 QuoteID 为键的近期窗口,窗口内若再次出现相同 QuoteID 且 TransactTime 与价格数量完全一致,判为重复;若 QuoteID 相同但内容变化,则视为更新,用新值覆盖并记录版本。

二进制私有协议中常见“按标的递增”的序列号,例如某交易所对每个合约单独维护 seq。此时校验不能用一个全局期望值,而要为每个合约维护独立的期望序列。工程上可用哈希表 map<合约代码, 期望seq> 承载,收到消息后按合约取出期望值比对,再回写期望值加一。若协议文档说明重启后序列号从 0 开始,则需结合登录响应或心跳中的重置标志清空该表。

// 示例:按业务键去重 + 按通道校验序列(伪代码,参数为教学假设)
struct QuoteKey { string symbol; long quoteId; };

map<QuoteKey, QuoteSnapshot> recentWindow;   // 近期窗口
map<string, long> expectedSeq;               // 按通道/合约维护期望序列

void onMessage(const Message& m) {
    // 1) 序列校验
    long& exp = expectedSeq[m.channel];
    if (m.seq == exp)      { exp = m.seq + 1; }
    else if (m.seq > exp)  { requestRetransmit(m.channel, exp, m.seq - 1); exp = m.seq + 1; }
    else                   { handlePossibleReset(m); return; }

    // 2) 业务去重
    QuoteKey k{m.symbol, m.quoteId};
    auto it = recentWindow.find(k);
    if (it != recentWindow.end()
        && it->second.transactTime == m.transactTime
        && it->second.bidPx == m.bidPx
        && it->second.offerPx == m.offerPx) {
        return; // 重复,丢弃
    }
    recentWindow[k] = toSnapshot(m);
    publish(m);
}

这段逻辑体现了前面强调的分工:序列号负责“有没有漏”,业务键负责“是不是同一条”。两者都通过,消息才进入下游。窗口大小需要按行情频率与内存预算设定,例如高频合约保留最近数万条,低频合约保留数千条,并配合时间淘汰,避免无限增长。

四、时间戳的常见陷阱

时间戳最容易出错的地方是精度与时钟源不一致。FIX 的 SendingTime 通常精确到毫秒,而二进制协议可能给到纳秒;交易所时间与本地接收时间之间还隔着网络与网关排队。若用“接收时间差小于 1 毫秒即重复”这类规则,会在高并发下误杀正常消息。更稳妥的做法是把时间戳作为辅助证据:业务键相同、内容相同、且交易所时间一致,才判重复;时间只用于判断消息是否过期、是否严重乱序。

不要对 FAST 或压缩协议的原始报文直接做哈希去重。压缩上下文、PMap 位图、复制算子都会让同一逻辑消息产生不同字节序列,哈希结果不可靠。必须先解码为统一字段视图,再按业务键判定。

五、字段梳理的交付物

实际接入一个新行情源时,建议先产出一份字段语义对照表,把每个关键字段的“层级、是否单调、是否可重置、精度、用途”写清楚,再据此确定去重键与校验策略。下面是一份可直接填写的示例结构(字段值为教学假设):

字段所属层语义是否单调重置条件在去重/校验中的用途
MsgSeqNum(34)传输层会话消息序号是登录协商、SequenceReset完整性校验、重传触发
QuoteID(117)业务层报价唯一标识否不重置重复判定主键
QuoteEntryID(299)业务层报价明细标识否不重置明细级去重
TransactTime(60)时间层交易所撮合时间近似递增不重置内容一致性比对、时效判断
SendingTime(52)时间层发送方时间近似递增不重置延迟统计,不单独判重
私有协议 seq传输层按合约递增序号是(分合约)服务重启可能归零分合约序列校验