交易技术

共享内存IPC设计:明日重构交易系统跨进程通信的低延迟方案

交易系统里最容易被低估的延迟,不在策略公式,也不一定在柜台接口,而是在进程之间递一条消息的路上。…

共享内存IPC设计:明日重构交易系统跨进程通信的低延迟方案

行情进程收到一笔报价,策略进程拿到它,风控进程判断,订单进程发往柜台。每一跳都可能触发系统调用、上下文切换、内核缓冲区拷贝。单看一跳,数字似乎不吓人;等链路串起来,滑点已经先把你的利润绞杀一遍,策略还在自我安慰。

消息队列、管道、套接字并不是不能用。它们成熟、易调试、边界清晰,业务系统里到处都有。但当你的交易系统开始追逐微秒级延迟,传统IPC的那套舒适区就会变成铁笼。共享内存把同一块物理内存映射到多个进程的虚拟地址空间,初始化完成后,进程可以直接读写这片区域,不再经过内核缓冲区转运,数据拷贝次数直接降为零。

这不是把某个队列替换成一块内存那么简单。共享内存只负责让进程看到同一片地址空间,它不会替你排队,不会替你同步,更不会替你保证一笔订单不会被重复发送。同步协议、内存可见性、缓存一致性、进程崩溃恢复,全部要自己扛。

你要的是低延迟,就得把这些脏活一件件剥开。否则所谓共享内存,只是把消息队列的混乱搬进了一块更快的内存。

先拆掉一个误区:共享内存不是天然的交易通道

共享内存的核心动作非常直接:创建一块可被多个进程映射的区域,让生产者和消费者共同访问其中的数据。

消息队列则要经过另一条路。发送方把用户态数据交给内核,内核保存消息,接收方再从内核空间取回用户空间。按照典型实现,传输过程中至少涉及两次数据拷贝:

1. 发送进程的用户空间复制到内核空间;

2. 内核空间再复制到接收进程的用户空间。

每次收发还伴随系统调用,线程可能被调度,处理器可能发生上下文切换。交易系统里,真正要命的不是这一次拷贝本身,而是不可控。你不知道线程什么时候被唤醒,不知道调度器什么时候把核心让给别的任务,也不知道某个高峰瞬间会不会让队列排队。

共享内存初始化建立映射后,数据可以直接写入共享区域。基于共享内存轮询的设计,正常收发阶段可以不再频繁触发系统调用。传输数据拷贝次数为零,延迟可以压到微秒级;在特定的无锁进程间通信方案中,甚至能够触及百纳秒级别。

但注意,这里的关键词是“特定方案”。不是你调用了 mmap,延迟就自动变成纳秒级。你把锁、动态内存分配、缓存未命中、跨核通信、日志刷盘全塞进热路径,最后得到的仍然是一套慢系统,只不过外面套了共享内存的包装。

共享内存消灭的是数据拷贝,不是系统复杂度。同步协议写错一处,低延迟通道就会变成高速爆仓通道。

因此,重构的第一步不是立刻写代码,而是把通信链路分层:

  • 数据存储区域:保存行情、时间序列、订单状态或回放数据;
  • 事件传输区域:传递行情更新、策略信号、风控结果和订单指令;
  • 控制区域:处理启动、停止、心跳、版本协商和故障通知;
  • 统计区域:记录队列深度、丢包计数、处理耗时和进程状态。

不要把所有东西都扔进一块巨大共享内存。那样做的结果通常是:谁都能写,谁都不知道谁写过,出了问题只能靠日志猜。

低延迟架构的第一根骨头:无锁环形缓冲区

交易系统中最适合优先改造的场景,通常是单生产者、单消费者,也就是SPSC。

例如:

  • 行情接收进程生产行情事件,策略进程消费;
  • 策略进程生产订单意图,风控进程消费;
  • 风控进程生产放行结果,柜台适配进程消费;
  • 回放进程生产历史行情,策略回测进程消费。

