订单簿结构深度拆解:买盘队列与卖盘队列的组织方式

大家好,我是老张。今天咱们来聊聊订单簿的核心——买盘和卖盘队列是怎么组织的。说白了,这就是交易所撮合引擎的「心脏」。

我记得刚入行那会儿,总觉得订单簿就是个简单的价格列表。后来自己动手写撮合引擎,才发现里面的门道比想象中深得多。你想想看,一个每秒处理几万笔订单的系统,队列组织不好,分分钟就崩了。

买盘队列与卖盘队列的基本结构

先看个最直观的结构图:

订单簿核心结构 买盘队列 (Bid Queue) 价格: 100.50 数量: 2000 价格: 100.40 数量: 1500 价格: 100.30 数量: 3000 价格: 100.20 数量: 800 价格从高到低排列 卖盘队列 (Ask Queue) 价格: 100.60 数量: 1200 价格: 100.70 数量: 2500 价格: 100.80 数量: 1800 价格: 100.90 数量: 900 价格从低到高排列 最佳买价: 100.50 | 最佳卖价: 100.60 | 价差: 0.10

看到这个图了吗?买盘队列是价格从高到低排列,卖盘队列是价格从低到高排列。为什么这么设计?

因为买方当然希望用更低的价格买入,但系统要优先满足出价最高的买家。同理,卖方希望卖得越贵越好,但系统优先处理要价最低的卖家。这就是价格优先原则的直观体现。

核心要点:买盘队列按价格降序排列,卖盘队列按价格升序排列。最高买价和最低卖价就是「最佳买卖价」,它们之间的差值叫「价差」。

价格优先原则的底层实现

价格优先,说白了就是「出价高的买家优先成交,要价低的卖家优先成交」。这个逻辑听着简单,但实现起来有几个坑。

我曾经在一个项目中,直接用数组来维护订单队列。每次插入新订单都要遍历整个数组找位置,性能惨不忍睹。后来改用跳表(Skip List),插入和查找都是 O(log n),这才解决问题。

来看看价格优先的具体实现逻辑:

// 价格优先的核心比较逻辑
// 买盘:价格高的优先
function compareBid(priceA, priceB) {
    if (priceA > priceB) return -1;  // A排在前面
    if (priceA < priceB) return 1;   // B排在前面
    return 0;  // 价格相同,交给时间优先
}

// 卖盘:价格低的优先
function compareAsk(priceA, priceB) {
    if (priceA < priceB) return -1;  // A排在前面
    if (priceA > priceB) return 1;   // B排在前面
    return 0;  // 价格相同,交给时间优先
}

你可能会问:为什么买盘和卖盘的比较逻辑是反的?嗯,这个问题问得好。因为买盘队列中,价格最高的在最前面,所以比较函数要返回「价格高的排前面」。卖盘则相反。

时间优先原则的底层实现

价格相同的时候怎么办?这时候就看谁先到。时间优先原则就是「先来后到」。

我建议在订单数据结构里加一个时间戳字段。这个时间戳必须是单调递增的,不能直接用系统时间——因为系统时间可能回拨,或者多个订单在同一毫秒到达。

// 订单数据结构
class Order {
    constructor(id, price, quantity, side) {
        this.id = id;           // 订单ID
        this.price = price;     // 价格
        this.quantity = quantity; // 数量
        this.side = side;       // 'buy' 或 'sell'
        this.timestamp = Date.now(); // 时间戳
        this.sequence = nextSequence++; // 全局递增序号
    }
}

// 时间优先比较
function compareByTime(orderA, orderB) {
    if (orderA.sequence < orderB.sequence) return -1;
    if (orderA.sequence > orderB.sequence) return 1;
    return 0;
}

实战技巧:我个人习惯用全局递增序号代替时间戳来做时间优先比较。因为时间戳在毫秒级别可能重复,而序号是严格递增的。另外,序号还能用来检测订单的先后顺序,排查问题的时候特别好用。

价格优先 + 时间优先的联合排序

把两个原则结合起来,就是完整的订单队列排序逻辑。先按价格排,价格相同再按时间排。

// 买盘队列的完整排序
function compareBidOrders(orderA, orderB) {
    // 第一步:价格优先
    if (orderA.price !== orderB.price) {
        return orderA.price > orderB.price ? -1 : 1;
    }
    // 第二步:时间优先
    return orderA.sequence - orderB.sequence;
}

// 卖盘队列的完整排序
function compareAskOrders(orderA, orderB) {
    // 第一步:价格优先
    if (orderA.price !== orderB.price) {
        return orderA.price < orderB.price ? -1 : 1;
    }
    // 第二步:时间优先
    return orderA.sequence - orderB.sequence;
}

这段代码看着简单,但它是整个撮合引擎的基石。我见过不少新手把比较逻辑写反了,结果撮合出来的成交价乱七八糟。

避坑指南:我曾经在一个项目中,把买盘队列的排序写成了「价格低的优先」。结果上线后,所有买单都跟最低价的卖单成交,市场瞬间被打穿。幸好是在测试环境发现的,不然就出大事了。所以排序逻辑一定要反复检查。

队列的数据结构选择

订单队列用什么数据结构?这是个好问题。不同的场景有不同的选择:

数据结构 插入复杂度 删除复杂度 适用场景
有序数组 O(n) O(n) 小规模订单簿
跳表 (Skip List) O(log n) O(log n) 中等规模,读写均衡
红黑树 O(log n) O(log n) 高性能场景
堆 (Heap) O(log n) O(log n) 只取最优价格

我个人比较推荐用跳表。为什么?因为红黑树实现起来太复杂,容易出bug。跳表代码简单,性能也够用。我在一个日交易量过亿的交易所里就用跳表,跑了两年没出过问题。

实际项目中的队列管理

光有排序还不够,还得考虑订单的增删改查。我总结了几条实战经验:

  • 批量插入优化:如果同时涌入大量订单,先排序再批量插入,比逐个插入快很多。
  • 价格档位聚合:相同价格的订单可以合并成一个节点,里面再维护一个时间有序的子队列。这样能减少树的高度。
  • 缓存最佳价格:把最佳买价和最佳卖价缓存起来,撮合的时候直接取,不用每次都查树。
  • 内存对齐:订单对象的内存布局要注意对齐,能提升缓存命中率。

总结一下:订单簿的核心就是「价格优先 + 时间优先」的排序逻辑。买盘队列价格从高到低,卖盘队列价格从低到高。实现时用全局递增序号保证时间顺序,用跳表或红黑树保证性能。记住,排序逻辑写反了会出大问题。

好了,这一章就聊到这儿。下一章咱们聊聊撮合引擎的核心循环——怎么把买单和卖单匹配起来。到时候我会分享一个我踩过的坑,保证让你印象深刻。


专注资料整理