← 返回模块
3.6.4.3beta 可读 · 未来付费校验通过内容版本 2026-05-24

低延迟消息:ZeroMQ、多播与共享内存

3.6.4 · 行情数据的消息与流式处理 · 编程

上海一家 私募 的电子交易主管把一名资深工程师拉到一边:「期权做市新策略要求 沪深300 ETF 的 top-of-book 在策略线程内到达延迟不超过 50 微秒。我们现在跑 Kafka 是 3 毫秒——差了三个数量级。怎么办?」诚实的答案是「先量,再按 rung 一级一级往下挪」。L2 把你留在 Kafka 这一级——acks='all' 端到端毫秒级,对买方研究与中频策略来说是正确答案。但有三类 workload 顶不住这个底板:feed handler 要把交易所原始 wire 包变成策略决策、延迟个位数微秒;cluster 内扇出根本不需要 broker 的耐久性、却付出延迟代价;同机线程到线程的递手,每微秒都重要。本课教 Kafka 之下三个 rung 以及「什么时候才往下挪」的规则。

四级延迟预算

RungFabricTypical latencyOperational characterCanonical use case
0Kafka~1–10 msdurable + replayable brokermost quant pipelines
1ZeroMQ~20–100 µslibrary-not-broker; no durabilityin-cluster fan-out
2UDP multicast~2–10 µsunreliable; gap-fill requiredexchange direct feed
3shared-memory ring~100 ns – 1 µsintra-host SPSC; no durabilityfeed-handler -> strategy thread

口号写明:​​measure first; escalate only when latency is the bug.​ 不是「上来就选最快的」。大多数买方 feed handler 住在 rung 0 或 rung 1;rung 2 是发布直连多播行情的交易所现实(CFFEX 中金所 CTP-MD、上交所 NeoArc、深交所 行情、CME MDP 3.0、ICE iMpact、Eurex T7);rung 3 是 HFT 极端。每个 rung 都要让渡:Kafka 让渡亚毫秒延迟、ZeroMQ 让渡耐久性、multicast 让渡可靠性、共享内存让渡跨主机。

Rung 1:ZeroMQ

ZeroMQ 是一个​​库​​,不是 broker——没有独立的服务进程。ZeroMQ socket 是进程内的对象,用一个小协议跑在 TCP、IPC(Unix 域 socket)或 inproc(进程内共享内存)上。三种 socket 模式覆盖典型场景:

  • PUB / SUB — 一个发布者、N 个订阅者,无耐久性、无 replay;cluster 内对 normalised feed 做扇出。
  • PUSH / PULL — 一个推送端、N 个拉取端,round-robin 分发;负载均衡 worker。
  • REQ / REP — 同步请求/应答,严格交替 send/recv;控制面。

bind-vs-connect 规则:​​the stable endpoint binds, the ephemeral endpoint connects​​——PUB/SUB 里 publisher bind、PUSH/PULL 里 work-sink bind、REQ/REP 里 reply server bind;subscriber、worker、requester 都 connect。原因:必须先有 binder 存在 connector 才连得上,而 connector 可以随建随毁;语义上就是「长生命周期服务」对应 bind、「短生命周期客户端」对应 connect。

pyzmq 的最简 PUB/SUB 对:

# publisher
import zmq, json, time
ctx = zmq.Context()
sock = ctx.socket(zmq.PUB)
sock.bind('tcp://*:5555')
while True:
    sock.send_string(json.dumps(tick))
    time.sleep(0.02)

# subscriber
import zmq, json
ctx = zmq.Context()
sock = ctx.socket(zmq.SUB)
sock.connect('tcp://publisher:5555')
sock.setsockopt(zmq.SUBSCRIBE, b'')
while True:
    msg = sock.recv_string()
    tick = json.loads(msg)
    print(tick)