这种场景不需要一开始就挑战多生产者、多消费者队列。SPSC环形缓冲区的结构足够简单,性能也足够凶。你预先分配固定数量的槽位,生产者按写指针写入,消费者按读指针取出,指针走到数组末尾后回到开头。

一个最小的槽位至少要回答四个问题:

  • 这格内存属于谁;
  • 这条消息是否已经写完;
  • 消费者是否已经读走;
  • 队列满了以后,生产者应该阻塞、丢弃还是覆盖。

如果这些问题没有写在协议里,代码迟早会互相踩踏。

生产者和消费者如何交接

最常见的设计是:

  • 生产者独占写入位置;
  • 消费者独占读取位置;
  • 读写指针放在共享区域;
  • 使用原子操作发布位置;
  • 通过获取与释放语义确保数据先写入,再对外可见。

生产者不能先推进写指针,再慢慢填充消息。那相当于在门还没装好的时候就告诉消费者可以进屋。消费者一旦看到新的写指针,就可能读取到半条行情:价格已经写入,数量还没写;时间戳更新了,交易所序号却是旧值。

正确顺序应该是:

1. 读取当前写位置和读位置;

2. 判断队列是否还有空槽;

3. 在本地槽位写入完整消息;

4. 确认消息内容已经完成;

5. 以释放语义发布新的写位置;

6. 消费者以获取语义读取写位置;

7. 确认有新消息后读取槽位;

8. 处理完成后推进读位置。

在C++中,常见的内存顺序包括 memory_order_relaxedmemory_order_acquirememory_order_releasememory_order_seq_cst。它们不是装饰,也不是把所有原子变量都换成最强顺序就万事大吉。

relaxed只保证原子性,不提供完整的先后关系;release用于发布前面的写入;acquire用于确保读取方看到发布之前的内容;seq_cst提供更强的全局顺序,但代价也更高。你要根据数据发布和消费关系设计,而不是凭感觉乱套。

为什么SPSC不需要锁

SPSC队列里,写指针只有生产者修改,读指针只有消费者修改。双方只需要读取对方的指针,不存在多个线程同时争抢同一写入位置的问题。

这就是它能摆脱互斥锁的根本原因。

互斥锁的代价不只是“加锁和解锁”那几条指令。竞争发生后,线程可能进入等待,调度器介入,缓存状态被打乱,交易线程从热路径上被踢出去。对行情洪峰和订单爆发来说,这种抖动比平均延迟更危险。

但是,无锁不等于没有等待。队列满了,生产者仍然要做选择;队列空了,消费者仍然要等数据。忙轮询可以减少唤醒延迟,却会持续消耗CPU。你不接受这个成本,就不要在宣传材料里把自己写成极速交易系统。

队列满了,谁来承担损失

这是设计里最容易被业务方含糊带过、最后又在实盘里挨刀的地方。

行情和订单不能用同一套溢出策略。

行情事件有时可以合并。比如同一个合约的最新盘口更新,旧的中间价在极端拥堵时可能失去价值。但订单指令不能随便覆盖,更不能因为队列满了就静默丢掉。订单、撤单、风控拒绝、成交回报都需要明确的序列号和状态恢复机制。

可以按消息类型拆分策略:

消息类型队列满时的处理设计重点
最新行情合并、丢弃过期更新或转入降级通道保留交易所序号,不能制造虚假连续性
盘口增量通常不能静默丢弃需要序号校验和快照重建
策略信号根据策略语义决定拒绝或延迟必须带策略版本和生成时间
下单指令不允许无提示覆盖使用唯一请求号和状态机
撤单指令优先级通常高于普通信号防止撤单排在过期开仓之后
成交回报必须可靠保存支撑账户状态和重启恢复
心跳消息可采用独立控制通道不要让行情洪峰淹没故障检测

如果你只写一个统一的 push(),然后把所有事件塞进去,系统看起来很整齐,风险却被藏在一个函数里。真正的交易系统不是仓库,订单和行情不能按照同一套地板标线处理。

