第二章:订单簿与撮合引擎——交易所的“心脏”

各位同学,今天我们来聊聊分布式交易所最核心的部分——订单簿和撮合引擎。说实话,这部分内容我当年刚接触时也觉得挺抽象的,但后来亲手搭过几个撮合系统后,发现它其实没那么神秘。

订单簿是什么?说白了,就是一个记录所有买卖挂单的账本。撮合引擎呢?就是负责把买卖双方“牵线搭桥”的算法。这两者配合好了,交易所才能高效运转。

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 撮合算法实现

撮合算法的核心逻辑其实就一句话:检查买单队列的最高价和卖单队列的最低价,如果能匹配就成交,直到无法匹配为止。

具体流程是这样的:

  1. 新订单进来,先判断是买单还是卖单
  2. 如果是买单,看卖单队列的最低价是否 ≤ 买单价格
  3. 如果是,成交!成交价取两者之间的某个值(通常是卖单价)
  4. 成交后,更新双方数量,如果一方数量归零就出队
  5. 重复步骤2-4,直到无法匹配
  6. 如果买单还有剩余数量,就加入买单队列
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个就够了。

本章知识体系

下面这张图,是我自己总结的订单簿与撮合引擎的核心逻辑。你可以把它当作“地图”,随时回来对照。

订单簿与撮合引擎核心架构 订单入口 订单类型判断(限价单/市价单) 买单队列(最大堆) 卖单队列(最小堆) 撮合引擎 成交结果 / 订单簿更新 Gossip协议负责在集群节点间广播订单簿更新

好了,这一章的内容就到这里。订单簿和撮合引擎是交易所的“心脏”,理解了它们,你就掌握了分布式交易所最核心的部分。下一章我们会聊更深入的话题,但先把今天的内容消化好——代码多写几遍,自然就熟了。


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