盘口增量与行情推送

admin 2026-09-29 05:08:29 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

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


盘口增量与行情推送

原创

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

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内核网络专栏

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



免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。

本文转载自:CppGuide 交易系统研究员 交易系统研究员《盘口增量与行情推送》

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

盘口增量与行情推送

文章总结: 本文系统阐述交易系统行情推送链路设计,从撮合引擎到行情网关再到客户端。核心结论包括:上游用增量消息节省带宽但需单线程顺序处理且不重放历史;下游推完整
天融信:登榜 网络安全文章

天融信:登榜

文章总结: 天融信连续八年入选2026年度软件和信息技术服务竞争力百强企业榜单,彰显其技术创新与综合实力。公司以人工智能+战略引领,深耕网络安全与智算云双赛道,
评论:0   参与:  0