共享内存区域怎么切:数据、索引和控制面分开

共享内存设计最忌讳“先申请一大块,再往里面塞结构体”。这种写法前期快,后期一定开始绞杀维护者。

建议把共享内存区域划分为固定布局,并在头部放置协议元数据。至少包括:

  • 魔数,用于确认这块区域确实属于目标系统;
  • 协议版本,用于防止新旧进程直接互读;
  • 总容量与槽位大小;
  • 创建时间或初始化代次;
  • 生产者与消费者进程标识;
  • 队列读写索引;
  • 心跳时间戳;
  • 数据区域偏移;
  • 校验或状态字段。

不同进程映射同一文件或同一命名对象时,虚拟地址不一定相同。因此,不要在共享内存里保存进程私有指针。char*、对象地址、带内部指针的容器,放进共享区域后都可能成为定时炸弹。

共享内存里应该保存偏移量、定长数据和明确的整数类型。进程拿到基地址后,通过“基地址加偏移量”定位对象。不要把普通的 std::stringstd::vector、带虚函数表的C++对象直接放进去。它们内部可能持有当前进程的堆地址,换一个进程映射,指针立刻失效。

结构体布局要稳定

进程间通信不是普通函数调用。你不能只考虑当前编译器和当前版本。

消息结构需要明确:

  • 字段类型和宽度;
  • 字节序;
  • 对齐方式;
  • 结构体版本;
  • 保留字段;
  • 时间戳单位;
  • 价格和数量的缩放规则;
  • 序列号是否单调递增;
  • 末尾是否需要校验字段。

价格不要在热路径里反复使用浮点计算来承担协议语义。交易所价格通常有明确最小变动单位,内部可以采用整数缩放表示,把价格转换为最小价位的整数。数量也要定义精度。你不把这些写死,策略进程和柜台适配进程迟早出现一个把价格当元、另一个把价格当分的事故。

消息不要无限追求“结构体最小化”。太小的消息未必更快。如果生产者和消费者反复跨缓存行争抢同一片区域,省下的几个字节根本救不了延迟。对频繁修改的索引和状态字段,要考虑缓存行隔离,避免伪共享。

伪共享会把无锁队列拖进泥里

生产者频繁修改写指针,消费者频繁修改读指针。如果两个指针放在同一个缓存行里,虽然它们由不同线程负责,缓存一致性协议仍然可能让两个核心反复争抢这条缓存行。

结果很讽刺:你没有加锁,却让硬件缓存系统替你打了一场锁战。

常见做法是把生产者独占字段和消费者独占字段分开,必要时按缓存行边界填充。不要迷信某个固定填充数值,实际缓存行大小、编译器布局和目标处理器都应该通过构建检查和压测确认。关键原则只有一个:写入频率高、由不同核心修改的字段,不要挤在同一缓存行。

内存分配和CPU调度:真正决定延迟尾部的脏活

交易系统的平均延迟可以很漂亮,尾部延迟却把账户一把扫损。很多系统在空闲时跑得飞快,行情一来就开始抖。原因通常不在共享内存映射,而在热路径里做了不该做的事。

热路径禁止动态分配

行情到达后临时创建对象,订单发送前扩容容器,日志拼接字符串,异常路径触发堆分配——这些动作平时没有动静,一到行情爆发就集体出来吃货。

低延迟系统应该在启动阶段预分配内存池:

  • 固定数量的行情消息对象;
  • 固定大小的订单对象;
  • 固定容量的队列槽位;
  • 预留日志缓冲区;
  • 预留回放和故障恢复所需的内存。

对象从池里取,用完归还,不要让交易线程临时向通用堆申请。内存池也要避免多线程无意义争抢。能按进程或按线程独占,就不要把所有对象放在一个全局分配器里。

大页内存不是万能药,但小页抖动确实难受

低延迟系统会考虑大页内存,用更少的页表项覆盖更大的地址区域,降低地址转换缓存压力。它属于基础设施层优化,不是业务代码里的魔法开关。

