核心数据模型:订单簿(Order Book)设计

做交易系统这么多年,我有个很深的体会:订单簿就是交易引擎的心脏。你想想看,所有买卖双方的意愿,最终都要汇集到这张表里。它设计得好不好,直接决定了撮合的速度和公平性。

我个人习惯把订单簿理解成一个「价格-数量」的二维矩阵。买方挂单在左边,卖方挂单在右边。价格从高到低排列,同一价格下按时间先后排队。嗯,这个结构看着简单,但坑其实不少。

订单簿的核心结构

先看一个最基础的订单簿长什么样。我用一个简化版的数据结构来说明:

// 订单簿核心结构(伪代码)
struct OrderBook {
    // 买单队列:价格从高到低排序
    map<Price, queue<Order>> bids;  
    // 卖单队列:价格从低到高排序
    map<Price, queue<Order>> asks;  
    
    // 每个订单的快速索引
    map<OrderId, Order> orderMap;
    
    // 当前最优价格
    Price bestBid;  // 最高买价
    Price bestAsk;  // 最低卖价
}

这里有个关键点:买单用降序,卖单用升序。为什么?因为撮合时,我们要最快找到「愿意出最高价的买方」和「愿意接受最低价的卖方」。说白了,就是让价格优先原则能高效执行。

核心要点:订单簿的排序结构,直接决定了撮合引擎的查找效率。用红黑树或跳表实现价格层,时间复杂度能做到 O(log n)。

我在项目中踩过的坑

我曾经在一个高并发场景下,直接用内存里的 std::map 存订单。结果发现,当订单量达到百万级别时,插入和删除操作开始出现明显的延迟抖动。后来我换成了分层存储:热数据用无锁队列,冷数据用 B+ 树。这才把延迟稳定在微秒级。

还有一个坑:订单的撤销操作。如果订单簿里没有快速索引,撤销一个订单就得遍历整个价格队列。你想想看,这在高频交易里简直是灾难。所以我建议,每个订单必须有一个全局唯一的 ID,并且订单簿里要维护一个 ID 到订单位置的映射

订单类型:限价单、市价单、止损单

订单类型这事儿,说白了就是「你想怎么买、怎么卖」。不同的订单类型,对应着不同的交易策略和风险偏好。我按实际使用频率来聊聊。

限价单(Limit Order)

这是最基础的订单类型。你指定一个价格,只有市场价格达到或优于这个价格时,订单才会成交。

// 限价单示例
Order createLimitOrder(
    Side side,      // BUY 或 SELL
    Price price,    // 指定价格
    Quantity qty,   // 数量
    Time expiry     // 有效期
) {
    // 检查价格是否在合理范围内
    assert(price > 0);
    // 检查数量是否为正
    assert(qty > 0);
    
    return Order{
        .type = LIMIT,
        .side = side,
        .price = price,
        .quantity = qty,
        .timestamp = now()
    };
}

限价单的优点:你能精确控制成交价格,不会出现「滑点」。但缺点也很明显——可能永远无法成交,或者成交速度很慢。

我的建议:如果你是做市商或者低频策略,限价单是首选。它能帮你赚取买卖价差,同时控制风险。

市价单(Market Order)

市价单不指定价格,只指定数量。系统会以当前市场上最优的价格立即成交。说白了,就是「不管多少钱,我就要买/卖这么多」。

这里有个容易忽略的问题:市价单的成交价格是不确定的。如果市场流动性不足,一个大的市价单可能会吃掉好几个价格层的订单,导致实际成交均价远低于预期。

避坑指南:我曾经见过一个交易员,在流动性很差的币对上下了个大额市价买单。结果成交价被拉高了 5%。所以,市价单一定要设置「滑点保护」或者「冰山订单」来分散风险。

止损单(Stop Order)

止损单是个「触发器」。当市场价格达到你设定的止损价时,它会变成一个市价单(或限价单)被激活。

// 止损单触发逻辑
void onPriceUpdate(Price newPrice) {
    for each stopOrder in stopOrders {
        if (stopOrder.side == BUY && newPrice >= stopOrder.stopPrice) {
            // 触发买入止损:价格涨到止损价以上
            activateOrder(stopOrder);
        }
        else if (stopOrder.side == SELL && newPrice <= stopOrder.stopPrice) {
            // 触发卖出止损:价格跌到止损价以下
            activateOrder(stopOrder);
        }
    }
}