三种 transport,按作用域递减:

  • tcp://*:5555 — 跨主机 fabric。
  • ipc:///tmp/feed.ipc — 同主机不同进程(Unix 域 socket;loopback 上比 TCP 稍快)。
  • inproc://feed — 同进程不同线程(无锁内存队列;ZMQ 最快 transport)。

挑能覆盖部署的最小作用域。运维属性:无耐久性(一条消息至多在飞一次;订阅者掉线那条就丢了);无 replay(没 broker 留历史);无内置序列化(ZeroMQ 只搬字节串,自己挑 Protobuf / JSON / MessagePack)。高水位(ZMQ_SNDHWM,默认 1000)决定进程内队列满时是丢还是阻塞,按 socket 选项配置。NATS Core 提一次:broker-based、延迟相当、可走 NATS JetStream 加耐久性;本课不展开。

Rung 2:UDP multicast

IP 层的 multicast 是什么:一个 IP 包发到组地址(IPv4 是 224.0.0.0/4 段;交易所典型「glop」分配比如 233.252.0.10);交换网络把包复制给每一台通过 IGMP 加入该组的主机;跨路由器传播组员关系的协议是 PIM-SM(Protocol Independent Multicast – Sparse Mode)。主机加入组用这个标准 syscall 序列:

import socket, struct
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('', port))
mreq = struct.pack('4s4s', socket.inet_aton(group_addr), socket.inet_aton(interface_addr))
s.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)

交易所在 connectivity 文档里把 (group, port, schema) 公布出来;feed handler bind UDP socket 到 port、加入 group、开始读包。多播行情上的三种参考 wire format:

  • FIX — 文本 tag=value,用 \x01 (SOH) 分隔;订单录入会话里随处可见(FIX 4.4FIX 5.0 SP2 是主用版本);行情侧历史上用过、大体已被取代;对 feed handler 的含义是「每条消息要做字符串解析,不是快路径」。
  • SBE — Simple Binary Encoding;FIX Trading Community 出的低延迟二进制后继;template 化(.xml schema 声明 template、自动生成 codec);现代交易所行情的 wire format——CME MDP 3.0 for CME/CBOT/NYMEX/COMEX、ICE iMpact for ICE、Eurex T7 for Eurex。
  • ITCH — 美国某直连交易所原生 byte-packed 行情协议;每条消息以 1 字节 type code 起头、后面是定长字段;直连 feed handler 的参考设计;TotalView-ITCH 5.0 是其生产版本;OPRA 用 OPRA 专属二进制格式承载汇总期权行情。

运维现实:​​multicast 是不可靠的​​。UDP 会丢包(交换机过载、网卡 buffer 满、消费者进程被 GC 暂停)。每家交易所都发一份基于序列号的 gap-fill 协议,feed handler 必须实现:主多播组上的每条消息携带单调序列号;feed handler 跟踪「下一条期望序列」;丢包时(next-expected ≠ observed-sequence),feed handler 要么 (a) 向交易所发布的 TCP retransmit 通道请求重传,要么 (b) 读第二条承载同一行情的多播组(A/B feed pattern:两条都订、按序列号去重,第二条覆盖第一条的丢包)。ITCH 的 GLIMPSE + Refresh-Spin 是典型例子:GLIMPSE 在会话起点给一份订单簿快照,Refresh-Spin 补传丢失的 ITCH 消息。对 feed-handler 进程的含义:​​多播 feed handler 比 Kafka 消费者代码更多、不是更少——序列跟踪、丢包检测、gap-fill 都得自己写​​。

点名不展开:SO_REUSEPORT 用于一张网卡上跑多个 feed-handler 进程(kernel 在进程间哈希包);内核旁路 (kernel-bypass) 的 Solarflare OpenOnload / DPDK / XDP(kernel 完全退出 receive 路径,包直接落进用户态 ring buffer;每包省 ~1–2 µs;代价是 vendor 特定网卡 + 驱动);网卡硬件时间戳(PTP 同步的网卡在 kernel 看到包之前给每个包打上纳秒级到达时间戳;测量交易所-策略延迟的标准手段)。字节级多播 feed handler 在 3.4.5 (C++) 与 3.5.3 (Rust) 教;Track-4 微观结构课程把 ITCH 与 SBE 协议逐条拆。