使用大页前,先确认:

  • 操作系统是否正确预留;
  • 进程是否有权限使用;
  • 共享区域是否按要求对齐;
  • 内存不足时是否有明确降级;
  • 大页失败后是否会悄悄退回普通页;
  • 容器、虚拟化环境是否改变了实际行为。

不要只在配置文件里写上大页参数,然后对着启动日志自我感动。系统到底映射成了什么页面,要通过运行时信息核对。你追求的是可验证的延迟,不是配置项数量。

CPU绑核和隔离核心

交易线程被调度到不同CPU核心,缓存热数据会跟着搬家。策略线程和行情线程如果频繁跨核,原子变量的读写还会产生额外的缓存一致性流量。

低延迟架构通常会考虑:

  • 将行情接收、策略计算、订单发送分别绑定到指定CPU;
  • 使用CPU隔离手段减少无关任务干扰;
  • 避免把日志、监控、后台维护线程放到交易核心;
  • 明确线程之间的数据传递方向;
  • 让单个线程承担单一职责,减少锁和共享状态。

CPU绑核不是把所有线程绑在同一个核心。那叫排队,不叫优化。你要先画出链路:谁生产,谁消费,哪一段必须串行,哪一段可以并行,哪一段允许批量处理。然后再分配核心。

忙轮询与睡眠唤醒

消费者等待行情时有两种基本方式:

  • 睡眠或等待通知;
  • 持续轮询队列索引。

睡眠能省CPU,但唤醒过程引入调度延迟,尾部延迟也更难控制。忙轮询响应更快,却会持续占用核心,还可能让整台机器的功耗和散热压力上升。

因此,轮询策略要有明确边界。比如在行情主通道使用持续轮询,在低频控制通道使用通知机制;或者在短时间空转后退让,让出部分执行资源。关键不是“永远忙轮询”,而是不要让所有线程都在没有数据时疯狂空转。

低延迟的每一微秒都需要付账。账单可能是CPU核、功耗、开发复杂度,也可能是故障恢复成本。别只盯着延迟数字,忘了问系统还能不能稳稳活着。

原子操作和内存顺序:别用“无锁”掩盖竞态

共享内存跨进程通信的同步,不能依靠普通变量碰运气。多个进程访问同一块数据,如果没有原子操作或信号量等同步机制,编译器重排、处理器乱序执行、缓存可见性延迟都可能让消费者看到不完整状态。

发布消息不等于修改一个计数器

很多初版代码只做了这件事:

1. 生产者写入消息;

2. write_index++

3. 消费者看到索引变化;

4. 消费者读取消息。

问题在于,第二步对消费者是否真正意味着第一步已经完成?如果索引发布没有正确的释放语义,消费者读取索引后,未必按照你想象的顺序看到消息内容。

在SPSC场景中,可以让生产者先完成槽位写入,再以释放语义更新发布索引;消费者以获取语义读取发布索引,再读取槽位内容。消费完成后,消费者发布新的可用位置,生产者获取这个位置后才能复用槽位。

如果消息是多字段结构,不能只给其中一个字段加原子就以为整个消息安全。原子字段的作用范围必须和协议一致。消息发布状态、槽位状态、读写索引要分别定义职责。

seq_cst不是万能补丁

遇到并发问题,很多人第一反应是把所有原子操作都改成最强的顺序。这样可能暂时压住部分问题,却没有解决协议错误,而且还可能增加不必要的同步成本。

你需要先回答:

  • 谁写这个变量;
  • 谁读这个变量;
  • 哪些数据必须在它之前完成;
  • 哪些数据只需要原子读写,不要求严格先后;
  • 是否存在多个生产者或消费者;
  • 进程崩溃后,索引是否会卡死;
  • 重启进程能否识别旧数据。

内存顺序不是性能调味料,而是通信协议的一部分。没有并发模型,直接堆原子操作,属于拿账户去给编译器交学费。

