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 行情为例,假设会话期望序列号为 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 毫秒即重复”这类规则,会在高并发下误杀正常消息。更稳妥的做法是把时间戳作为辅助证据:业务键相同、内容相同、且交易所时间一致,才判重复;时间只用于判断消息是否过期、是否严重乱序。
五、字段梳理的交付物
实际接入一个新行情源时,建议先产出一份字段语义对照表,把每个关键字段的“层级、是否单调、是否可重置、精度、用途”写清楚,再据此确定去重键与校验策略。下面是一份可直接填写的示例结构(字段值为教学假设):
| 字段 | 所属层 | 语义 | 是否单调 | 重置条件 | 在去重/校验中的用途 |
|---|---|---|---|---|---|
| MsgSeqNum(34) | 传输层 | 会话消息序号 | 是 | 登录协商、SequenceReset | 完整性校验、重传触发 |
| QuoteID(117) | 业务层 | 报价唯一标识 | 否 | 不重置 | 重复判定主键 |
| QuoteEntryID(299) | 业务层 | 报价明细标识 | 否 | 不重置 | 明细级去重 |
| TransactTime(60) | 时间层 | 交易所撮合时间 | 近似递增 | 不重置 | 内容一致性比对、时效判断 |
| SendingTime(52) | 时间层 | 发送方时间 | 近似递增 | 不重置 | 延迟统计,不单独判重 |
| 私有协议 seq | 传输层 | 按合约递增序号 | 是(分合约) | 服务重启可能归零 | 分合约序列校验 |