核心数据模型:订单簿(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 股。撮合过程是这样的:
- 先匹配最优卖价 10.06 的 200 股。成交 200 股,剩余 50 股。
- 继续匹配下一个卖价 10.07 的 150 股。成交 50 股,订单完全成交。
- 最终成交均价 = (200×10.06 + 50×10.07) / 250 ≈ 10.062。
你看,市价单就是这样「扫」订单簿的。如果流动性不足,可能会扫到很深的价格层。
性能优化小技巧:在实际系统中,我不会每次撮合都遍历整个订单簿。而是维护一个「最优价格指针」,每次只从最优价格开始匹配。这样能大幅减少查找时间。
总结
订单簿、订单类型、撮合规则,这三者是交易引擎的「三驾马车」。设计得好,系统就能高效、公平地运行;设计得不好,轻则性能瓶颈,重则引发交易纠纷。
我个人觉得,订单簿的设计要兼顾「查询效率」和「更新效率」。订单类型要覆盖常见的交易场景,但不要过度设计。撮合规则要简单、明确、可验证。
最后说一句:纸上得来终觉浅。这些知识看着简单,但真正实现一个生产级的交易引擎,你会发现到处都是细节。多写代码,多踩坑,慢慢就懂了。
公众号:蓝海资料掘金营,微信deep3321