CAS适合解决什么问题

比较并交换操作常用于竞争位置分配,例如多个生产者争抢队列槽位,或者更新状态机。它可以避免传统互斥锁,但CAS失败后通常需要重试,重试就是等待,等待就是CPU消耗。

MPMC队列比SPSC复杂得多:

  • 多个生产者可能同时申请写入位置;
  • 多个消费者可能同时抢同一条消息;
  • 队列满和队列空的判断更复杂;
  • 每个槽位可能需要独立序列号;
  • 进程崩溃可能留下永久占用的槽位;
  • 高竞争时CAS失败次数会明显增加。

所以,先把链路拆成多个SPSC通道,通常比直接做一个全能MPMC队列更容易控制。行情接入到策略、策略到风控、风控到订单适配,按职责拆分,降低共享状态面积。不要为了架构图上的一条总线,把所有线程都推到同一个队列门口排队。

从消息队列迁移到共享内存:别在实盘上切刀

重构交易系统,最危险的不是代码写不出来,而是切换没有回退路径。

旧消息队列正在稳定工作,你突然把行情、策略、风控和柜台链路全部切换到共享内存。出了问题,没人知道是数据布局、索引、线程调度、柜台时序还是策略本身。最终所有人盯着账户亏损,系统却没有留下足够证据。

更稳妥的迁移应该分阶段进行。

第一阶段:先画出真实通信图

不要只看架构文档。把生产环境里的实际通信列出来:

  • 哪个进程发布行情;
  • 哪个进程订阅行情;
  • 每秒消息数量的峰值如何变化;
  • 哪些消息允许丢弃;
  • 哪些消息必须保序;
  • 哪些链路存在反向回报;
  • 哪些线程依赖同步确认;
  • 哪些日志和监控偷偷插进了热路径。

特别要找出“伪异步”。有些系统表面上通过消息队列异步传递,实际上策略线程发完信号后还在等待风控回复。你把队列换成共享内存,等待逻辑仍然存在,延迟不会凭空消失。

第二阶段:先做旁路镜像

可以让新通道先旁路接收数据,不参与真实下单。旧链路继续承担生产职责,新共享内存通道只做镜像和校验:

  • 比较消息数量;
  • 比较序列号;
  • 比较时间戳;
  • 比较字段值;
  • 比较队列延迟;
  • 比较丢失和重复情况。

这一步不要急着追求漂亮的延迟曲线。先证明新通道没有篡改数据,没有漏读,没有重复消费,没有跨版本错读。

第三阶段:从低风险链路切换

可以先改造回放、仿真或内部行情分发,再进入实时行情;先改造监控数据,再碰订单通道;先让共享内存承载非关键事件,再逐步扩大范围。

订单链路必须保留明确的幂等机制。每个订单请求带唯一请求号,策略、风控和柜台适配都能识别重复请求。进程重启后,系统必须能区分“从未发送”“已经发送但未收到回报”和“收到回报但状态尚未落库”。

没有幂等,就别谈可靠的高速交易。速度只会让重复下单更快地击穿账户。

第四阶段:灰度切换和强制回退

切换时至少保留:

  • 旧通道的可启动版本;
  • 新旧协议版本识别;
  • 进程启动前的共享区域检查;
  • 队列满、空、损坏时的报警;
  • 手工切回开关;
  • 订单发送总闸;
  • 进程崩溃后的区域清理或恢复策略。

不要让进程根据“共享内存打开失败”自动切到另一路,然后继续下单。自动降级如果没有边界,会把基础设施故障伪装成正常交易,最后变成无法追责的订单事故。

交易数据的正确性比几微秒更值钱

低延迟不是唯一目标。行情少一条、序号断一段、成交回报乱一次,所有速度优势都不值钱。

行情增量必须带序号

共享内存队列里传递行情增量时,消息至少应包含交易所序号或内部连续序号。消费者不能只看价格和数量变化来判断数据是否完整。

