凌晨两点半,多数人已经睡了,我盯着屏幕上滚动的行情帧,心里想的却不是价格本身,而是那条 WebSocket 链路到底还能撑多久。这不是矫情——真实网络环境里,一条看起来"连接正常"的行情通道,很可能早就处于假死状态:TCP 层面没断,应用层却再也收不到任何推送。很多量化交易系统在接入实时期货数据时,一开始都觉得 WebSocket 无非是"建立连接、收消息、处理消息"三件事,真正跑起来才发现,订阅协议、心跳保活、断线重连、消息对齐、消费积压,每一步都有隐藏成本。
这篇东西就专注聊一件事:怎么把实时期货行情里的 WebSocket 链路做扎实。从选型逻辑讲到订阅机制,从心跳参数讲到真实踩坑,全程用我自己摸过的例子来说。适合正在自研量化系统、准备从模拟数据切到真实行情,或者已经在用 HTTP 轮询但想升级到 WebSocket 推送的开发者。如果你只是拿 wscat 连一下、能收到数据就觉得完事了,那这篇文章正好帮你补上后面那 90% 的活儿。
1. 为什么量化系统最后都绕不开 WebSocket
1.1 轮询的病灶:你永远比市场慢半拍
早期很多系统接期货行情,用的是 HTTP 轮询——每隔几百毫秒拉一次最新价格。这套方案能跑,但有一个根上的问题:轮询间隔决定了你的感知粒度。假如你 200ms 拉一次,而某合约在竞价阶段价格 50ms 就变一次,那么你的系统平均会比真实行情滞后几十甚至一百多毫秒。对日内策略来说,这个滞后不是"无所谓"的误差,而是实实在在的成本:挂单响应慢半拍,成交回报追不上,止损触发晚了几笔 tick,滑点就是这么一点一点吃出来的。
更麻烦的是,轮询的请求是突发的。整点、开盘、大行情瞬间,所有客户端都在抢着拉数据,服务端压力陡增,响应延迟跟着拉长。你调高轮询频率想跟上行情,带宽和 CPU 先吃不消;调低频率,实时性又没了。这个矛盾在轮询模型下无解,因为它天生是"客户端主动去问",而行情是"服务端随时要推"。
另一个被低估的问题是请求堆积。轮询间隔设置太短时,上一次请求还没返回,新一轮请求已经发出去,很多 HTTP 客户端库并不会帮你合并或排队,而是直接开新的连接。连接数一多,本地端口资源、服务端连接数、中间层代理都跟着紧张,最后系统的瓶颈往往不在行情数据本身,而在你用来拿数据的这套 HTTP 机制。
1.2 SSE 和长连接:方向对了一半
既然 HTTP 轮询不行,有人会想用 SSE(Server-Sent Events)——服务端单向推送,也是基于 HTTP 长连接,浏览器原生支持,实现起来比 WebSocket 简单。SSE 确实解决了"服务端主动推送"的问题,但它是单向的:客户端只能收,不能在同一通道上往服务端发消息。
问题就出在这里。期货行情接入不只是"收价格"这么简单,你需要动态订阅、退订合约,盘中心态变了要切品种,策略上线要临时加一组合约。如果用 SSE,订阅和退订就得另走一套 HTTP 接口,等于一个系统里维护两套通道。而且 SSE 的字段是文本为主,传二进制行情协议不太顺手,消息格式也相对受限。它在"服务端单向广播"场景下很好用,但期货行情这种强交互、需要精细化控制订阅列表的场景,SSE 就显得束手束脚。
HTTP 长连接本身也存在类似的尴尬:你保留一个连接不关,确实省了反复握手的开销,但消息边界要靠 Content-Length 或 chunked 编码来界定,实时消息和解码逻辑掺杂在一起,调试起来并不比 WebSocket 省心。换句话说,长连接解决了"连接复用",但没解决"双向实时通信"的模型问题。
1.3 WebSocket 的定位:全双工才是行情该有的形态
WebSocket 的核心优势不是"快"这么简单,而是它本身就是一个全双工通道:同一个连接上,客户端可以随时发订阅请求,服务端可以随时推行情数据,互不干扰。这对期货行情接口特别重要,因为订阅行为本身就是高频的——开盘前批量订阅几十个合约,盘中临时退订流动性差的品种,这些操作如果都走独立的 HTTP 请求,逻辑割裂不说,时序也无法保证。
我自己的体会是,WebSocket 还有一个经常被忽略的好处:它天然把"连接"和"业务消息"分开了。连接生命周期由底层管理,心跳、重连、关闭都有明确事件;业务层只需要关心 op、topic、data 这些字段。这个分层让行情模块的代码结构清晰很多,后续加鉴权、加日志、加监控都容易下手。相比之下,HTTP 轮询的代码里到处是"请求-响应-解析-再请求"的循环,行情逻辑和网络逻辑混成一锅粥。
期货行情的数据形态也在倒逼技术选型。这里的推送粒度往往是 tick 级——一笔成交就是一个消息,一个合约一秒钟能来好几条。如果这一秒内来了 5 条行情,轮询最多取到 1 条,WebSocket 可以完整收到全部 5 条。对于要做逐笔回放、订单簿重建、微结构分析的策略来说,完整数据流不是"更好",而是"必须"。WebSocket 的低帧开销(一个数据帧头部只有几个字节)也决定了它不是拿大炮打蚊子,它就是为了高频小消息设计的。
2. 接数据之前,先把这三件事读清楚
2.1 订阅与确认:连接是管道,合约才是主题
很多第一次接 WebSocket 行情的人会有一个错觉:连接一建立,数据就会自动进来。实际上绝大多数期货数据商的接入流程是两段式的:先鉴权,再订阅。鉴权通过之后,连接才是"可用"的;你显式订阅了某个合约,服务端才开始往这个连接上推该合约的行情。
订阅消息的格式每家略有差异,但核心字段大体一致:操作类型(subscribe)、订阅类型(quote / trade / kline 之类)、合约列表(rb2410、au2412 这种)。我用过的大致形态是下面这样:
{"op": "subscribe", "type": "quote", "instruments": ["rb2410", "au2412"]}注意,这儿的"至少等信息"是订阅回执。服务端通常会返回一条 sub_ack,或者直接在推送的消息里带上订阅成功的标记。我的建议是:订阅之后不要立刻默认成功,一定要等到确认再标记该合约处于"已订阅"状态。道理很简单,合约代码大小写、月份格式、主力连续代码的写法,任何一个不匹配,服务端都可能静默忽略你的订阅请求。你这边以为订阅成功了,那边一条数据都不推,排查起来最费时间——因为连报错都没有。
另外要注意订阅粒度。有的接口允许一次订阅几十个合约,有的接口对单次订阅数量有限制,需要分批。还有的接口区分"快照订阅"和"增量订阅",订阅类型填错了,收到的消息形态完全不同。这些细节不在文档里反复读几遍,真到上线时才会暴露。
2.2 快照、增量与逐笔:三种消息别混用
期货行情推送里,消息大体分三类,用途完全不同:
- 快照(Snapshot):某一时刻某个合约的全量盘口状态,买卖十档的价格和挂单量都在里面。快照适合在系统刚启动、或者断线重连后用来把订单簿"初始化"到当前状态。
- 增量(Update / Delta):只描述盘口哪些档位发生了变化,比如买一的价格从 3810 变成 3811。增量消息体积小、频率高,是盘中主要的推送形态。
- 逐笔成交(Trade):每一笔实际成交的价格、数量、时间。做微结构分析、成交统计、滑点测算时靠的就是这个。
最容易犯的错误是把三者混在一个处理函数里。快照不带增量语义,直接覆盖订单簿没问题;但增量必须基于已有的订单簿做局部更新,如果你没有维护好盘口基线,增量来了就是往空气里填数字。逐笔成交和盘口快照也不能混在一起渲染,因为盘口是"当前挂单的状态",逐笔是"已经发生的交易",性质完全不同。
我常用的做法是给每类消息建独立的分发路径:快照走初始化模块,增量走订单簿更新模块,逐笔走交易记录模块。三个模块互不打扰,任何一方出问题,可以单独降级重放,不会把整个行情处理链路带崩。
2.3 鉴权握手:Token 过期与断线重连的连环局
期货数据接口通常要求先鉴权再订阅。鉴权方式常见的有两种:连接建立后发一条 auth 消息,或者在连接 URL 里带 token 参数。前者更安全,因为 token 不会出现在日志里;后者实现简单,但注意别把带 token 的 URL 打到日志或者监控系统里,否则 token 泄露一次就要重新签发。
这里有一个容易踩的连环坑:token 往往是有有效期的。假设有效期 12 小时,盘中一旦触发断线重连,你拿着已经过期的 token 去连,服务端直接拒绝,重连逻辑如果没处理鉴权失败的分支,就会陷入"重连-被拒-再重连"的死循环。
所以连接管理模块里一定要区分"网络故障断开"和"鉴权失败拒绝"。前者是临时问题,指数退避重连即可;后者是凭据问题,应该停下来告警,等人处理,而不是无限重试。另外,token 过期前能主动续期最好,有些接口支持在连接内发送刷新消息,能避免重连这个动作本身。
3. 心跳机制与断线重连:让链路在真实网络里活下来
3.1 为什么必须有应用层心跳
我第一次接期货行情时,天真地以为 TCP 连接在那儿就是"活着"的。后来被现实教育了:TCP 的 keepalive 默认两个小时才探测一次,而且不同系统、不同中间设备的配置还不一样,很多家用路由器、云厂商的负载均衡设备,空闲连接的映射超时时间可能只有几分钟。一旦 NAT 映射被回收,连接在两端都"看起来正常",实际上中间链路早就断了——这就是传说中的假死连接。
假死状态是最坑的,因为你的代码还在正常运行,收不到任何数据也不会抛异常,直到某个操作触发了底层的写入超时,你才意识到问题。对行情系统来说,假死几分钟意味着策略拿着过期的价格在跑,后果比直接断线严重得多。
所以必须有应用层心跳:定时发送一个业务层面的 ping,服务端收到后回 pong,你确认这个往返正常,才认为链路是真正通的。这和"活着"的区别在于,它不是依赖底层网络机制,而是直接验证了"我的消息能到服务端、服务端的消息能回来"这条完整路径。
3.2 心跳超时的判定准则
心跳的时间间隔怎么定,是第一个问题。太短了,比如 3 秒一次,消息量是上去了,但大多数情况下都是在刷无用功,某些风控严格的服务端甚至会认为你是异常流量把你踢掉;太长了,比如 60 秒一次,断线后最多要等一两分钟才能发现,对行情系统来说这个反应速度不可接受。
我目前的经验值是在 10~15 秒之间发一次心跳,连续 2~3 次没有收到对应的 pong 才判定连接不可用。这个组合的含义是:单次丢失可能是网络抖动,不急着处理;连续丢失说明链路大概率已经断了,该走重连流程了。判定逻辑放在一个独立的计时器里,每次收到 pong 就刷新"最后活跃时间",主循环里定期检查这个时间是否超期。
const HEARTBEAT_INTERVAL = 15000; // 15秒发一次心跳 const PONG_TIMEOUT = 30000; // 30秒内没收到pong就判定超时 function startHeartbeat(ws) { let lastPong = Date.now(); ws.on('pong', () => { lastPong = Date.now(); }); setInterval(() => { if (ws.readyState === WebSocket.OPEN) { ws.ping(); if (Date.now() - lastPong > PONG_TIMEOUT) { ws.terminate(); // 强制断开,触发重连 } } }, HEARTBEAT_INTERVAL); }这里额外提醒一个细节:很多服务端的 ping 也可能主动发给你,客户端必须在收到 ping 时回 pong,否则服务端会认为客户端失活。这个逻辑不要漏。
3.3 指数退避与随机抖动:重连也要有纪律
断线之后的第一个念头是"立刻重连",但立刻重连往往是灾难的开始。如果服务端因为故障或者负载过高导致了大规模断线,所有客户端在同一瞬间发起重连,服务端会被这波重连风暴直接冲垮——这就是经典的惊群问题。
正确的做法是指数退避加随机抖动:第一次重连等 1 秒,第二次等 2 秒,第三次 4 秒,按 2 的指数翻倍,到了 30 秒封顶就一直保持 30 秒的间隔。同时每次间隔加上一个随机数,比如 0 到 1 秒的抖动,让不同客户端的重连时间错开。公式大概是:
delay = min(cap, base * 2^attempt) + random(0, jitter)这个策略的哲学是:重连的最终目标是恢复连接,不是为了"立刻"恢复。与其在服务端不稳定时反复横跳,不如退一步,等它恢复,然后用稳定的间隔持续尝试。实测下来,带退避的重连成功率远高于瞬间重连,而且对服务端更友好,你也不容易把自己的 IP 打进对方的限流名单。
3.4 重连后的数据对齐:用 seq 先修基线再续增量
重连成功不代表数据无缝衔接。关键问题在于:断线期间你错过了一大段增量消息,从重连那一刻起继续收增量,你的订单簿状态是错位的——因为你缺失了中间那几秒甚至几分钟的增量更新。
很多行情协议会为每条消息带一个序号(seq)。这个序号是救命的:重连后你对比自己最后处理的 seq 和当前最新 seq,差多少就知道丢了多远。如果差距小,可以尝试从服务端补拉落下的消息;如果差距大,就别做梦了,直接拉一次全量快照,把订单簿重新初始化,再继续收增量。
我见过一些系统的处理方式比较粗暴:重连成功后什么都不管,直接继续收增量。结果盘口五档的价格和真实价格越差越大,策略基于错误的盘口下单,亏损就是这么来的。所以一定要把"数据对齐"当成重连流程的一个正式环节:先快照,再增量,顺序不能反。这个环节没做好,前面网络层的所有工作都白费。
4. 接入实战的工程细节:从收到消息到变成可用数据
4.1 消费积压:缓冲队列不是无底洞
WebSocket 收消息的速度非常快,尤其是订阅几十个合约时,大行情一来,每秒可能涌入几千条消息。如果你的处理逻辑在同一个线程里做解析、更新订单簿、落库、通知策略,任何一个环节卡顿,消息队列就会像堵车一样越积越长。
最开始的教训来自我自己:把所有处理都写在 onmessage 回调里,结果有一次回调里做了一次磁盘写入,单条消息耗时 2ms,看似不多,但行情峰值每秒上千条,队列直接堆到几万条,延迟飙到几十秒,整个系统处于"看似在跑、其实在翻历史"的状态。
正确的模型是:回调只负责收消息和入队,消费逻辑放到独立线程/进程里做。队列要设上限,超过上限宁可丢弃旧消息,也不能无限堆积。对行情来说,丢掉一条旧快照不是致命的,因为下一秒就有新快照;但延迟处理旧消息导致策略永远在看"几分钟前的市场",才是致命的。所以,积压时的策略应该是"丢弃过期数据,只保留最新状态",而不是"全量处理直到追上"。
4.2 三种时间戳:你以谁的时钟为准
一条行情消息里可能包含多个时间:交易所撮合时间(event_time)、网关收到时间(recv_time)、你本地处理时间(local_time)。这三个时间经常被混用,但语义完全不同。
- event_time是市场事件的真实发生时间,回放历史、统计延迟、计算滑点时必须以它为准。
- recv_time是数据商网关收到交易所行情的时间,可以用来估算网络延迟链路。
- local_time是你进程处理到这条消息的时间,可以用来做性能监控,但不能用来标记行情本身。
实际应用里最大的坑是:用本地时间做策略的时间轴。本地时钟和服务端时钟可能差几百毫秒,跨机器部署时各节点时钟也可能不一致,如果用本地时间判断"这条消息是否已经过期",结果必然不准。我通常的做法是给消息对象加上 recv_time 和 event_time 两个字段,策略层统一以 event_time 为准,监控层单独计算 local_time 和 recv_time 的差来评估链路延迟。
另外,重采样也是一些系统需要的东西。有的策略按 500ms 频率做一次决策,但行情是事件驱动的,你不能保证 500ms 内恰好有一条 push 消息。这时候就需要把 tick 数据按时间窗口切片,聚合出每个窗口的 OHLC、成交量等信息。处理这类聚合时,窗口边界一定要用 event_time,不能用 local_time,否则切片结果会随着你的机器性能浮动,回测和实盘之间就会产生不一致。
4.3 文本协议还是二进制协议:取舍不只看速度
行情接口的消息体常见两种:JSON 文本和二进制。JSON 的好处是直观、调试方便,浏览器里打出来直接就懂;缺点是体积大,解析也相对重。二进制协议体积小、解析快,但可读性差,字段偏移错一位就是灾难。
选哪个,不能只盯着"性能"两个字,要看你的实际瓶颈。以 Python+Node.js 的常见水平,解析 JSON 的性能在几万条每秒的量级内完全够用;如果你的策略频率还没到微秒级决策,为了省那零点几微秒的解析时间选二进制,反而增加了开发和排障成本。反过来,如果你在 C++ 核心链路里处理行情,或者消息量到每秒百万条级别,那二进制协议几乎必须考虑。
另一个维度是体积。行情数据往往是高频小消息,消息头、JSON 括号、字段名这些 overhead 占比很高。一个 tick 数据用 JSON 表示可能要 200 字节,二进制可能只要 40 字节。如果你需要把行情存盘做历史回放,这个差距累积起来非常可观。我的建议是:开发调试期用 JSON,跑量之后逐步迁移到二进制,但要保证网关层能把二进制透明转成统一的内部结构体,不让策略层感知差异。
4.4 多合约订阅与消息路由
一个 WebSocket 连接往往订阅几十个合约,每条行情消息必须能准确路由到对应的策略或模块。这要求消息里必须有明确的合约标识,路由表维护"合约代码 -> 回调函数"的映射,消息进来先按合约号分拣,再分发。
这里有一个容易忽略的问题:行情消息里的合约写法可能和你订阅时用的写法不一致。比如你订阅时写 rb2410,消息里返回的可能是 "rb2410" 也可能是 "RB2410.SHFE",甚至可能是另一套内部代码。如果路由直接拿消息里的合约名去查表,查不到就丢,就会造成"明明订阅了却有些消息处理不到"的隐性丢数据问题。
我的做法是在初始化时先建立合约别名映射表,把服务端返回的各种写法归一化到统一的内部主键。路由只认主键,别名映射一旦配好,后续再奇异也不会出乱子。这个表建一次一劳永逸,比每次收到消息再做模糊匹配靠谱得多。
5. 真实踩坑记录:连接正常却不收数据,以及它的难兄难弟
5.1 连接正常却不收数据:问题出在订阅握手上
这个坑我印象太深了。有一次接某个场外数据商,wscat 手动连上去,发订阅消息,行情哗哗地来,一切看似完美。但把同样的代码搬进自己的 Node.js 应用里,连接建立了,鉴权返回了,状态也是 OPEN,就是一条行情都收不到。我当时怀疑是网络代理问题,怀疑是防火墙,甚至怀疑是数据商针对我们账户做了限流,排查了半天一无所获。
最后发现原因让人哭笑不得:我代码里订阅合约用的代码是rb2410,服务端只认大写RB2410,wscat 测试时我下意识写对了,搬到代码里时从配置文件读取的却是小写。服务端校验失败后没有报错——注意,这里的关键就是"没有报错"。很多行情服务端的订阅接口是静默失败设计:你订阅了一个它不认识的合约,它不会明确告诉你订阅失败,只是不往你这里推数据。表面上一切正常,实际上你什么都没有。
排查这类问题的正确路径是:先看服务端有没有返回 sub_ack;有的话看 ack 里的错误码;没有的话,把订阅消息的原始内容打印出来对比文档,一个字节一个字节地查;再不行,用 wscat 手动连同一个地址、发同样内容的订阅消息做对比。这种"看起来健康、实际没数据"的问题,最忌拍脑袋猜,一定要用对照实验一步步缩小范围。
5.2 心跳参数的两种死法
心跳参数设置的两种极端,我都试过。第一次是激进派:3 秒发一次心跳,5 秒超时判死。结果连接频繁被服务端断开,检查日志发现服务端反馈客户端消息频率异常,风控认为我在做非正常操作。后来问数据商技术支持才知道,他们规定心跳间隔不能低于 10 秒。这个规矩其他数据商可能没有,但足以说明:心跳参数不是只由你的网络环境决定的,也要看服务端的承受能力和规则。
第二次是另一个极端:60 秒心跳,超时时间设到 5 分钟。断网后系统没有任何反应,数据停留在最后一帧,策略却还在傻乎乎的跑。直到 5 分钟后超时判定触发,才重新拉起连接。对行情系统来说,这个发现时间是不可接受的。后来折中到 15 秒心跳、30 秒超时,连续 3 次无响应就重连。这样的平衡在大多数场景下都能在 30 秒内发现问题,又不会因为过于频繁的心跳给服务端和网关造成压力。
5.3 重连之后的第一帧,并不是最新价
这个坑几乎踩在每一个做订单簿重建的系统身上。当时我重连成功后,直接继续收增量消息,发现盘口的买一价和实际行情对不上,数值之间差着一个固定的偏移。我一度以为是解析错了,后来才意识到:增量消息是"相对变化",不是"绝对状态"。断线期间缺失的增量没有补上,重连后的第一帧增量只是基于它自己视角的最新变化,你本地缺了中间那段,自然对不上。
解决办法就是上一节说的"先快照、再增量"。重连成功后,先发一条快照订阅请求,等服务端返回当前完整的盘口快照,把本地订单簿整个替换掉,再开始处理增量消息。这个流程要说简单也简单,但很多人就是在代码里少了这一行,导致实盘数据一直偏着。我见过的生产事故中,这类"基线错位"引发的错误订单,比网络完全中断还难查——因为系统在跑,数据也在更新,只是数值整体不对。
5.4 网络切换与睡眠后遗症
最后说一个看起来不太像行情系统问题的坑。有一段时间在笔记本上调试行情,发现连接偶尔会"无缘无故"断开,日志里什么异常都没有。后来发现是电脑休眠恢复后,网络连接已经被系统回收了,但应用层感知不到。休眠前是正常的,醒来后 TCP 连接早就断了。如果不做任何处理,你会一直等数据,永远不会等来。
处理方式是在应用里监听系统事件:online、offline、sleep、wake。一旦检测到网络状态变化,主动触发一次连接健康检查,必要时直接关掉旧连接重新建立。这听起来是前端开发才会关注的事情,但自动化跑着的行情程序同样会遇到——你总不能每次电脑休眠都人肉盯一遍。
还有 Wi-Fi 切换的场景:从办公室网络切到手机热点,IP 变了,原来的连接在 NAT 层面就失效了。如果心跳超时检测没跟上,客户端会以为连接还在,实际数据早就断供。所以"网络变化即重连"这个原则,不只是给移动端用的。
6. 给自己留的几条实操经验
写到这里,核心的技术点都过了一遍。最后留几条我个人在实际操作中沉淀下来的习惯,供你参考。
第一,把连接管理收敛成一个独立模块。不要在三四个文件里分别处理 onopen、onmessage、onclose。我倾向于把建连、鉴权、订阅、心跳、退避重连、数据对齐全部封装成一个"行情网关",对外只暴露一个统一的行情回调接口。策略层永远不需要关心链路细节,它只需要知道"这条消息是一条新的 tick"。这个抽象在初期可能觉得多此一举,但当你接入第二家数据商、或者行情源切换时,会庆幸当初留了这一层。
第二,监控指标要覆盖链路健康度。至少包括:连接状态、当前重连次数、最后一次心跳延迟、消息接收速率、消费队列积压量、最近一次数据时间戳。这六个数值可以在问题刚冒头时就发出信号,而不是等策略跑出错单才回头查。很多人只盯价格对不对,没盯数据新鲜度,结果价格一条不少,但延迟已经高了十几秒,这比丢几条数据更隐蔽、更危险。
第三,把"静默失败"当成默认假设。行情接入里的很多问题都不报错——订阅不成功不报、连接假死不报、增量缺口不报。在设计系统时,凡是"我以为正常"的地方都要加一个显式的校验机制。订阅一定要等 ack 才标记成功,心跳一定要超时才算失败,数据一定要通过 seq 校验连续性。宁可多写几行防御代码,也不要相信"应该没问题"。
行情接入这件事,纸面上讲永远只有三步:连上、订阅、收数据。但真正让一套量化系统在生产环境稳定跑起来,靠的是对这些隐藏细节的死磕。把链路管明白了,策略才可能安心地在上面做决策。