盘口增量与行情推送"

admin 2026-10-16 07:58:41 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

盘口增量与行情推送

原创

交易系统研究员
交易系统研究员

CppGuide

2026年9月28日 11:34
上海

在小说阅读器读本章

去阅读

在公众号小说中沉浸阅读

前两篇讲的都是撮合引擎内部:订单簿怎么存、一张委托怎么成交。这一篇往外走一步,看 撮合产出的数据是怎么变成用户屏幕上那些不停跳动的数字的。

这是本专栏第一次跨越两个服务,正好用它说明一件事:交易系统里的服务边界不是按\"代码量差 不多\"划出来的,而是按各自的约束划出来的。撮合必须单线程、必须能重放;行情推送两条都不需要,反而需要另外一些东西。


一、用户盯着的行情,到底是哪几样数据

打开任何一个交易界面,屏幕上同时在动的东西不止一样。把它们分清楚,后面的设计才有对象:

界面上的东西 它回答什么问题 数据形态
深度 (也叫盘口) 现在有人在什么价位、挂了多少量 一张随时在变的表
最新价 最近一笔成交在什么价 一个数
24 小时涨跌幅、最高价、最低价、成交量 这个合约今天的行情怎么样 一组统计值
逐笔成交 刚刚发生了哪些成交 一条条往下滚的记录
K 线(蜡烛图) 更长时间尺度上价格怎么走的 一根根蜡烛

前三样回答的是「现在是什么样」,后两样回答的是「刚才发生了什么」。这个区别看起来 像在抠字眼,但它决定了推送协议里一个很具体的设计,第六节会讲到。

这五样东西全部来自同一条链路。


二、从撮合到屏幕,这条链路上有谁

撮合引擎产出两种消息:盘口变化和成交回报。它把这两种消息发到消息队列上就结束了, 既不知道谁在看行情,也不负责推送。

接手的是行情网关。它做三件事:把盘口变化累加成一张完整的订单簿、维护 24 小时统计、 把结果按频道推送给订阅的客户端。

为什么要这么分?因为两边的约束正好相反:

  • 撮合必须单线程按顺序执行,而且必须能够重放——重启之后把消息重新处理一遍,要得到 一模一样的结果,否则账就对不上。这两条要求让它不能做任何需要等待的事情。
  • 行情网关要同时服务成百上千个连接,天然是并发的;而它持有的那张订单簿丢了不要紧, 重新累加一份就行,不涉及任何账务。

一个必须单线程且可重放,一个必须并发且状态可丢弃。这两种要求装不进同一个进程,所以它们 是两个服务。

下文会多次出现「帧」这个词,指网关推给客户端的一条消息。这是 WebSocket 协议里的标准 说法,不是本文生造的叫法。


三、上游为什么只发变化的那几档

撮合每次成交或撤单,往往只改动订单簿上的两三个价位。它发出去的消息也只描述这几个价位:

{\"type\":\"mbl\",\"data\":{\"marketBookLevelList\":[
  {\"operationType\":1,\"instrumentId\":\"合约ID\",\"direction\":1,
   \"price\":10.00,\"displayVolume\":350,\"timestamp\":1727000000000},
  ...
]}}

operationType 有三个取值:新增一个价位、更新某个价位上的数量、删除一个价位。 direction 区分买卖两侧。这种\"只描述变化部分\"的消息,通常叫增量。

为什么不干脆每次都把整张订单簿发一遍?因为数量级差得太远。一个活跃合约的订单簿两侧加起来 可能有几百个价位,而每秒会变化几十次。每次重发几百档,序列化的开销和占用的带宽都要按这个 倍数放大,而其中绝大部分档位这一次根本没有变。


四、增量的代价:漏掉一条,之后一直是错的

增量方案有一个必须正视的代价。

行情网关手里那张订单簿,等于它此前收到的全部增量之和。这意味着中间只要漏掉一条,或者 把两条的先后顺序弄反了,这张簿就会出现偏差——本该删掉的价位还留着,或者本该存在的价位没有。

关键在于:这个偏差不会自己消失。

后续的增量都建立在这份已经错了的簿上,越叠加错得越久,一直要到进程重启、重新累加一份新的 簿,偏差才会消失。而在这期间:

  • 系统不会报任何错——每一条增量本身都是合法的,处理也都成功了;
  • 用户看到的深度里挂着一笔并不存在的委托,照着它下单,会发现一张也成交不到,或者以一个 意想不到的价格成交了。