如果发现序号跳变,策略进程要立即进入明确状态:

  • 暂停使用增量数据;
  • 请求或等待快照;
  • 清空无法确认连续性的旧状态;
  • 重新建立盘口;
  • 恢复交易前通知风控和监控系统。

不能少了一条行情还继续计算,然后在复盘里说“当时市场太快”。市场快不是系统丢数据的免责条款。

时间戳要区分来源

至少区分:

  • 交易所事件时间;
  • 网卡或接收层时间;
  • 进入共享内存时间;
  • 策略开始处理时间;
  • 策略完成时间;
  • 风控完成时间;
  • 订单发送时间;
  • 柜台确认时间。

全部使用同一种时间戳,最后只能看到一条漂亮但无用的曲线。你需要知道延迟在哪一段堆积,是接收端卡住,还是策略计算变慢,是队列排队,还是柜台接口阻塞。

进程崩溃后,谁来处理半条消息

共享内存不会因为生产者进程崩溃就自动回滚。如果生产者写了半条消息,或者拿到了一个槽位后直接退出,消费者必须有办法识别这个槽位是否有效。

可考虑:

  • 槽位状态字段;
  • 消息长度和版本;
  • 生成序号;
  • 校验字段;
  • 生产者心跳;
  • 超时回收;
  • 重启代次;
  • 进程启动时的区域扫描。

但这些机制不能互相打架。比如生产者刚写完消息,心跳恰好超时,恢复线程把槽位回收,消费者又开始读取。状态机必须明确,谁有权回收,什么条件下回收,回收后如何阻止旧进程继续写入。

交易系统最怕两个进程同时相信自己拥有同一个事实。一个认为订单已发送,另一个认为订单未发送,最后柜台收到两笔开仓单。共享内存越快,错误扩散越快。

日志、监控和测试:别把性能测试做成表演

低延迟系统不能靠“平均耗时”证明自己。平均值很容易把事故藏起来。

记录延迟分布,不只看平均数

至少观察:

  • 最小值;
  • 中位数;
  • 高分位延迟;
  • 最大值;
  • 队列深度;
  • 队列满次数;
  • 空转次数;
  • 原子重试次数;
  • 消息丢失和重复;
  • 进程重启后的恢复时间。

事实数据里,共享内存初始化之后可以在轮询模式下避免频繁系统调用,通信延迟能够进入微秒级,特定无锁方案甚至可达到百纳秒级。但这个范围不是承诺,也不是拿来装饰报告的。你的机器、核心隔离、内存布局、消息大小、进程竞争和柜台链路都会改变结果。

测试必须覆盖压力和故障

正常行情下,任何队列都能跑。真正需要测试的是:

1. 行情突然爆发,生产速度持续高于消费速度;

2. 消费者暂停一段时间后恢复;

3. 生产者在写入过程中崩溃;

4. 消费者在读取过程中崩溃;

5. 共享区域版本不匹配;

6. 队列索引接近边界并回绕;

7. 进程重启后映射到不同虚拟地址;

8. 订单消息重复提交;

9. 序号跳变后触发快照恢复;

10. 监控和日志线程被人为拖慢;

11. CPU核心被后台任务抢占;

12. 内存不足或大页分配失败。

每一项都要有明确结果,不接受“应该没问题”。应该不是状态机,也不是风控规则。

日志不能堵住交易线程

日志系统如果在热路径里直接格式化字符串、申请内存、写文件,前面所有低延迟优化都会被它一脚踹翻。

建议把热路径日志压缩成固定字段和事件编号,先写入预分配缓冲区,由独立线程异步落盘。关键订单事件不能因为追求性能而完全不记录,但记录方式必须和实时发送解耦。

监控也一样。不要让每条行情都触发重量级指标计算。可以在交易线程维护简单计数器,在旁路线程聚合。对关键故障采用独立控制通道,不要让监控消息和行情消息争同一条队列。

基准测试必须贴近真实消息

