4、增量消息解析:增量消息的通用格式、添加订单(Add)事件处理、更新订单(Update)事件处理
好,咱们接着聊增量消息的解析。上一章我们把快照重建的骨架搭好了,现在要往里填血肉——也就是处理那些源源不断推送过来的增量事件。
我个人习惯把增量消息比作「手术刀」。快照是静态的解剖图,告诉你当前市场长什么样。而增量消息,就是一刀一刀切下去,告诉你每一刀切在哪、切多深。你只有把每一刀都理解透了,才能实时还原出市场的动态全貌。
4.1 增量消息的通用格式
先说说格式。不同交易所的增量消息长得不一样,但核心骨架大同小异。我接触过的交易所,比如 Binance、OKX、Coinbase,它们的增量消息基本都包含这几个字段:
| 字段 | 含义 | 示例值 |
|---|---|---|
type |
事件类型 | "add", "update", "delete" |
side |
买卖方向 | "bid" 或 "ask" |
price |
价格(字符串,避免精度丢失) | "12345.67" |
size |
数量(字符串) | "0.5000" |
sequence |
序列号(用于校验顺序) | 1029384756 |
嗯,这里要注意一点:价格和数量一定要用字符串接收。为什么?因为浮点数有精度问题。你想想看,如果价格是 0.00001234,用 double 一存,可能就变成 0.000012340000001 了。这在高频交易里是致命的。我早期踩过这个坑,后来所有价格字段统一用 Decimal 或字符串处理,再也没出过问题。
一个典型的增量消息 JSON 长这样:
{
"type": "add",
"side": "bid",
"price": "100.50",
"size": "2.0000",
"sequence": 123456
}
4.2 添加订单(Add)事件处理
添加事件,说白了就是「在订单簿里插入一个新价格档位」。比如买方在 100.50 这个价格挂了 2 个比特币,之前这个价格没有单子,那就要新建一条记录。
处理逻辑其实很简单:
- 根据
side找到对应的红黑树(买盘或卖盘) - 用
price作为 key 去树里查找 - 如果没找到,就插入一个新节点
- 如果找到了……嗯,那就不是 Add 了,那是 Update 或 Delete 的问题
代码实现大致是这样:
void handleAdd(const Json& msg) {
Side side = parseSide(msg["side"]);
double price = std::stod(msg["price"].get<std::string>());
double size = std::stod(msg["size"].get<std::string>());
uint64_t seq = msg["sequence"];
// 校验序列号,防止乱序
if (seq <= lastSequence) {
// 丢弃或记录日志
return;
}
lastSequence = seq;
auto& tree = (side == Side::BID) ? bids : asks;
auto it = tree.find(price);
if (it == tree.end()) {
tree.insert({price, size});
} else {
// 理论上不应该走到这里,但防御性编程
// 我习惯打印一条警告日志
spdlog::warn("Add event for existing price: {}", price);
}
}
这里有个小细节:序列号校验。我在项目中遇到过网络重传导致消息乱序的情况。比如先收到 seq=100 的 Add,后收到 seq=99 的 Add,如果直接处理 seq=99,会把之前正确的状态覆盖掉。所以一定要加序列号检查,小于等于当前序列号的直接丢弃。
4.3 更新订单(Update)事件处理
更新事件,就是「某个价格档位的数量发生了变化」。比如买方在 100.50 这个价格原本挂了 2 个,现在撤单了 1 个,剩下 1 个。这时候交易所会推送一个 Update 事件,告诉你新的 size 是多少。
处理逻辑:
- 同样先找到对应的红黑树
- 用 price 查找节点
- 如果找到了,直接更新 size
- 如果没找到……嗯,这就有意思了
你可能会问:「没找到怎么办?」 我告诉你,直接插入。为什么?因为网络延迟或者快照不完整,你可能漏掉了之前的 Add 事件。这时候 Update 本质上就变成了 Add。我曾经在对接某二线交易所时,发现它们的增量流偶尔会丢事件,后来我干脆把 Update 和 Add 合并处理——先尝试更新,如果找不到就插入。这个策略帮我省了不少排查时间。
void handleUpdate(const Json& msg) {
Side side = parseSide(msg["side"]);
double price = std::stod(msg["price"].get<std::string>());
double newSize = std::stod(msg["size"].get<std::string>());
uint64_t seq = msg["sequence"];
if (seq <= lastSequence) return;
lastSequence = seq;
auto& tree = (side == Side::BID) ? bids : asks;
auto it = tree.find(price);
if (it != tree.end()) {
it->second = newSize;
} else {
// 容错:当作 Add 处理
tree.insert({price, newSize});
spdlog::warn("Update event for missing price, inserted as new: {}", price);
}
}
newSize 为 0,通常意味着该价格档位被清空。这时候应该删除节点,而不是保留一个数量为 0 的条目。否则你的订单簿里会有一堆「空壳」节点,影响查询效率。
4.4 增量消息处理的整体流程
把 Add 和 Update 串起来,再加上 Delete(下一章会讲),整个增量消息处理的流程就清晰了。我画了一张图,帮你把逻辑串起来:
核心要点:
- Add 事件:直接插入新价格档位,如果已存在则记录异常
- Update 事件:更新已有价格档位的数量,如果不存在则容错插入
- 序列号校验是必须的,否则乱序消息会搞乱你的订单簿
- 数量为 0 的 Update 要当作 Delete 处理
我的一个小技巧: 在开发阶段,我会把每个增量消息的原始 JSON 和操作后的订单簿快照都打印到日志里。这样一旦出问题,我可以回放整个流程,定位到底是哪条消息处理错了。生产环境当然要关掉,但调试阶段这个习惯帮我省了至少几十个小时的排查时间。
好了,Add 和 Update 的处理逻辑就讲到这里。说白了,增量消息解析的核心就是「根据 type 做不同的操作,同时做好容错和校验」。下一章我们会聊 Delete 事件,以及如何处理「数量归零」这种边界情况。到时候你会发现,Delete 的处理比 Add 和 Update 要稍微绕一点,但原理是一样的。