3. 金融行情数据模型

在构建 Kafka 金融行情高吞吐管道之前,必须先厘清“管道里到底流的是什么”。行情数据模型决定了消息的键设计、分区策略、序列化格式、压缩方式以及下游消费语义。如果模型理解偏差,后续无论 Kafka 参数怎么调优,都会在正确性上翻车。本节从交易所原始数据出发,梳理 Level-1、Level-2、逐笔委托、逐笔成交、快照与增量这几类核心结构,并给出它们在 Kafka 主题中的建模建议。

一、核心原理:从“状态”与“事件”两个视角看行情

所有行情数据本质上可以归为两类:状态类与事件类。状态类描述某一时刻市场的完整切片,典型代表是快照(Snapshot);事件类描述状态发生的变化,典型代表是逐笔委托、逐笔成交和增量更新(Incremental)。Level-1 与 Level-2 则是按信息深度划分的层级:Level-1 通常只包含最优一档买卖价量,Level-2 则包含多档盘口乃至全部委托队列。

理解这一点的意义在于:快照是幂等的,重复消费同一快照不会破坏状态;而逐笔事件是顺序敏感的,乱序或丢失会导致重建出的订单簿与真实市场不一致。因此,在 Kafka 中,快照类主题可以容忍更高的重试与重复,而逐笔类主题必须保证分区内严格有序,并谨慎设置 enable.idempotence 与 max.in.flight.requests.per.connection。

行情数据模型:状态类与事件类的关系 状态类 State 快照 Snapshot Level-1 最优一档 Level-2 多档盘口 幂等 · 可重复消费 事件类 Event 逐笔委托 Order 逐笔成交 Trade 增量更新 Incremental 顺序敏感 · 不可乱序 订单簿 Order Book 事件回放重建状态 Kafka 主题建模建议 • 快照主题:key = symbol,value 为完整盘口,允许重复,compact 清理策略 • 逐笔主题:key = symbol,分区内严格有序,禁止乱序,保留策略按时间 • 增量主题:需携带 seqNum 与 prevSeqNum,消费端做连续性校验 • 时间戳:区分交易所时间 exchangeTs 与接收时间 recvTs,用于延迟分析

二、方法流程:各类数据结构的字段与建模要点

下面按数据类别逐一拆解关键字段与 Kafka 建模注意事项。

  • Level-1 行情:包含最新价、成交量、成交额、最优买一/卖一价量。字段少、频率低,适合作为轻量级主题,供监控与简单策略使用。
  • Level-2 行情:包含多档买卖盘(如十档),每档含价格与挂单量。数据量显著增大,需关注单条消息大小与压缩。
  • 逐笔委托:记录每一笔新增、撤单委托,含委托编号、方向、价格、数量、委托类型。是重建订单簿的原始输入。
  • 逐笔成交:记录每一笔撮合成交,含成交编号、买卖委托号、价格、数量、成交方向。用于计算真实成交与主动买卖。
  • 快照:某一时刻的完整盘口切片,通常按固定频率(如每 3 秒)发布,用于快速对齐状态。
  • 增量:仅包含发生变化的档位,需配合序号做连续性校验,否则会静默丢失状态。

在序列化选择上,推荐使用 Avro 或 Protobuf 而非 JSON,原因有三:一是字段固定、schema 可演进;二是二进制体积小,降低网络与磁盘压力;三是可与 Schema Registry 集成,避免上下游字段漂移。以下是一个逐笔成交的 Avro schema 片段示例:

{
  "type": "record",
  "name": "TradeTick",
  "fields": [
    {"name": "symbol",    "type": "string"},
    {"name": "tradeSeq",  "type": "long"},
    {"name": "exchangeTs","type": "long"},
    {"name": "price",     "type": "double"},
    {"name": "volume",    "type": "long"},
    {"name": "side",      "type": {"type": "enum", "name": "Side", "symbols": ["BUY","SELL","UNKNOWN"]}},
    {"name": "bidOrderId","type": ["null","string"], "default": null},
    {"name": "askOrderId","type": ["null","string"], "default": null}
  ]
}

三、案例场景:订单簿重建与快照对齐

假设我们要为某只股票维护实时订单簿。典型流程是:先消费一条快照作为初始状态,然后按序消费逐笔委托与逐笔成交,逐条应用到订单簿上。若中途发现增量序号不连续,则必须暂停应用并重新拉取快照对齐,否则订单簿会逐渐偏离真实市场。

在 Kafka 中,这一流程对应两个主题:market.snapshot 与 market.order.tick。快照主题使用 cleanup.policy=compact,保证每个 symbol 只保留最新快照;逐笔主题使用 cleanup.policy=delete 并设置合理的 retention.ms,保证可回放。消费端需维护每个 symbol 的 lastAppliedSeq,并在检测到跳号时触发重新对齐逻辑。

建议在逐笔消息中同时携带 exchangeTs 与 recvTs。前者用于业务逻辑,后者用于监控端到端延迟。两者差值持续增大,往往意味着消费积压或网络抖动。

四、误区与复盘

实践中常见的误区有以下几类:

  1. 把快照当增量用:误以为每条快照都是全量,导致下游重复计算或状态覆盖。应明确快照是状态切片,增量是变化量。
  2. 忽略序号连续性:逐笔与增量消息若不做 seqNum 校验,丢一条消息可能几天后才被发现,且难以定位。
  3. 用 JSON 承载高频行情:字段名重复传输,体积膨胀数倍,序列化开销高,直接拖低吞吐。
  4. 分区键选择不当:若用随机分区,同一 symbol 的逐笔消息会散落到不同分区,顺序无法保证。必须用 symbol 作为 key。
  5. 混淆交易所时间与本地时间:在延迟统计与回测中造成系统性偏差。
逐笔委托与逐笔成交必须保证分区内严格有序。若开启重试,务必同时开启幂等生产者,并将 max.in.flight.requests.per.connection 设为 1 或配合幂等使用,否则重试可能导致消息乱序。

复盘时可以用一张清单快速核对数据模型是否建模正确:

数据类别 关键字段 Kafka 清理策略 顺序要求
Level-1 快照 symbol, lastPrice, bid1, ask1 compact 弱
Level-2 快照 symbol, bids[], asks[] compact 弱
逐笔委托 orderId, side, price, qty, type delete 强
逐笔成交 tradeSeq, price, qty, side delete 强
增量更新 seqNum, prevSeqNum, changes[] delete 强

把这张表贴在设计文档里,每次新增行情源时逐项核对,可以避免绝大多数因数据模型理解偏差导致的线上事故。数据模型是行情管道的“地基”,地基不稳,后面的吞吐优化与容错设计都无从谈起。