行情的传输底座是 UDP 组播:不保证到达、不保证顺序、不防重复。接受这个事实之后,行情系统的可靠性工程就收敛成一个问题的多种形态:靠序列号发现异常,按异常类型选恢复策略。这篇把每条异常路径走一遍。
序列号:唯一的真相来源
每个频道独立维护一个单调递增的序列号(u32 或 u64),每条消息一个号。接收方只维护一个期望值:
$$ \text{expected} = \text{last} + 1 $$
每条消息到达,先比序列号,再谈正文。三种比对结果对应三种异常:
seq == expected:正常,处理,前进;seq > expected:出现空洞(丢了或者乱序晚到);seq <= last:重复或迟到太久,丢弃。
u32 序列号会绕回(一天几十亿条的消息量不至于,但多日复位不规范的实现会踩到),比较要用无符号模运算 $(a - b) \bmod 2^{32}$ 判断大小,直接有符号相减在绕回点附近会反。
空洞:补包、等快照、还是认栽
发现 seq > expected,先在乱序窗口里等等。UDP 乱序是常态,做一个长度几十条的环形缓冲,把乱序到达的消息暂存,窗口内的空洞可能被后续到达填满。窗口等不到,升级处理:
补包请求。交易所网关提供重传窗口,接收机可以请求重发缺失区间——但窗口极短(秒级),因为重传要占用洪峰带宽,交易所只愿意垫付很小的量。请求要迅速:检测到空洞立即发,犹豫几秒窗口就关了。
等快照覆盖。补不回来就靠下一张快照。快照是全量状态,序号空洞里的所有变化会被快照的最终状态吸收。代价是这段时间的逐笔数据永久缺失——对盘口展示无所谓(快照一覆盖就是对的),对订单流分析是洞(那几笔成交再也回不来)。所以订单流类下游必须注册"数据缺口事件",缺口时段的分析结果要标脏。
认栽并告警。连续空洞、快照也等不到,说明链路真的断了,进入断线流程。
断线重连:心跳判定与状态重置
断线的判定靠心跳:频道静默超过 $k$ 个心跳周期(通常 $k=3$)即判离线。难点在重连之后——第一件事永远是拿全量快照,而不是从断点续。断线期间本地快照已经和真实市场偏离,继续吃增量只会让错误累积。重连状态机:
断线判定 → 标记本地状态为脏 → 重连 → 请求/接收快照 →
快照应用后清脏标记 → 从快照序号+1 恢复增量
"标记脏"这个中间态很重要:脏状态下的数据如果推给了下游,客户端看到的就是假盘口(尤其价格还在跳,跳的方向还和真实市场相反)。所以行情服务器要把数据质量状态做成一等公民广播出去——下游客户端显示"行情延迟/恢复中"标识,靠的就是这个通道,而不是下游自己猜。
重复与迟到
重复消息靠序号去重:$seq \le last$ 直接丢。但"迟到"的消息要小心分类——落在 (expected, last] 区间外但还在乱序窗口里的,是乱序;越过窗口的迟到消息和重复无法区分,统一丢弃。这意味着乱序窗口大小的选择是可用性权衡:窗口大,能救回的乱序多,但延迟了正常消息的处理(要等窗口期确认不补洞);窗口小,正常消息快,但轻微乱序就变成空洞触发补包风暴。生产上窗口取几十毫秒量级,和专线延迟的抖动方差匹配。
主备切换:序列号衔接的坑
券商通常接主备两路行情(同一交易所的两个不同物理链路或两个接收机)。最坑的场景是主源挂了切备源:两路源的序列号空间相互独立,备源的当前序号和主源的断点没有半点关系。切换瞬间,接收方看到的不是"从 10005 继续"而是"毫无征兆的 802341"——如果按普通空洞逻辑处理,要么触发不可能的补包,要么等一张快照。
正确处理是切换也走"全量快照重置"路径:切源 = 状态置脏 + 快照同步 + 新序号空间重新锚定。两个源的序号空间在配置层就要声明独立,不能共享一个 expected。更深一层的防御是双路并行合并:主备同时收,按序号各维护状态,定期互 diff(《盘口(十档)数据结构》末尾提过),一路断了零延迟切另一路的最新快照。多花的带宽买切换的确定性,这笔账在行情服务商那里是必付的。
把异常做成可观测
所有异常路径都要出指标:空洞频率、补包成功率、乱序窗口命中率、断线次数、切源次数、脏状态时长。这些是行情系统的体检表——空洞频率突然升高往往先于用户投诉暴露专线劣化;切源次数增多是接收机硬件老化的早期信号。《行情系统的容量与降级》讲的过载保护,触发条件里也有一半来自这些指标。