止损单最常用的场景是风险控制。比如你持有多头仓位,设置一个卖出止损单,当价格跌到某个水平时自动平仓,限制亏损。

但要注意:止损单不保证成交价格。在极端行情下(比如闪崩),止损单触发后可能以远低于止损价的价格成交。这就是所谓的「滑点放大」。

撮合规则:价格优先、时间优先

撮合规则是整个交易引擎的「宪法」。所有订单都必须遵守这两条铁律:价格优先时间优先

价格优先

这个好理解:买单出价高的优先成交,卖单出价低的优先成交。你想想看,如果两个买方都想买,一个出 100 块,一个出 99 块,那肯定是出 100 块的先成交。这很公平,对吧?

但在实际系统中,价格优先的实现有个细节:价格比较的精度。不同市场的价格最小变动单位不一样。比如股票是 0.01 元,加密货币可能是 0.00000001 个币。我建议用整数表示价格,避免浮点数比较带来的精度问题。

时间优先

当价格相同时,先挂单的先成交。这个规则保证了公平性,也防止了「插队」行为。

时间优先的实现,核心是订单的时间戳。但这里有个坑:分布式环境下的时间同步。如果两个订单来自不同的服务器,它们的时间戳可能不一致。我曾经遇到过,因为 NTP 同步延迟,导致两个订单的时间戳顺序颠倒,引发了撮合结果的争议。

解决方案:使用全局单调递增的序列号(比如基于 Redis 或数据库的自增 ID)来代替时间戳。这样能保证所有订单的「到达顺序」是确定的,不受物理时钟影响。

完整的撮合流程

我把撮合的核心逻辑画成了流程图,方便你理解:

订单撮合核心流程 收到新订单 判断订单类型(限价/市价/止损) 价格优先:找到最优对手盘 时间优先:同价格按到达顺序 执行撮合:生成成交记录 更新订单簿状态

这个流程看着简单,但实际实现时,每一步都有很多细节要考虑。比如:

  • 部分成交:一个订单可能只成交一部分,剩下的部分要继续留在订单簿里等待。
  • 多笔撮合:一个大订单可能和多个小订单成交,需要循环匹配直到订单完全成交或没有对手盘。
  • 异常处理:如果撮合过程中系统崩溃,如何保证数据一致性?我建议用预写日志(WAL)来记录每一步操作。

一个实际的撮合例子

假设当前订单簿是这样的:

买单(Bids) 价格 卖单(Asks)
100 股 @ 10.05 10.05 200 股 @ 10.06
300 股 @ 10.04 10.04 150 股 @ 10.07
500 股 @ 10.03 10.03 100 股 @ 10.08

现在来了一个市价买单,数量 250 股。撮合过程是这样的:

  1. 先匹配最优卖价 10.06 的 200 股。成交 200 股,剩余 50 股。
  2. 继续匹配下一个卖价 10.07 的 150 股。成交 50 股,订单完全成交。
  3. 最终成交均价 = (200×10.06 + 50×10.07) / 250 ≈ 10.062。

你看,市价单就是这样「扫」订单簿的。如果流动性不足,可能会扫到很深的价格层。

性能优化小技巧:在实际系统中,我不会每次撮合都遍历整个订单簿。而是维护一个「最优价格指针」,每次只从最优价格开始匹配。这样能大幅减少查找时间。

总结

订单簿、订单类型、撮合规则,这三者是交易引擎的「三驾马车」。设计得好,系统就能高效、公平地运行;设计得不好,轻则性能瓶颈,重则引发交易纠纷。

我个人觉得,订单簿的设计要兼顾「查询效率」和「更新效率」。订单类型要覆盖常见的交易场景,但不要过度设计。撮合规则要简单、明确、可验证。

最后说一句:纸上得来终觉浅。这些知识看着简单,但真正实现一个生产级的交易引擎,你会发现到处都是细节。多写代码,多踩坑,慢慢就懂了。


公众号:蓝海资料掘金营,微信deep3321