Rung 3:共享内存环形缓冲

跨进程递手要在亚微秒上精打细算时,答案是一段共享内存里的定长环形缓冲(ring buffer),由同一主机上两个进程共享。典范是 LMAX Disruptor(LMAX Exchange 在 2011 年的论文「Disruptor: High performance alternative to bounded queues for exchanging data between concurrent threads」)。五条强制性质,按这个顺序:

  • power-of-two ring size — slot 数是 2^n,于是取模用位与 & (size - 1) 即可;整数模在热路径上太贵。
  • pre-allocated slots — slot 在启动时分配;publish 写进已存在的 slot、绝不分配;负载下做分配就是 bug。
  • cache-line-aligned slots and sequence counters — slot 尺寸是 x86 上 64 字节 cache line 的整数倍;生产者和消费者各自的序列号计数器放在​​不同​ cache line 上,避免「伪共享 (false sharing)」——两核交替写同一条 cache line 会触发昂贵的缓存一致性流量。
  • atomic 64-bit write-sequence counter and read-sequence counter — 生产者 publish 时把 write-sequence +1;消费者 consume 后把 read-sequence +1;C++ 里是 std::atomic<int64_t>,Rust 里是 AtomicI64
  • busy-spin reader — 消费者死循环 while write_seq <= read_seq: cpu_relax() 而不是阻塞——这是 Rung 3 的延迟/吞吐折衷(独占一颗 CPU 核心做忙等;用一颗核心换亚微秒的醒来)。

单生产者单消费者(SPSC)是最简单情形;多生产者更复杂(CAS 抢序列号)、更慢。Aeron 是生产级现成的共享内存环——Aeron IPC 是 SPSC 版本;Aeron UDP / multicast transport 把同一套原语推广到多主机。规则:​​只有当你测过 Kafka 或 ZMQ 端到端、确认跨进程跳点就是瓶颈时,才上共享内存——复杂性代价(无耐久性、无锁同步、无序列化、无流控)不是免费的​​。多数自己写过 Disruptor-style ring 的团队后来都切到了 Aeron,因为现成实现处理了手写版没顾上的边界情形。C++ / Rust 实现的 SPSC ring(基于 std::atomic)在 3.4.5 与 3.5.3 教。

决策规则:四个场景

ScenarioChosen rung
durable + replayable ingest of a venue feed for backtestKafka (rung 0)
in-cluster fan-out of normalised ticks to N strategy processes (no durability requirement)ZeroMQ PUB/SUB (rung 1)
consuming the venue's raw direct feedUDP multicast (rung 2) — no choice; venue dictates the wire format
intra-host SPSC hand-off at sub-microsecond latencyshared-memory ring (LMAX Disruptor / Aeron IPC, rung 3) — only after measurement says inter-process is the bug

规则跨四行成立:在满足延迟预算的前提下挑最低 rung;承认每个 rung 都要让渡;自觉地选。

Exercise

Exercise

