4. 行情订阅模型

在WebSocket实时行情系统中,连接建立只是第一步,真正决定系统可用性与数据一致性的,是订阅模型的设计。一个成熟的行情订阅模型需要同时解决四个问题:谁订阅了什么、如何拿到初始状态、后续变化如何推送、连接是否还活着。本节围绕这四个问题展开,帮助你建立可落地的订阅协议设计思路。

一、核心原理:订阅是"状态+通道"的组合

行情订阅本质上是在服务端维护一份"客户端—标的—频道"的映射关系。客户端通过订阅消息声明自己关心的数据范围,服务端据此将对应的行情推送到该连接。这里有两个关键维度:

  • 频道(Channel):数据的类型或主题,例如 ticker(逐笔成交)、depth(深度盘口)、kline(K线)、trade(成交明细)。频道决定了数据的结构和推送频率。
  • 标的(Symbol/Instrument):具体的交易对象,例如 BTC-USDT、600519.SH、IF2406。标的决定了数据的归属。

订阅关系可以表示为 (channel, symbol) 的二元组集合。客户端可以一次订阅多个组合,也可以按频道批量订阅多个标的。服务端需要为每个连接维护这个集合,并在标的行情变化时,遍历订阅者列表进行推送。

设计要点:订阅关系应作为连接级别的会话状态保存,而不是全局状态。连接断开时,该连接的订阅集合应被自动清理,避免内存泄漏和无效推送。

二、订阅与取消订阅:协议设计

订阅和取消订阅通常采用请求—响应模式,客户端发送操作指令,服务端返回确认结果。一个典型的订阅消息结构如下:

{
  "op": "subscribe",
  "args": [
    {"channel": "ticker", "symbol": "BTC-USDT"},
    {"channel": "depth", "symbol": "ETH-USDT"}
  ],
  "reqId": "sub-001"
}

服务端返回确认:

{
  "event": "subscribe",
  "arg": {"channel": "ticker", "symbol": "BTC-USDT"},
  "result": true,
  "reqId": "sub-001"
}

取消订阅使用 op: "unsubscribe",参数结构与订阅一致。设计时需要注意以下几点:

  1. 幂等性:重复订阅同一组合应返回成功而非报错,避免客户端重连后重复订阅导致状态混乱。
  2. 部分成功:批量订阅中某个标的无效时,应逐条返回结果,而不是整体失败。
  3. reqId 关联:每个请求携带唯一 reqId,便于客户端将响应与请求对应,尤其在异步推送场景下。
  4. 订阅上限:服务端应限制单连接的最大订阅数,防止资源滥用。

三、快照与增量:数据一致性的关键

订阅成功后,客户端首先需要一份快照(Snapshot),即当前时刻的完整行情状态。之后服务端持续推送增量(Delta/Update),即发生变化的部分。快照与增量的配合是保证数据一致性的核心。

数据类型 快照内容 增量内容 一致性要求
ticker 最新价、买一卖一、成交量 价格或量变化时推送全字段 可直接覆盖
depth 完整买卖盘口档位 变化的档位及数量 需按版本号合并
kline 当前周期K线完整数据 当前K线更新或新K线 按时间戳对齐
trade 最近N笔成交 新增成交记录 按成交ID去重

对于深度数据,增量合并必须依赖版本号(seq/version)。服务端在快照中携带当前版本号,增量消息也携带版本号。客户端检查版本号是否连续,若出现跳变,说明中间丢失了消息,必须重新订阅并拉取快照。这是行情系统中最容易出错的环节。

常见误区:客户端收到增量后直接覆盖本地盘口,而不校验版本号。一旦网络抖动导致丢包,本地盘口将与服务端永久不一致,且无法自愈。

四、心跳与保活:连接的生命线

WebSocket连接可能因网络中断、代理超时、服务端重启等原因静默断开。心跳机制用于检测连接是否仍然可用。常见方案有两种:

  • 协议层 Ping/Pong:WebSocket协议原生支持,服务端发送Ping帧,客户端回复Pong帧。浏览器端无法主动发送Ping,通常由服务端发起。
  • 应用层心跳:客户端定时发送 {"op":"ping"},服务端回复 {"op":"pong"}。这种方式更灵活,可携带时间戳用于计算延迟。

保活参数建议:心跳间隔 15~30秒,服务端超时阈值设为心跳间隔的 2~3倍。若超过阈值未收到心跳,服务端主动关闭连接;客户端若连续多次未收到Pong,应主动重连。

# 客户端心跳日志示例
[12:00:00] send ping, ts=1710000000
[12:00:00] recv pong, ts=1710000000, rtt=12ms
[12:00:15] send ping, ts=1710000015
[12:00:15] recv pong, ts=1710000015, rtt=15ms
[12:00:30] send ping, ts=1710000030
[12:00:33] no pong, retry=1
[12:00:36] no pong, retry=2, closing connection

五、综合案例:一次完整的订阅生命周期

假设客户端要订阅 BTC-USDT 的 ticker 和 depth:

  1. 建立WebSocket连接,等待 open 事件。
  2. 发送订阅请求,携带 reqId。
  3. 收到订阅确认,同时收到 ticker 快照和 depth 快照(含版本号)。
  4. 持续接收增量推送,ticker 直接覆盖,depth 按版本号合并。
  5. 每15秒发送一次应用层心跳,监控Pong响应。
  6. 若心跳超时或连接关闭,触发重连流程:重新建立连接、重新订阅、重新拉取快照。
重连后的订阅恢复是断线恢复的核心:客户端应保存订阅列表,在连接重建后自动重新发送订阅请求,并等待新的快照到达后再恢复增量处理,避免使用旧快照与新增量混合。
行情订阅模型:连接生命周期与数据流 客户端 订阅列表管理 快照缓存 增量合并 心跳检测 重连恢复 服务端 订阅关系表 快照生成 增量推送 心跳响应 超时清理 subscribe(channel, symbol, reqId) snapshot + version delta(seq, data) ping pong reconnect + resubscribe 版本号连续 → 合并增量 版本号跳变 → 重新拉取快照

六、常见误区与复盘

  • 误区一:订阅后立即处理增量,忽略快照。正确做法是先等待快照到达,再开始合并增量,否则本地状态缺少基准。
  • 误区二:取消订阅后仍收到推送。服务端应在处理取消请求后立即从订阅表中移除,并停止推送;客户端也应忽略已取消标的的残留消息。
  • 误区三:心跳间隔过短或过长。过短会增加无效流量,过长则故障发现延迟。建议根据网络环境在15~30秒之间调整。
  • 误区四:重连后不重新订阅。连接重建后,服务端不保留旧连接的订阅关系,客户端必须重新发送订阅请求。
  • 误区五:深度增量不做版本校验。这是导致盘口错乱的最常见原因,必须强制校验版本号连续性。

总结来看,一个健壮的行情订阅模型需要做到:订阅关系可管理、快照与增量可衔接、心跳可检测、断线可恢复。把这四点落实到协议设计和客户端状态机中,才能支撑起实时行情系统的稳定运行。