只用一个几十字节的假消息做压测,没有意义。真实消息可能包含:

  • 合约标识;
  • 买卖盘口;
  • 成交量;
  • 时间戳;
  • 交易所序号;
  • 策略标签;
  • 风控字段;
  • 校验信息。

不同消息大小会改变缓存命中和内存带宽表现。测试还要覆盖空队列、半满队列、接近满载队列三种状态。空队列测出来的漂亮数字,不能代表洪峰时的表现。

开拓者TB、交易API与现有系统如何接

如果现有策略运行在开拓者TB或其他交易软件环境中,不要先假设可以直接替换内部IPC。第三方平台内部的进程模型、数据结构和私有接口未必公开,最新内部版本的精确通信函数名称也不能靠猜。

更现实的做法,是在边界处增加适配层。

适配层负责把平台侧的数据转换成内部统一协议:

  • 合约代码统一;
  • 行情价格统一精度;
  • 时间戳统一单位;
  • 买卖方向统一枚举;
  • 订单状态统一状态机;
  • 请求号统一生成;
  • 错误码统一映射;
  • 断线和重连统一通知。

平台适配进程与内部策略、风控、柜台系统之间,可以使用共享内存;平台内部不公开的通信方式则保持原样。这样做的价值是把重构边界放在你能控制的地方,不去碰无法验证的黑箱。

交易API的适配尤其要注意阻塞行为。有些API调用虽然看起来是异步接口,但内部仍然可能等待网络、柜台确认或线程锁。共享内存只能加速你控制的进程间传输,不能把一个阻塞的柜台API变成非阻塞接口。

订单线程必须有清晰的状态转换:

  • 待发送;
  • 已交给适配层;
  • 已调用API;
  • 已收到柜台受理;
  • 部分成交;
  • 完全成交;
  • 已撤;
  • 被拒绝;
  • 状态未知。

不要让策略只看到一个“发送成功”。发送成功可能只是本地函数返回,根本不代表柜台受理,更不代表市场已经成交。状态语义不清,速度越快,误判越多。

共享内存与时间序列存储:能合并,但别混为一谈

一些量化开源系统会使用 mmap 建立内存映射区域,同时承担时间序列数据存储和极速进程间通信IPC的功能。这种设计很有吸引力:行情数据可以持久化,多个进程又能快速访问,减少重复拷贝和数据搬运。

但存储和实时事件传输有不同的要求。

时间序列数据强调:

  • 可回放;
  • 可索引;
  • 可恢复;
  • 版本兼容;
  • 长期保存;
  • 数据完整性。

实时IPC强调:

  • 低延迟;
  • 有界容量;
  • 快速发布;
  • 明确背压;
  • 低抖动;
  • 故障隔离。

如果你把持久化写盘直接放到实时共享队列的消费路径里,磁盘抖动会反向拖慢策略。更合适的做法是:实时通道完成消息发布,旁路存储线程异步写入时间序列区域;如果必须保证成交和订单事件持久化,就为关键事件设计独立可靠通道,别让普通行情和关键订单共用一套拥堵机制。

共享内存映射文件同样要考虑磁盘空间、文件扩展、权限、残留区域和重启恢复。进程退出不代表映射区域自动恢复到业务正确状态。区域头部的版本、代次和初始化标记必须能支持启动检查。

一套可落地的重构顺序

你明天要开始重构,不要从最复杂的多生产者订单总线开刀。按下面的顺序推进,风险更可控:

1. 选择一条SPSC链路做试点。

优先选择行情接收进程到策略进程的链路,或回放进程到策略进程的链路。先避开订单发送和成交回报。

2. 定义固定协议。

写清楚消息大小、字段类型、版本、序列号、时间戳、队列容量和满载策略。没有协议文档,代码越多,返工越狠。

3. 建立共享区域头部。

加入魔数、版本、槽位大小、读写索引、心跳和初始化状态。所有进程映射后先做校验,再进入工作状态。

4. 实现单生产者单消费者队列。

使用预分配槽位,生产者和消费者分别维护自己的索引。用正确的获取与释放语义完成消息发布。

