周二早上,上海一家中等频率股票策略的 私募 量化开发被呼叫:沪深300 ETF 行情入库链路安静了 90 秒,交易室监控屏幕冻在最后一根 K 线上。生产者(producer)——解码 vendor 推送的 normalised 行情的 feed handler——状态正常;消费者(consumer)——把每条 tick 落进数据仓库的 writer——也正常。坏掉的是中间那根管子:一条手写的 UNIX socket,没有缓冲、没有偏移量、不能重放。Writer 因为一次慢盘 flush 暂停了三秒,socket 把背压顶回 feed handler,feed handler 的 receive ring 被压爆,于是 三秒钟 SSE 的 ticks 蒸发在一段无人读取的 kernel buffer 里。整个 3.6 子模块 到这一课为止(3.6.1 到 3.6.3)所有输入都从硬盘上的文件读。等你被要求把策略接到一条「活的」行情上,这套「文件在硬盘上」的心智模型就崩了:生产者与消费者是不同进程、常常是不同机器,任何局部故障都不能允许在十亿条 tick 的一天里漏一条 tick。中间这一层就叫消息传递层(messaging layer),本课教的是后面每一课都要用到的词汇。
消息传递要解决的四个问题
本模块之后所有的选型——Kafka 还是 ZeroMQ 还是 UDP multicast、at-least-once 还是 exactly-once、JSON 还是 Protobuf 还是 SBE——都回答 four problems 中的一个。按这个顺序记住、永不打乱:
decoupling in time— broker 做缓冲,因此一个慢或正在重启的消费者不会把背压顶到生产者上。fan-out— 一个生产者、N 个互相独立的消费者,每个消费者都看到每一条消息。durable replay— 在 offset N 处崩溃的消费者,重启后从 N+1 接着读,不漏不重。load balancing— 一个消费者组里 N 个消费者共享 topic 的分区,每个分区在任一时刻只属于一个消费者。
各自的典型落地场景:解耦保护了一个因为 chunk-close 慢盘暂停的 TimescaleDB writer,不丢 tick;扇出把一条 normalised 510300 行情同时喂给 20 个策略进程,每个进程都要看到每条 tick;持久重放让回测引擎从 Kafka topic 的 offset 0 读昨天的整个交易日,而不是重新生成;负载均衡让 16 个消费者共享 ticks.sse.510300 的 16 个分区,吞吐量 16 路扩展同时按 symbol 保序。
三种通信模式
三种原语拼出上面的四种方案:
point-to-point queue— 一个生产者,N 个互相竞争的消费者,每条消息恰好送到一个消费者;典型场景是任务队列(跑这个回测、做这次风险重算)。pub-sub topic— 一个生产者,N 个互相独立的订阅者,每条消息送到每一个订阅者;典型场景就是行情扇出。request-reply— 同步语义的 RPC 走队列(策略问风控『这单能不能下』并等回复);视作 point-to-point 的特例。
规则:要把工作分给唯一负责人,用 point-to-point queue;要让每个消费者都看到每条消息,用 pub-sub topic;要做控制面同步交互,用 request-reply。
三种投递语义:一段话讲清一种
按代价从小到大,broker 能给的三种保证:
at-most-once— 发了就不管;生产者发了不重试;便宜;生产者或 broker 一崩就丢;某些 telemetry 可以接受,行情或订单状态绝不可。at-least-once— broker 反复重试直到消费者 ack;两边都好实现;消费者必须幂等,因为它可能看到同一条消息两次。本行业的标准幂等姿势:仓库表ticks_raw上(symbol, ts)做主键,配INSERT ... ON CONFLICT DO NOTHING(3.6.3 L4)。exactly-once— broker 加事务消费者保证每条消息在事务边界内恰好处理一次;贵;只有在「靠业务无法做到幂等」时才用(比如一个无法用其他方式幂等化的资金余额变更)。
口号写明:默认就是 at-least-once + 幂等消费者。把语义升到 exactly-once,仅在「业务上幂等不可达」时才考虑——在量化场景几乎用不到,因为仓库表的 (symbol, ts) 主键已经在帮你做消费端幂等了。理论脚注:跨网络的真正 exactly-once 在一般情形下是不可能的(FLP 不可能性定理、Two Generals' Problem);Kafka 的所谓 EOS 指的是 Kafka 事务边界内的 exactly-once,这通常已经够用。
序列化的六种选择、三个场景
Tick 怎么编码不是语法题——线上的 wire format 决定下游每一项延迟、体积、跨团队契约的成本。六种典型选择:
| Format | Human-readable | Size vs JSON | Encode/decode cost | Schema-evolvability | Canonical use case |
|---|---|---|---|---|---|
JSON | yes | 1.0x baseline | slow | schema-on-read | two services on the same team |
MessagePack | no | ~0.3x | medium | schema-on-read | JSON is the bottleneck |
Protobuf | no | ~0.2x | fast | tagged schema | cross-team contract |
FlatBuffers | no | ~0.2x | near-zero (zero-copy) | tagged schema | latency-sensitive consumer |
SBE | no | ~0.15x | near-zero | template | low-latency exchange feed |
FIX | yes (text tag=value) | ~0.6x | medium | tag-numbered | order-entry session layer |
JSON 是默认起手——同一个团队两个服务、对端数据形状还没定下来。MessagePack 一行库替换(msgpack.packb / msgpack.unpackb),大小约 JSON 的 30%;JSON 成为瓶颈但又不想引一份 schema 文件时上 MessagePack。Protobuf 是「生产者与消费者是两个团队、各自有独立发版节奏」的标准答案——.proto 文件就是合约,按 3.6.2 进 git 共享仓库,schema 演化规则内建(新字段用新 tag 号、tag 号不允许重用、删除字段标 reserved)。FlatBuffers 让你做 zero-copy 读取——wire format 就是内存布局——给「一个紧循环每秒读上百万条」的消费者用。SBE(Simple Binary Encoding)是 FIX Trading Community 出的低延迟二进制后继;现代交易所行情就是它(CME MDP 3.0、ICE iMpact、Eurex T7)。FIX 是订单录入的会话协议——tag=value 用 \x01 分隔,FIX 4.4 与 FIX 5.0 SP2 是主用版本——在 CFFEX 中金所 走 CTP 通道之外的境外股票期权订单流里仍随处可见,即便行情侧 SBE 已经取代了它。
一个 Tick 样例。同一条记录在 Protobuf 与 JSON 两种格式下:
message Tick { string symbol = 1; int64 ts_ns = 2; double price = 3; int64 size = 4; string side = 5; }
{"symbol":"510300","ts_ns":1716566400000000000,"price":4.137,"size":10000,"side":"B"}
JSON 约 70 字节;Protobuf 约 16 字节;CME MDP 3.0 上 SBE 模板编码约 10 字节。side 沿用英文 B / S(wire 上不翻译;B=买、S=卖)。Schema registry 脚注:跨团队演化时生产环境答案是 Confluent Schema Registry——.proto subject 注册并设 BACKWARD / FORWARD / FULL 兼容级别。国内 量化 firm 也常自建简化版本;Apicurio 是开源替代。
关键消息传递词汇
整个模块之后每一课——L2 的 Kafka、L3 的低延迟 fabric、L4 的 capstone——都用同一套词汇。现在背下来:
topic— 一个具名的 append-only 日志;一条逻辑通道。partition— 一个 topic 被切成 N 个分区;每个分区是磁盘上一条完全有序的日志。consumer group— 一组消费者协同读一个 topic;任一时刻每个分区只属于该组里一个消费者。offset— 分区内单调递增的 64 位下标,指向「我下一条要读的消息」。key— 每条消息可选的字节串;相同 key 经哈希落到相同分区;这是每 key 保序的机制。headers— 与消息同行、但不是 payload 的元数据;用来放 tracing ID、replay marker、源系统标签。retention— broker 保留消息的时长(Kafka 默认 168 小时;行情 topic 经常 30 天或 90 天以满足合规要求)。log compaction— 每个 key 仅保留最新一条、丢弃更早的;典型场景是 instrument-master topic(每个symbol只关心最新一条记录)。replication factor— 分区在 broker 间的副本数;生产默认值 3。in-sync replicas (ISR)— 当前与 leader 保持同步的副本集合;acks=all的写要等 ISR 全部 ack。producer ack— 生产者要求的持久化级别:acks=0(发了不管)、acks=1(仅 leader)、acks=all(整个 ISR)。idempotent producer— broker 按 producer-id + 每分区序列号去重;重试不会产生重复。transactional producer— 多分区原子写入;exactly-once 语义背后的关键机制。
把上面这些词锚到一个例子:topic 叫 ticks.sse.510300,16 个分区,副本因子 3,retention 168 小时,按 symbol 做 key。所有 510300 的 tick 落到同一个分区上;该消费者组内只有一个消费者拥有这个分区,按生产端顺序读到这些 tick;当组里 16 个消费者一起跑时,它们在 symbol 维度上 16 路并行而不破坏 per-symbol 保序。
背压与有界队列规则
管道里任意两阶段之间的队列都有有限容量。消费者比生产者慢时,队列被填满,必须有人决定接下来怎么办。五种选择,code-formatted:
block— 生产者等;当延迟不是目标时(批处理)这是最安全的默认。drop newest— 保留历史、丢失最新;行情场景里基本不是正确答案。drop oldest— 保留最新、丢失历史;适用于「过期 tick 比缺一条 tick 更糟」的实时面板。log-and-skip— 打一条结构化日志,把这条消息丢掉;可观测,能让故障浮上水面。escalate— 拉起更多消费者来排空队列;当慢是结构性而非瞬时性时的正确选项。
口号:unbounded queue + slow consumer = OOM crash;每个队列都给上限,自觉选 overflow 策略。这是手写 feed handler 最常见的故障形态:默认「够快」、忽略上限,list 一路膨胀直到 OOM-killer 把进程杀掉。consumer lag 是任何消息管道最重要的生产指标;下一课在 Kafka 上具体接起来,3.6.6 把告警接上。
Exercise
Exercise
You are wiring three quant services together — (a) a market-data feed handler that consumes a synthetic exchange feed and publishes normalised ticks to downstream consumers; (b) a TSDB ticker-plant writer that appends each tick to a TimescaleDB hypertable ticks_raw(symbol, ts, price, size, side) with PRIMARY KEY (symbol, ts); (c) a real-time VWAP monitor that prints a 1-minute rolling VWAP per symbol from an in-memory window. For each of (a)->, choose (i) the communication pattern (point-to-point queue vs pub-sub topic vs request-reply) and justify in one sentence, (ii) the delivery semantic (at-most-once vs at-least-once vs exactly-once) and justify in one sentence (note the (symbol, ts) primary key on ticks_raw), (iii) the wire format (JSON vs MessagePack vs Protobuf vs FlatBuffers vs SBE vs FIX) and justify in one sentence, (iv) the overflow policy for the bounded queue on (a)->(b) if the TSDB writer is temporarily slow, choosing one of (block, drop newest, drop oldest, log-and-skip, escalate) and justify in one sentence. State the answers as a four-row table.
提示
提示
下一课
你现在握住了模块之后每一课都假设你已经会用的词汇——topic、partition、consumer group、offset、三种投递语义、六种 wire format、有界队列规则。下一课把每一个词锚定到 Apache Kafka,本模块(以及 3.6 之后所有模块)的具体参照 broker。这里命名的每一个概念在下一课都对应一个具体的 Kafka 旋钮:acks=all 对应耐久性轴、enable.idempotence=true 对应去重轴、key=symbol 对应分区轴、enable.auto.commit=false 对应「先处理再提交」规则。先选 broker 再写 producer;先选 schema 再串两个团队;默认 at-least-once + 幂等消费者;每个队列都有界,自觉选 overflow 策略。
阅读清单
- Kafka 中文 文档(kafka.apachecn.org)——topic / partition / consumer group 章节。
- 《Apache Kafka 实战》中文版(胡夕 著)。
- 《数据密集型应用系统设计》中文版(Martin Kleppmann),关于消息系统与流处理 的章节。
- ZeroMQ 指南 中文 翻译(社区版,github.com/anjuke/zguide-cn)。
- Protobuf 中文 文档(developers.google.com 中文镜像)。
- FIX Trading Community SBE 1.0 规范(fixtrading.org 英文权威)。
- 《Enterprise Integration Patterns》(Hohpe & Woolf)——经典模式目录(Point-to-Point Channel、Publish-Subscribe Channel、Idempotent Receiver 是本课对应词条)。
一条额外注释:A-股 量化团队 的行情链路 落在 自建 Kafka 上;数据安全 与 跨境带宽 使 Confluent Cloud 与 AWS MSK 在国内 罕见。行情 vendor (TongLianData、WindData、HsiHengSheng) 已经把交易所 原始多播 normalise 成 TCP 推送,firm 把流写进内网 Kafka topic——本课 producer 在实际工作中 就是 这个落点。SSE、SZSE 与 CFFEX 是 ChinaSecuritiesRegulatoryCommission 监管的 ShanghaiStockExchange、ShenzhenStockExchange、ChinaFinancialFuturesExchange 三家场内。词汇先扎稳,L2 与 L3 的选项都建在这一层上。
速查卡
本课反复出现的展示形态:
- Inline-code 列表——FourProblems、ThreePatterns、ThreeSemantics、ThirteenVocabulary、FiveOverflowPolicies,code-formatted 项目列表。
- Inline-code 表格——SixWireFormats 按(human-readable、size、cost、evolvability、use-case)排列。
- Fenced
```protobuf代码块——Tickschema 含五字段与 tag 1–5。 - Fenced
```json代码块——同一条Tick在 wire 上 JSON 形态。 - Exercise 与两条渐进 Hint——三服务 (
FeedHandler、WarehouseWriter、VwapMonitor) 接线决策。