第五章:订单簿构建——市场心跳的数字化映射
做市系统里,订单簿就是我们的战场地图。没有它,你就是在黑灯瞎火里做交易。今天咱们聊聊怎么把这张地图画清楚、画实时。
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%。
增量更新策略:
- 收到新订单 → 插入对应价格队列
- 收到取消订单 → 从队列和映射中删除
- 收到成交 → 减少对应订单的数量,数量归零则删除
- 每次操作后,更新该价格的聚合深度
// 增量更新示例
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;
}
可视化的时候,我建议用SVG或Canvas直接在前端绘制。别用第三方图表库,太重了。做市系统的前端要轻、要快,一个简单的阶梯图自己画就行。
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);
}
前端收到增量数据后,直接更新本地的订单簿数据结构。这里有个技巧:维护一个本地快照,每次增量更新后,把快照和增量合并,这样即使断线重连,也能快速恢复。
断线重连策略:
- WebSocket断开后,前端标记数据为「过期」
- 重连成功后,请求全量快照
- 快照加载完成后,恢复增量更新
- 整个过程对用户无感
我记得有一次线上事故,就是因为断线重连后没有请求全量快照,导致前端订单簿和服务器不一致,交易员看着错误的数据做决策...嗯,从那以后我就在代码里加了强制快照校验。
5.5 性能优化要点
订单簿的性能直接决定做市系统的成败。这里分享几个我踩过的坑:
| 优化点 | 问题 | 解决方案 |
|---|---|---|
| 内存分配 | 频繁创建对象导致GC | 对象池复用订单对象 |
| 锁竞争 | 多线程读写冲突 | 读写锁 + 无锁队列 |
| 序列化 | JSON解析慢 | 二进制协议(Protobuf) |
| 数据推送 | 全量推送带宽高 | 增量推送 + 压缩 |
说白了,订单簿构建就是一场和时间的赛跑。数据结构选对了,更新机制设计好了,可视化做清晰了,你的做市系统就成功了一半。
本章核心要点:
- 用红黑树或跳表实现订单簿,别用数组
- 增量更新是性能关键,别全量重建
- 深度图用累计深度展示,前端自己画
- WebSocket推送增量数据,断线重连要快照恢复
- 性能优化从内存、锁、序列化、推送四个维度入手