这条性质直接推出消费侧的三条硬性约束,它们没有商量余地:

  1. 必须单线程按顺序处理。 同一个合约的盘口消息不能并发消费——两个线程同时处理两条增量, 谁先写进簿里就不确定了。
  2. 解析失败时只丢弃整条消息。 绝不能把解析了一半的结果写进簿里。上游偶尔发来一条格式 有问题的数据,丢掉它只是少了一次更新,下一条增量会把状态带回来;而写进去一半,这张簿就 进入了后续增量也修不回来的状态。
  3. 不重放历史增量。 行情网关启动时从队列的末尾开始消费,只处理服务启动之后产生的 变化。历史增量对当前盘口没有意义——中间可能已经缺失了一大段,照着残缺的历史累加出来的 簿必然是错的,不如从当前时刻起重新累加。

第三条值得和撮合对照着看。撮合服务启动时必须从检查点开始把消息完整重放一遍,少处理一条 都不行;行情网关正好相反,必须不重放。差别的根源还是那一句:撮合的状态是账务的一部分, 行情网关的状态是派生的、随时可以重建。


五、下游为什么反过来推完整快照

既然增量这么省,为什么网关推给客户端时不也发增量,而是每次推一份完整的订单簿?

这是全篇最值得记住的一处:同一个取舍,在这条链路的两段给出了相反的答案。原因不是哪一段 的工程师偏好不同,而是两段面对的条件不一样。

上游(撮合 → 网关):接收方只有一个,而且运行在自己的机房里;消息按顺序投递,顺序有 保证;万一累加错了,重启一遍就重新算对了。出错能发现,也能修复——所以可以用增量。

下游(网关 → 客户端):接收方成百上千,运行在谁也控制不了的浏览器和手机上;某个客户端 漏收一帧,没有任何人会知道,它会一直显示错误的深度直到用户自己刷新;更不能要求每个接入方 都正确实现一遍增量累加的逻辑。出错既发现不了,也修不了——所以只能推完整快照。

判据不是\"哪种更省带宽\",而是\"出错之后谁能发现、谁能修\"。

推完整快照还有一个附带的好处:新订阅者收到的第一帧和后续推送是同一种帧,客户端不需要 区分\"首帧\"和\"增量帧\"两套处理逻辑。

代价与补偿:把档数写进频道名

完整快照的代价是每帧更大。补偿办法是按档数分频道:

频道名 每侧推送档数 谁会订阅
orderbook:<合约> 不限 要画完整深度分布的客户端
orderbook10:<合约> 10 档 只显示最优十档的界面
orderbook25:<合约> 25 档 盘口面板的常用档数

档数写进频道名,订阅和推送就天然对齐了:订了哪一档就只收哪一档,服务端不必为每条连接单独 记住它想要几档。代价是新增一种档数要同时改服务端的档位表和客户端的订阅串。


六、状态频道与事件频道:订阅之后补不补发

回到第一节那个「现在是什么样」和「刚才发生了什么」的区别。它在这里变成一个很具体的问题。

行情帧只在数据发生变化时才推送。假设一个冷门合约三分钟内没有任何人挂单撤单,客户端刚 订阅上来,如果只等下一次推送,这三分钟里屏幕上就是一张空的深度表——用户会以为这个合约 根本没有人挂单。

所以传递「当前状态」的频道,订阅时必须立刻补发一帧当前值。深度、报价、K 线都属于这一类。

传递「刚才发生了什么」的频道则不补发。逐笔成交是一串已经发生过的事件,没有\"当前值\"可补; 一个客户端连上来之前发生的成交,本来就不属于这条连接。

补发还有一个前提容易被忽略:得先确认这个合约确实已经有数据了。 服务刚启动、还没收到过 某个合约的任何盘口消息时,补发出去的是一张空簿,而客户端会把它当成\"买卖两侧当前都没有挂单\" 来显示——这比不发更容易误导人。所以补发之前要先判断该合约的簿是否已经建立。

逐笔成交虽然不补发,网关仍然会在内存里留一小段最近成交(每个合约几百笔)。这不是为了推送, 而是为了让查询接口能立刻给出一段可用的记录,不必等下一笔成交发生。留存要设上限:不设的话, 一个活跃合约跑上一天就能把内存占满,而真正的历史记录由落库之后的查询接口提供。

一笔成交为什么只推一条

还有一个容易踩的地方。一笔成交会产生两条成交回报:主动成交方一条,挂单方一条,两条描述 的是同一笔成交。

公共行情是给所有人看的市场数据,不区分谁是买方谁是卖方。两条都推的话,同一笔成交会在客户端 的成交列表里出现两次,成交量统计也随之翻倍。所以行情网关只取其中一条推送。


七、24 小时统计为什么按分钟分桶

界面上那一行「24h 涨跌幅 / 最高 / 最低 / 成交量」,需要在一个不断向前滑动的 24 小时窗口里 做聚合。

最直白的做法是把这 24 小时里的每一笔成交都留着,需要时遍历一遍。但一个活跃合约一天有几十万 笔成交,逐笔留存的内存开销和窗口滑动时的删除开销都不划算。