You have the L2 Kafka pipeline running and a new requirement lands — three strategy processes on the same host as the feed handler must each see every normalised tick at <100 microseconds end-to-end. (a) State why Kafka is the wrong rung for this requirement, citing a specific number from the latency-budget table. (b) Author a pyzmq PUB/SUB pair: publisher.py (~25 lines) that reads from the Kafka ticks.sse.510300 topic in group zmq-bridge, decodes each JSON message, and re-publishes on zmq.PUB bound to tcp://*:5555; subscriber.py (~20 lines) that connects to tcp://publisher:5555, subscribes to all topics, and prints received: SYMBOL @ TS for each message. (c) Run three instances of subscriber.py against one publisher.py and confirm each subscriber sees every message (the pub-sub fan-out property). (d) Kill one subscriber for 10 seconds, restart it, and confirm that it does not receive the messages it missed (the no-durability property). (e) Sketch in one paragraph what the multicast variant of publisher.py would look like if the upstream were CFFEX CTP-MD — name the socket type, the multicast group address shape (233.252.x.x / vendor-published), the IGMP join via IP_ADD_MEMBERSHIP, and the sequence-number gap-fill responsibility. (f) Sketch in one paragraph when you would escalate from ZeroMQ to a shared-memory Disruptor for the same fan-out — name the latency budget that would force the move and the two complexity costs you would inherit.

提示
第 (a) 步翻 latency-budget 表:Kafka ~1–10 ms 而要求 <100 µs,差一个数量级。ZeroMQ 的 ~20–100 µs 正好落在那个窗口内。
提示
第 (d) 步那 10 秒的「停机窗口」就是「无耐久性」的实证。记录杀掉前后接收到的 ts,会看到正好 10 秒宽的缺口——ZeroMQ 高水位在订阅端丢消息,没有 broker 留历史。

下一课

握住完整的 broker 菜单——Kafka 做耐久可重放、ZeroMQ 做 cluster 内无耐久扇出、UDP multicast 做交易所直连、共享内存做同机亚微秒——下一课把 L1 的词汇、L2 的 Kafka 具体选型、L3 的 ZeroMQ 备选三者组合起来,对着 3.6.3 仓库跑一个可执行的端到端 feed-handler + ticker-plant 管道。Capstone 把整个 3.6 的运维卫生串起来:3.6.1 的 set -euo pipefail、3.6.2 的 .gitignore 纪律、3.6.3 的 (symbol, ts) 主键 + ON CONFLICT,再加上本模块的消息旋钮。同一条管道的 C++ + 直连多播 + 内核旁路 版本——3.4.5 / 3.5.3 教——是买方对延迟最敏感的角落跑的形态,也是本课 Python-over-Kafka 形态​​不是​​的那一种。

阅读清单

  • ZeroMQ 指南 中文 翻译(社区版,github.com/anjuke/zguide-cn)。
  • pyzmq 文档(英文权威,pyzmq.readthedocs.io)。
  • CTP API 中文 文档(中金所 公开,www.sfit.com.cn)——CTP-MD 多播 行情 协议。
  • 上交所 NeoArc 协议 公开 摘要(sse.com.cn)。
  • 深交所 行情 协议 摘要(szse.cn)。
  • LMAX Disruptor 论文(Thompson 等,2011,lmax-exchange.github.io/disruptor/files/Disruptor-1.0.pdf)——中文 互联网 有 多 篇 译文 与 解读。
  • Aeron 文档(github.com/real-logic/aeron)。
  • Martin Thompson 的 「Mechanical Sympathy」 博客 —— cache-line / false-sharing 经典。

一条额外注释:A-股 量化 团队 的多播 行情 落点 通常 是 CFFEX CTP-MD;上交所 / 深交所 level-2 的直接 multicast 接入需要 会员 seat,多数 私募 通过 vendor 中介(通联 / 万得 / 恒生)拿一条 normalised TCP 推送、然后自己再写进内网 Kafka。ZeroMQ 在国内 量化 firm 内做 cluster 内扇出是主流模式;共享内存 + Aeron 在头部 私募 高频 stack 内可见,中频与中小 私募 罕见。把规则收成一句:​​先用 Kafka;要 cluster 内 fan-out 用 ZeroMQ;交易所 直连 用 多播;只有 测过 多播-到-策略 仍 卡住 才用 共享内存。​

速查卡

