第二章:订单簿与撮合引擎——交易所的“心脏”
各位同学,今天我们来聊聊分布式交易所最核心的部分——订单簿和撮合引擎。说实话,这部分内容我当年刚接触时也觉得挺抽象的,但后来亲手搭过几个撮合系统后,发现它其实没那么神秘。
订单簿是什么?说白了,就是一个记录所有买卖挂单的账本。撮合引擎呢?就是负责把买卖双方“牵线搭桥”的算法。这两者配合好了,交易所才能高效运转。
2.1 订单簿数据结构设计
我个人习惯把订单簿设计成两个核心结构:买单队列和卖单队列。每个队列里按价格排序,价格相同的按时间排序。
核心要点:买单用最大堆(价格高的优先),卖单用最小堆(价格低的优先)。这是最经典的做法。
为什么这么设计?你想想看,买单当然希望用最低的价格买,但撮合时却是价格高的买单优先成交——因为出价高的人更“着急”。卖单同理,价格低的优先成交。
我在项目中遇到过一个问题:如果直接用数据库来存订单簿,性能根本扛不住。高频交易场景下,每秒可能有几千笔订单进来,数据库的读写锁会成为瓶颈。
所以,我们通常用内存中的跳表(Skip List)或红黑树来实现订单簿。Go语言里有个现成的container/heap包,用起来很方便。
// 订单结构体
type Order struct {
ID string
Price float64
Quantity float64
Timestamp int64
Side string // "buy" 或 "sell"
}
// 买单队列(最大堆)
type BuyQueue []*Order
func (b BuyQueue) Len() int { return len(b) }
func (b BuyQueue) Less(i, j int) bool {
// 价格高的优先,价格相同时间早的优先
if b[i].Price != b[j].Price {
return b[i].Price > b[j].Price
}
return b[i].Timestamp < b[j].Timestamp
}
func (b BuyQueue) Swap(i, j int) { b[i], b[j] = b[j], b[i] }
func (b *BuyQueue) Push(x interface{}) { *b = append(*b, x.(*Order)) }
func (b *BuyQueue) Pop() interface{} {
old := *b
n := len(old)
x := old[n-1]
*b = old[0 : n-1]
return x
}
小技巧:订单ID建议用雪花算法生成,保证全局唯一且有序。我曾经用UUID当订单ID,结果排查问题时差点崩溃——完全看不出先后顺序。
2.2 限价单与市价单
这两种订单类型,是交易所最基础的“武器”。
限价单:你指定一个价格,只有价格到了这个位置才成交。比如你想用30000 USDT买1个BTC,那就挂个限价买单。价格没到?那就等着。
市价单:你不关心价格,只关心能不能成交。系统会以当前最优价格帮你成交。说白了就是“给我买,多少钱都行”。
嗯,这里要注意:市价单在流动性不足时可能会“滑点”严重。我记得有一次做压力测试,一个大的市价卖单直接把价格砸穿了5%,吓得我赶紧加了滑点保护机制。
| 特性 | 限价单 | 市价单 |
|---|---|---|
| 价格控制 | 完全控制 | 无控制 |
| 成交速度 | 可能等待 | 立即成交 |
| 滑点风险 | 无 | 有 |
| 适用场景 | 做市、挂单 | 紧急买卖 |
2.3 撮合算法实现
撮合算法的核心逻辑其实就一句话:检查买单队列的最高价和卖单队列的最低价,如果能匹配就成交,直到无法匹配为止。
具体流程是这样的:
- 新订单进来,先判断是买单还是卖单
- 如果是买单,看卖单队列的最低价是否 ≤ 买单价格
- 如果是,成交!成交价取两者之间的某个值(通常是卖单价)
- 成交后,更新双方数量,如果一方数量归零就出队
- 重复步骤2-4,直到无法匹配
- 如果买单还有剩余数量,就加入买单队列
func (m *MatchingEngine) Match(order *Order) []*Trade {
var trades []*Trade
if order.Side == "buy" {
// 买单:与卖单队列撮合
for m.sellQueue.Len() > 0 && order.Quantity > 0 {
bestSell := m.sellQueue.Peek()
if bestSell.Price > order.Price {
break // 卖单太贵,不匹配
}
tradeQty := min(order.Quantity, bestSell.Quantity)
trade := &Trade{
BuyOrderID: order.ID,
SellOrderID: bestSell.ID,
Price: bestSell.Price,
Quantity: tradeQty,
}
trades = append(trades, trade)
order.Quantity -= tradeQty
bestSell.Quantity -= tradeQty
if bestSell.Quantity == 0 {
m.sellQueue.Pop()
}
}
if order.Quantity > 0 {
m.buyQueue.Push(order)
}
} else {
// 卖单:与买单队列撮合(逻辑类似,略)
}
return trades
}
避坑指南:我曾经犯过一个错误——撮合时直接用浮点数比较价格。结果因为精度问题,明明价格相等的两个订单就是撮合不上。后来统一改用整数(比如用“分”为单位),问题就解决了。
2.4 Gossip协议在订单广播中的应用
在分布式系统中,订单不能只存在一台机器上。我们需要把订单广播到所有节点。Gossip协议就是干这个的。
Gossip协议的原理很简单:每个节点定期随机选择几个其他节点,把自己知道的信息告诉它们。就像八卦一样,一传十、十传百,最终所有节点都会知道。
为什么用Gossip而不是直接广播?你想想看,如果100个节点,每个节点都向其他99个节点广播,那网络流量就是O(n²)。Gossip只需要O(n log n),而且容错性更好——就算有几个节点挂了,信息照样能传遍全网。
我在项目中用的是反熵(Anti-Entropy)模式:每个节点每隔100ms随机选3个节点,交换订单簿的增量更新。这样既能保证最终一致性,又不会把网络打爆。
// Gossip节点结构
type GossipNode struct {
ID string
Peers []string // 已知的其他节点
Orders map[string]*Order // 本地订单簿
LastSync map[string]int64 // 上次同步时间戳
}
func (n *GossipNode) Gossip() {
// 随机选3个节点
selected := n.selectRandomPeers(3)
for _, peerID := range selected {
// 获取增量更新(上次同步后的新订单)
updates := n.getUpdatesSince(n.LastSync[peerID])
// 发送给对端
n.sendToPeer(peerID, updates)
// 更新同步时间
n.LastSync[peerID] = time.Now().UnixNano()
}
}
经验之谈:Gossip的扇出数(每次选几个节点)很关键。扇出太少,传播慢;扇出太多,网络压力大。我一般设3-5个,具体要看集群规模。100个节点以内,3个就够了。
本章知识体系
下面这张图,是我自己总结的订单簿与撮合引擎的核心逻辑。你可以把它当作“地图”,随时回来对照。
好了,这一章的内容就到这里。订单簿和撮合引擎是交易所的“心脏”,理解了它们,你就掌握了分布式交易所最核心的部分。下一章我们会聊更深入的话题,但先把今天的内容消化好——代码多写几遍,自然就熟了。
公众号:蓝海资料掘金营,微信deep3321