实际做法是按分钟分桶:同一分钟内的成交先聚合成一个桶,记下这一分钟的开高低收和成交量。 24 小时最多 1440 个桶,窗口向前滑动时只需要从队首丢弃过期的桶。代价是窗口边界的精度只到 分钟——第 0 分钟第 59 秒的成交会和第 0 分钟第 1 秒的成交同进同出。对一行展示用的统计来说, 这个误差不影响任何判断。

这里有两条性质值得单独说。

第一,时间只来自消息,不读系统时钟。 窗口的推进完全由消息里携带的时间戳驱动。如果改读 系统时钟,同一批消息重放两次会得到不同的统计结果,事后对账和故障复现就都没有依据了。

第二,由上一条带来一个必须知道的后果:没有消息流入时,窗口根本不会滑动。 统计会停在最后 一条消息的时刻。所以盘口消息也要用来推进窗口——如果只靠成交推进,一个整天没有成交的合约 会一直显示前一天的统计数字,用户看不出它其实已经没有交易了。

最后是乱序。时间戳比当前窗口更早的消息,按\"落在它自己那一分钟\"处理;如果那一分钟已经被淘汰 出窗口,这笔成交直接丢弃,而不是让窗口回退。行情链路上的乱序只可能来自极短的抖动,丢掉 过期的那一笔,比让窗口回退安全——回退会让已经推给客户端的统计数字变小,在用户看来就像 某笔成交被撤销了一样。


八、为什么一条接收慢的连接会影响到所有人

最后一节讲推送本身。这是行情系统里最典型的一类故障。

行情网关内部有一张订阅表,记录着每个频道被哪些连接订阅了。推送时按频道查出这些连接,逐个 发送。订阅表会被两类线程同时访问——处理客户端连接的线程(订阅、退订、断开清理)和消费上游 消息的线程(广播),所以它需要一把锁。

问题出在发送发生在锁里还是锁外。

发送最终要写网络连接。遇到一个接收很慢的客户端(网络差、或者它自己处理不过来),这个发送 调用会一直等在那里。如果此时还持有订阅表的锁,那么其他客户端的订阅、退订,以及其他所有 频道的广播,全部会排在后面等待——一条接收慢的连接,让全部推送一起停顿。

正确的做法是:在锁里只把目标连接的名单复制一份,出锁之后再逐个发送。 锁只在复制名单的那 一瞬间被持有,接收慢的客户端只会影响发给它自己的那一次发送。代价是每次广播多复制一份名单, 与让全部推送停顿相比可以忽略不计。

同样的道理也适用于订单簿那份状态:在锁里把帧的内容构造好,出锁之后再发送,不要在持有簿 的锁时调用发送。两把锁也不要嵌套使用——不嵌套就不存在获取顺序不一致导致的相互等待。

还有一处并发细节容易被漏掉:同一条连接可能被两个线程同时写入。推送线程往它写行情帧, 这条连接自己的读线程往它写心跳回应和关闭帧。两者必须共用同一把写锁,否则两帧的字节会交错在 一起,对端解析出一段畸形数据之后会直接断开连接。


九、几个看起来是小事的协议约定

下面这几条单独看都很小,但每一条都对应一类真实发生过的问题。

价格和数量写成十进制字符串,不写成 JSON 数字。 JSON 数字在浏览器里按双精度浮点解析, 大数量会丢失精度,价格末位会出现误差。写成字符串,由客户端自行按精确的十进制解析,就不会有 这个问题。

服务内部一律用定点十进制表示价格,不用浮点。 浮点无法精确表示十进制小数,同一个价格可能 得到两个只差最低位的值,订单簿上就会分裂成两个相邻的档位。这条规则在第十三篇讲订单簿时出现 过一次,这里是它在另一个服务里的同一次应用——整套系统里只要涉及价格,这条规则都成立。

买侧价高在前,卖侧价低在前,这是协议的一部分。 也就是说数组的第一个元素永远是该侧的最优 价,客户端可以直接取用而不必自己排序。写进协议之后,两端就不会各排一次、还排得不一样。

每一帧都带上频道名。 一条连接上可能同时订阅了深度、报价、成交多个频道,客户端靠这个字段 把收到的帧分发到对应的显示区域。帧里写的频道名必须和实际投递的目标完全一致,否则这一帧会被 送到另一个订阅上,或者干脆没有任何区域认领它。

面向所有人的行情查询接口不需要签名。 深度、最优报价、公共成交都是公开数据,与账户无关, 和行情推送连接是同一个口径。但单次请求返回的档数和笔数必须设上限——不设上限的话,一次 请求就能把整张订单簿或全部留存成交序列化出去,而调用方往往只是随手传了个大数字,并不知道 自己要了多少数据。


十、回头看这些决定