5. 拆出控制通道。

启停、心跳、版本协商、故障和重置不要与行情数据抢队列。控制通道可以低频,但必须可靠。

6. 加入序列号和丢失检测。

消费者发现序号跳变时,必须进入可解释的恢复流程,而不是继续拿残缺盘口计算信号。

7. 旁路镜像旧链路。

新通道先接收数据,和旧消息队列逐条比对。确认数据一致后,再考虑承担业务职责。

8. 加入故障注入。

让生产者、消费者在不同阶段被强制终止,验证共享区域是否能够发现半条消息、死占槽位和旧进程残留。

9. 做CPU与内存优化。

在协议稳定后再做绑核、缓存行隔离、大页、忙轮询和内存池。不要一开始就把所有优化同时打开,否则出问题时无法定位。

10. 最后切换订单链路。

订单必须有幂等请求号、发送状态、回报状态和人工总闸。没有完整状态机,订单通道不具备进入实盘的资格。

这一套顺序不华丽,但能把爆炸半径压下来。交易系统不是实验室里的单机样例,真正的对手是行情洪峰、进程崩溃、柜台断线和人在亏损时做出的错误判断。

最后,低延迟不是把风险藏起来

共享内存IPC设计确实是交易系统降延迟的硬手段。它绕开内核缓冲区拷贝,初始化后可以减少系统调用和上下文切换;配合无锁环形缓冲区、原子操作、CPU绑核、忙轮询、大页内存和预分配内存池,跨进程通信能够进入微秒级,特定场景还能进一步压缩。

但这条路没有免费筹码。

你省掉了数据拷贝,就必须自己承担同步;你绕开了阻塞,就必须处理忙轮询的CPU成本;你拿掉互斥锁,就必须把所有权、顺序和恢复规则写清楚;你把消息推得更快,就必须保证风控、幂等和柜台状态不会掉在后面。

别把“无锁”写成系统卖点,把“丢消息”藏进异常日志;别把“纳秒级”写在方案首页,把队列满载时的处理留给值班员;更别因为一条基准测试曲线漂亮,就认为实盘能够扛住真正的逼空、扫损和订单洪峰。

交易系统的低延迟不是为了让错误更快发生,而是为了在正确的逻辑下,少丢掉那些本来属于你的价格。

如果同步协议、故障恢复和订单状态没有经过硬压测试,宁可让系统慢一点,也不要让它带着半条消息冲进市场。账户不会因为你的架构图画得漂亮而停止爆仓。

Related reading: 高频交易系统内存池设计:如何从底层消除动态分配的延迟抖动 and 无锁队列与RingBuffer:低延迟交易系统的内存级优化原理.

常见问题

为什么共享内存比传统消息队列更快?
传统消息队列在传输过程中涉及用户态与内核态之间的数据拷贝及系统调用,而共享内存初始化后进程可直接读写同一物理内存区域,数据拷贝次数降为零,且在轮询模式下可减少频繁的上下文切换。
在共享内存中如何实现无锁通信?
通过SPSC(单生产者单消费者)环形缓冲区设计,生产者与消费者分别维护各自的读写指针,利用原子操作的获取与释放语义发布消息,从而避免了互斥锁带来的线程竞争与调度开销。
共享内存设计中如何避免伪共享?
应将生产者独占字段与消费者独占字段分开存储,必要时按缓存行边界进行填充,确保高频修改且由不同核心处理的字段不会挤在同一个缓存行内。
为什么交易系统热路径中禁止动态内存分配?
动态内存分配(如堆分配)在行情爆发时可能导致不可控的延迟抖动,低延迟系统应在启动阶段预分配内存池,以确保交易线程在热路径中不进行临时内存申请。
共享内存中的订单链路如何保证可靠性?
必须引入幂等机制,为每个订单请求分配唯一请求号,并建立完善的状态机,确保进程重启后能准确识别订单状态,防止重复下单或状态丢失。