本课反复出现的展示形态——抄进笔记:

  1. Inline-code 表格——四级延迟预算(Kafka / ZeroMQ / UDP multicast / shared-memory ring)。
  2. Inline-code 列表——三种 ZeroMQ socket 模式(PUB / SUBPUSH / PULLREQ / REP)、三种 ZeroMQ transport、三种交易所 wire format(FIXSBEITCH)、Disruptor 五条强制性质。
  3. Inline-code 描述——IGMP 多播 join 的 syscall 序列(socketsetsockopt SO_REUSEADDRbindstruct.packsetsockopt IP_ADD_MEMBERSHIP)。
  4. Inline-code 决策表——四种场景映射到所选 rung。
  5. Fenced ```python 代码块——最简 pyzmq PUB / SUB 对。
  6. Exercise 与两条渐进 Hint——把 Kafka 桥接到 ZeroMQ 做 cluster 内扇出、再做一次「无耐久性」窗口测试。

国内 私募 量化 现场补充

A-股 中小 私募 在 cluster 内做行情扇出绝大多数走 ZeroMQ PUB/SUB:从 vendor TCP 推送上接收 510300、600519、000001 等 沪深300 成分股的 normalised tick,再用 tcp://*:5555 把数据扇出给 8–16 个策略进程。每个策略进程在 ZeroMQ 上跑一条独立 SUB 连接,组合 Protobuf 反序列化和内存里的滚动窗口,做日内 alpha 信号生成或风控止损判断。回测和合规归档继续走 Kafka——这就是「ZeroMQ for fan-out, Kafka for replay」的双线分工,在 私募 内网部署里非常常见。

CFFEX 中金所 的 CTP-MD 多播通道是少数 私募 能直接做 multicast 接入的场景:IF、IH、IC、IM 四个股指期货合约 + 国债期货 + 商品期货,CTP API 已经把 IGMP join、序列号跟踪、gap-fill 都封装好,量化开发只需要给 CTP 注册一个回调函数,让 callback 把消息写入内网 Kafka topic 或 ZeroMQ PUB 端点。这就形成了「CTP-MD 多播 → 内部 Kafka topic → 多个策略组」的三层架构——L3 的所有选型 trade-off 都在这一条管道上一次性出现:rung 2 (多播) 接 rung 0 (Kafka) 接 rung 1 (ZeroMQ)。当某条策略需要 sub-µs 时,再考虑把 ZeroMQ 那一跳换成 Aeron IPC——但根据 私募 实际落地,这一步在 A-股 中频策略里极少触发,多发生在跨境套利或期权做市头部团队的 stack 内。

国内典型 vendor 接入清单(仅供参考):TongLianData (通联) 与 WindData (万得) 提供 ShanghaiStockExchange、ShenzhenStockExchange 的 normalised level-2 流;HsiHengSheng (恒生) 提供股票与债券双线流;ZhongJinSuo (中金所) 通过 ShFutureTechnology 的 ChinaTradePlatform CTP-MD 多播提供股指期货;GuoTaiJunan、HuaTai、ZhongXinJianTou 等内资 broker 的算法交易部门也内嵌了简化版 multicast feed handler 供自家做市策略使用。

A-股 量化 中频策略落地 fabric 选型实践(按 私募 规模分层):HeadFundExample 头部 私募(管理规模 RMB 百亿以上)通常会自建 Aeron IPC + 内核旁路 stack 跑高频期权做市策略,落到 ShangHaiTradingFloor 的内网机房;MidSizedFundExample 中型 私募(管理规模 RMB 数十亿)多用 ZeroMQ in-cluster fan-out + Kafka durable 双线分工,部署在 BeijingDatacenter 或 ShenzhenDatacenter;SmallSizedFundExample 中小 私募(管理规模 RMB 数亿)大多只跑 Kafka 单条线、不上 ZeroMQ。HeadFund 与 MidSizedFund 都需要把 fabric 选型写进每条策略的 RiskParameterSheet。这些规模分层与 fabric 选型不写在书本里,但是 量化 招聘面试时常考的实战题目。