设计决定 若不如此会出现的问题
上游发增量 每秒变化几十次,重发几百档等于把没变的部分一再重发
同一合约的盘口消息不并发消费 两条增量的先后顺序一旦颠倒,簿就错了,而且不会自己纠正
解析失败丢弃整条消息 写进去一半的结果,后续增量也修不回来
行情网关不重放历史增量 中间可能缺失一大段,照残缺历史累加出来的簿必然是错的
下游推完整快照 客户端漏收一帧没有人会发现,也没有人能修
档数写进频道名 否则只看十档的客户端也要收下整张簿
状态频道订阅时补发 不补发的话,冷清合约上用户看到的是一张空的深度表
补发前先确认簿已建立 补发一张空簿比不发更容易被误解为\"没有人挂单\"
一笔成交只推一条 两条都推会让成交列表出现重复,成交量统计翻倍
24 小时统计按分钟分桶 一天几十万笔,逐笔留存的内存与删除开销都不划算
窗口只由消息时间戳推进 读系统时钟会让同一批消息重放两次得到不同结果
盘口消息也要推进窗口 否则整天没成交的合约会一直显示前一天的数字
过期的乱序成交直接丢弃 让窗口回退,会使已经推出去的统计数字变小
广播先复制名单再发送 持锁发送时,一条接收慢的连接会让全部推送一起停顿
同一连接的两个线程共用写锁 否则两帧字节交错,对端解析出畸形数据后断开连接
价格数量用字符串传、用定点存 浮点会丢精度、会把同一个价格分裂成两档

把这张表读一遍会发现,行情链路上的问题和撮合链路上的问题是两种不同的类型。

撮合那边的风险是算错——手续费取整方向反了、强平价没有量化到最小变动价位、成交编号重放 时对不上。它们的共同点是账目会出现差额,虽然不报错,但迟早对账能对出来。

行情这边的风险是显示错——深度里多了一笔不存在的委托、统计停在前一天、一条慢连接让所有人 的行情不动了。它们不产生任何账目差额,对账永远查不出来,只能靠用户反馈\"我这边数字不对\"。

所以这两个服务虽然在同一套系统里,判断代码写得对不对的标准并不一样。撮合问的是\"这个数算得 对不对\",行情问的是\"用户此刻看到的,是不是系统此刻真实的样子\"。

下一篇:清算服务的程序结构——成交回报到了清算之后发生了什么,为什么保证金、持仓、 资金这三样东西必须在同一个地方算。

如果你对交易系统的开发与设计感兴趣,可以阅读小方在知识星球的专栏:

使用 C++20 从零构建一个完整的低延迟交易系统

如果你想求职交易系统相关的岗位,可以阅读小方在知识星球的专栏:

交易系统开发岗位求职与面试指南

如果你对交易系统开发感兴趣或者想这方面的工作,可以看看交易系统开发训练营,这个训练营中小方将带你从零开发一套完整的交易系统,包括上文中提到的全部技术:

AI实战训练营:用AI从零开发一套高可用交易系统 即将开营

推荐阅读:

C/C++ 网络编程实战训练营一期

C/C++ 网络编程二期录像

C/C++ 项目实战训练营

用 AI 带你从零开发大型商业远程控制软件

AI实战训练营:用AI从零开发一套高可用交易系统

大型Linux内核网络专栏

小方说服务器开发:一个实实在在帮你提高开发能力的优质圈子!



    盘口增量与行情推送" 网络安全文章

    盘口增量与行情推送"

    本文系统阐述交易系统行情推送链路设计,从撮合引擎到行情网关再到客户端。核心结论包括:上游用增量消息节省带宽但需单线程顺序处理且不重放历史;下游推完整快照因客户端出错难发现难修复;状态频道订阅时补发当前值;24小时统计按分钟分桶;广播先复制名单再发送避免慢连接阻塞全部推送。文章提供具体协议约定与可操作建议。
    公安部网安局公布2起拒不履行信息网络安全管理义务罪典型案例" 网络安全文章

    公安部网安局公布2起拒不履行信息网络安全管理义务罪典型案例"

    公安部网安局公布2起拒不履行信息网络安全管理义务罪典型案例,涉及江苏盐城和河南三门峡两家公司,因未履行身份核验义务或未及时整改违法信息,导致严重后果,法定代表人分别被判处有期徒刑八个月缓刑一年并处罚金。案例警示互联网服务提供者须切实履行信息网络安全管理义务,否则将承担刑事责任。
    天融信:登榜" 网络安全文章

    天融信:登榜"

    天融信连续八年入选2026年度软件和信息技术服务竞争力百强企业榜单,彰显其技术创新与综合实力。公司以人工智能+战略引领,深耕网络安全与智算云双赛道,推出大模型智能体原生安全防护体系,并在AI安全、智算云等领域取得实践成果,持续助力数字中国建设。
    评论:0   参与:  0