第五章:订单簿构建——市场心跳的数字化映射

做市系统里,订单簿就是我们的战场地图。没有它,你就是在黑灯瞎火里做交易。今天咱们聊聊怎么把这张地图画清楚、画实时。

5.1 订单簿数据结构设计

订单簿的核心,说白了就是两个东西:买盘和卖盘。每个盘口由价格和数量组成。我见过不少新手直接用数组存,结果一到高频场景就崩了。

我个人习惯用红黑树跳表来实现。为什么?因为订单簿需要按价格排序,而且插入、删除、查询都得快。数组虽然简单,但插入一个订单要移动一堆元素,太慢了。

核心数据结构:

  • 买盘(Bids):按价格降序排列,最高价在最前面
  • 卖盘(Asks):按价格升序排列,最低价在最前面
  • 价格节点:每个价格对应一个订单队列
  • 订单ID映射:方便快速取消或修改订单
// 伪代码示例:订单簿数据结构
class OrderBook {
    TreeMap<Double, Queue<Order>> bids;  // 买盘,降序
    TreeMap<Double, Queue<Order>> asks;  // 卖盘,升序
    HashMap<String, Order> orderMap;     // 订单ID映射
    
    // 价格-数量聚合,用于深度图
    TreeMap<Double, Double> bidDepth;
    TreeMap<Double, Double> askDepth;
}

这里有个坑:浮点数做Key。我在项目中遇到过因为浮点精度问题导致订单匹配失败的惨案。后来统一改用整数,比如价格乘以10000存成long型。嗯,这个细节能救你一命。

5.2 买卖盘口维护

盘口维护的核心逻辑就四个字:增删改查。但做市系统里,我们更关心的是「增量更新」——不是每次变化都重建整个订单簿,而是只处理变化的部分。

我曾经接手过一个系统,每次订单簿变化都全量推送,结果带宽直接被打满。后来改成增量更新,带宽降了90%。

增量更新策略:

  1. 收到新订单 → 插入对应价格队列
  2. 收到取消订单 → 从队列和映射中删除
  3. 收到成交 → 减少对应订单的数量,数量归零则删除
  4. 每次操作后,更新该价格的聚合深度
// 增量更新示例
void onNewOrder(Order order) {
    if (order.side == BUY) {
        bids.get(order.price).add(order);
        bidDepth.merge(order.price, order.quantity, Double::sum);
    } else {
        asks.get(order.price).add(order);
        askDepth.merge(order.price, order.quantity, Double::sum);
    }
    orderMap.put(order.id, order);
}

你想想看,如果每次变化都全量重建,那CPU和内存都在做无用功。增量更新才是做市系统的正确姿势。

5.3 深度图可视化

深度图是订单簿的「颜值担当」。交易员一眼就能看出市场深度和潜在支撑阻力位。我习惯用阶梯图来展示,买盘在左、卖盘在右,中间是当前价格。

深度图的核心数据是累计深度:从最优价格开始,逐级累加数量。比如买盘第一档有100个,第二档有200个,那第二档的累计深度就是300。

深度图数据准备:

  • 从最优价格开始遍历
  • 每个价格点的深度 = 该价格及所有更优价格的数量之和
  • 买盘从高到低累加,卖盘从低到高累加
  • 通常展示前20档或前50档
// 深度图数据生成
List<DepthPoint> getBidDepthPoints(int levels) {
    List<DepthPoint> points = new ArrayList<>();
    double cumulative = 0;
    for (Map.Entry<Double, Double> entry : bidDepth.entrySet()) {
        cumulative += entry.getValue();
        points.add(new DepthPoint(entry.getKey(), cumulative));
        if (points.size() >= levels) break;
    }
    return points;
}

可视化的时候,我建议用SVGCanvas直接在前端绘制。别用第三方图表库,太重了。做市系统的前端要轻、要快,一个简单的阶梯图自己画就行。

订单簿深度图 当前价格 买盘深度 卖盘深度 0 深度 价格

5.4 实时更新机制

实时更新是订单簿的「灵魂」。做市系统里,订单簿每秒可能变化上千次。怎么保证前端看到的是最新的?

我推荐用WebSocket推送增量数据。别用轮询,那是上个时代的做法。WebSocket建立一次连接,后续数据实时推送,延迟能控制在毫秒级。

注意:WebSocket推送的数据量要控制。我见过有人把整个订单簿序列化后推送,结果一个订单变化就推几KB数据。正确的做法是只推送变化的部分:

  • 新增订单:{type: "add", price: 100.5, quantity: 200, side: "buy"}
  • 取消订单:{type: "cancel", orderId: "abc123"}
  • 成交:{type: "trade", price: 100.5, quantity: 50}
// WebSocket推送增量数据
void sendIncrementalUpdate(OrderBookUpdate update) {
    String json = String.format(
        "{\"type\":\"%s\",\"price\":%.2f,\"quantity\":%.2f,\"side\":\"%s\"}",
        update.type, update.price, update.quantity, update.side
    );
    webSocket.send(json);
}

前端收到增量数据后,直接更新本地的订单簿数据结构。这里有个技巧:维护一个本地快照,每次增量更新后,把快照和增量合并,这样即使断线重连,也能快速恢复。

断线重连策略:

  1. WebSocket断开后,前端标记数据为「过期」
  2. 重连成功后,请求全量快照
  3. 快照加载完成后,恢复增量更新
  4. 整个过程对用户无感

我记得有一次线上事故,就是因为断线重连后没有请求全量快照,导致前端订单簿和服务器不一致,交易员看着错误的数据做决策...嗯,从那以后我就在代码里加了强制快照校验。

5.5 性能优化要点

订单簿的性能直接决定做市系统的成败。这里分享几个我踩过的坑:

优化点 问题 解决方案
内存分配 频繁创建对象导致GC 对象池复用订单对象
锁竞争 多线程读写冲突 读写锁 + 无锁队列
序列化 JSON解析慢 二进制协议(Protobuf)
数据推送 全量推送带宽高 增量推送 + 压缩

说白了,订单簿构建就是一场和时间的赛跑。数据结构选对了,更新机制设计好了,可视化做清晰了,你的做市系统就成功了一半。

本章核心要点:

  • 用红黑树或跳表实现订单簿,别用数组
  • 增量更新是性能关键,别全量重建
  • 深度图用累计深度展示,前端自己画
  • WebSocket推送增量数据,断线重连要快照恢复
  • 性能优化从内存、锁、序列化、推送四个维度入手

交易系统化学习资料 微信Strategy888888