从集合竞价到连续竞价:深入剖析交易所核心撮合模式

本文旨在为中高级工程师与架构师提供一份关于交易系统核心——撮合引擎的深度技术剖析。我们将跳过概念性介绍,直击两种主流撮合模式:集合竞价(Call Auction)与连续竞价(Continuous Auction)的本质区别。内容将从市场现象出发,回归到算法与数据结构的理论基础,深入到单线程引擎的CPU缓存行为、内存管理,并最终探讨分布式环境下的高可用架构演进路径。本文的目标不是一份入门指南,而是一次对金融交易技术核心的硬核拆解,适用于构建股票、期货、数字货币等高性能交易平台的团队。

现象与问题背景

在任何一个金融市场,核心问题都是“价格发现”(Price Discovery)。如何为一个资产(如股票、合约)在特定时间点找到一个公允的成交价?这个问题的不同解法,催生了不同的市场撮合机制。我们每天都会遇到两种截然不同的场景:

场景一:开盘前的“混沌”。在股票市场每日开盘前(例如A股的9:15-9:25),或是一个新币种在交易所首次上线交易(IEO/IDO)的瞬间,市场会涌入大量买卖委托。这些委托在时间上没有绝对的先后顺序,它们共同表达了市场在正式交易开始前对该资产的估值预期。此时,如果采用“先到先得”的连续竞价,一个极端价格的早期订单可能会造成巨大的价格波动,这既不公平也无法反映市场的整体意愿。我们需要一个机制,能“汇总”所有意愿,并计算出一个能让最多数量的买卖双方都满意的“唯一开盘价”。这就是集合竞价的用武之地。

场景二:盘中的“流动”。当市场进入连续交易时段(如A股的9:30之后),新的订单源源不断地进入系统。此时,市场的核心诉求变成了效率与流动性。交易者期望他们的订单能够被立即评估,并与当前市场上的最优对手方订单成交。价格不再是静态计算出来的,而是由不断到来的新订单动态“推动”的。一个买单可能会吃掉多个价位的卖单,一个卖单也可能被多个小买单逐步成交。这种基于价格优先、时间优先原则,来一笔、撮一笔的模式,就是连续竞价

这两种模式在业务上服务于不同阶段,在技术实现上更是天差地别。一个追求“全局最优解”,另一个追求“局部最优解”的即时响应。作为架构师,我们需要理解其背后的原理,才能设计出正确、高效且可扩展的撮合系统。

关键原理拆解

(大学教授视角)要理解这两种模式,我们必须回到它们所依赖的计算机科学与微观经济学基础原理。

  • 集合竞价:最大成交量原则 (Maximum Volume Principle)

    集合竞价的核心不是追求速度,而是寻找一个特定价格点,使得在该价格或优于该价格的买卖盘口中,能够成交的数量最大。这本质上是一个离散优化问题。其算法步骤在理论上是清晰的:

    1. 收集在一个指定时间窗口内(例如开盘前10分钟)所有的买卖订单。
    2. 提取所有订单中的申报价格,并形成一个所有可能成交价的集合 P。
    3. 对于集合 P 中的每一个价格 p,计算两个值:
      • (A) 需求总量:所有申报价格 高于或等于 p 的买单数量之和。
      • (B) 供给总量:所有申报价格 低于或等于 p 的卖单数量之和。
    4. 在该价格 p 上的可成交量为 min(A, B)
    5. 遍历集合 P 中所有价格,找到那个使 min(A, B) 最大的价格 p*。这个 p* 就是最终的成交价。

    若存在多个价格都能达到最大成交量,则会引入额外的规则来确定唯一价格,例如选择最接近上一个收盘价的价格。这个过程在数据结构上并不复杂,通常是对订单按价格排序后进行线性扫描或累加计算,时间复杂度主要取决于订单排序,约为 O(N log N),其中 N 是订单数量。

  • 连续竞价:限价订单簿 (Limit Order Book) 与价格-时间优先原则

    连续竞价的灵魂在于其数据结构——限价订单簿(LOB)。LOB 实时维护了市场上所有尚未成交的买卖委托。它必须被设计成能够极快地响应以下操作:

    1. 添加订单 (Add Order): O(log M),M 为价格档位数量。
    2. 取消订单 (Cancel Order): O(1) 或 O(log N),取决于实现。
    3. 获取最优报价 (Get Best Bid/Ask): O(1)。

    为了同时满足价格优先和时间优先,LOB 的经典实现是一个双边结构:

    • 买盘 (Bid Book): 按价格降序排列。价格最高的买单拥有最高撮合优先级。
    • 卖盘 (Ask Book): 按价格升序排列。价格最低的卖单拥有最高撮合优先级。
    • 时间优先: 在同一价格上,先进入系统的订单拥有更高的撮合优先级。这通常通过在每个价格档位上维护一个订单队列(如双向链表)来实现。

    最常见且高效的实现是使用两个平衡二叉搜索树(如红黑树)或跳表,每个节点代表一个价格档位,节点的值是一个订单队列。买盘的树按价格从大到小组织,卖盘的树按价格从小到大组织。这样,树的“最左”或“最右”节点(取决于具体实现)始终是最佳报价档位,访问时间复杂度为 O(1) 或 O(log M)。新订单的插入和删除操作复杂度为 O(log M)。

系统架构总览

一个完整的交易所撮合系统,远不止撮合引擎本身。我们可以将其逻辑上划分为几个关键层级,所有的数据流必须严格遵循这个路径,以保证一致性和公平性。

文字描述的架构图:

用户端 -> 接入网关 (Gateway) -> 序号生成器 (Sequencer) -> [撮合引擎 (Matching Engine)] -> 市场行情 (Market Data) / 成交回报 (Trade Reporter) -> 持久化 (Persistence)

  • 接入网关 (Gateway): 负责处理客户端连接(通常使用 FIX 协议或 WebSocket),进行用户认证、权限校验、消息解码和初步的参数校验(如价格、数量是否合法)。这一层可以水平扩展以承载海量并发连接。
  • 序号生成器 (Sequencer): 这是整个系统的“心脏起搏器”,是实现公平性的基石。所有经过网关验证的合法订单,都必须通过一个逻辑上单点的 Sequencer 来获取一个全局唯一、严格单调递增的序列号。无论订单从哪个网关进入,最终都由这个序列号确定其处理顺序。在分布式系统中,这通常通过 Raft/Paxos 协议或专用的分布式日志系统(如 Kafka 的单 partition topic)实现。
  • 撮合引擎 (Matching Engine): 引擎消费由 Sequencer 排序后的订单流。为了确定性和极致的性能,撮合引擎本身通常是单线程的。它在内存中维护订单簿,执行撮合算法,并产生两种输出:内部成交回报(Trades)和订单簿变更事件(Order Book Deltas)。单线程模型避免了复杂的锁机制,使得CPU缓存效率极高。
  • 行情与回报服务: 这些是下游服务。行情服务(Market Data Publisher)消费订单簿变更事件,并向市场广播最新的深度行情(Level 2 Data)和K线数据。成交回报服务(Trade Reporter)则将成交信息推送给相关的交易双方,并发送给清结算系统。这些服务可以异步处理,与核心撮合逻辑解耦。
  • 持久化层: 所有进入撮合引擎的指令(下单、取消)和引擎产生的结果(成交)都必须被可靠地记录下来,用于审计、恢复和结算。这通常通过写入一个高吞吐的日志系统(如 Kafka)或直接写入数据库来实现,但必须是异步操作,不能阻塞撮合主线程。

核心模块设计与实现

(极客工程师视角)理论讲完了,来看代码。talk is cheap。我们用 Go 语言的伪代码来展示核心逻辑。假设我们已经有了基础的 `Order` 结构体。


// Order 结构体
type Order struct {
    ID        int64
    UserID    int64
    Price     float64 // 实际应用中会用 Decimal 或定点数避免浮点误差
    Quantity  int64
    Side      OrderSide // BUY or SELL
    Timestamp int64     // Sequencer 分配的时间戳/序列号
}

// Trade 结构体
type Trade struct {
    TakerOrderID int64
    MakerOrderID int64
    Price        float64
    Quantity     int64
}

集合竞价实现

集合竞价的实现相对直接,它是一个批处理过程。核心是遍历所有可能的价格,计算成交量。


func CalculateCallAuction(orders []*Order) (matchPrice float64, trades []*Trade) {
    // 1. 获取所有不重复的价格点并排序
    pricePoints := collectAndSortUniquePrices(orders)

    var maxVolume int64 = 0
    var bestPrice float64 = 0.0

    // 2. 遍历每个价格点,计算最大成交量
    for _, p := range pricePoints {
        var buyVolume, sellVolume int64
        for _, o := range orders {
            if o.Side == BUY && o.Price >= p {
                buyVolume += o.Quantity
            } else if o.Side == SELL && o.Price <= p {
                sellVolume += o.Quantity
            }
        }

        currentVolume := min(buyVolume, sellVolume)
        if currentVolume > maxVolume {
            maxVolume = currentVolume
            bestPrice = p
        }
        // 此处省略处理多个价格点成交量相同时的 tie-breaking 规则
    }

    if maxVolume == 0 {
        return 0.0, nil // 没有可成交的
    }

    // 3. 以 bestPrice 生成成交记录 (伪代码)
    // 需要按价格优先、时间优先原则匹配具体的买卖订单
    trades = generateTradesAtPrice(orders, bestPrice)
    return bestPrice, trades
}

工程坑点:这个实现的性能瓶颈在于 `O(P * N)` 的循环,P是价格点数量,N是订单数。优化点在于,可以先对订单按价格排序,然后通过一次遍历和累加计算出每个价格点的累计买卖量,将复杂度降低到 O(N log N + P)。在真实系统中,浮点数是禁区,必须使用高精度的 Decimal 库或者将价格和数量乘以一个大的整数(如10^8)转为定点数处理。

连续竞价实现 (订单簿)

连续竞价的核心是订单簿(OrderBook)的操作。下面是一个简化版的实现,使用 map 模拟价格档位,每个档位是一个订单链表。


// OrderBook 结构,实际会用更高效的结构,如红黑树+双向链表
type OrderBook struct {
    // bids: map[price] -> order list, price is descending
    // asks: map[price] -> order list, price is ascending
    bids *RedBlackTree // 假设我们有一个实现了的红黑树
    asks *RedBlackTree
}

func (ob *OrderBook) ProcessLimitOrder(order *Order) []*Trade {
    var trades []*Trade
    if order.Side == BUY {
        // 与卖盘撮合
        for order.Quantity > 0 {
            bestAskNode := ob.asks.Min() // 获取价格最低的卖单
            if bestAskNode == nil || order.Price < bestAskNode.Price {
                // 对手盘不存在或价格不匹配,无法成交
                break
            }

            // 遍历价格档位的订单队列
            for item := bestAskNode.Orders.Front(); item != nil; item = item.Next() {
                makerOrder := item.Value.(*Order)
                matchQuantity := min(order.Quantity, makerOrder.Quantity)

                trades = append(trades, createTrade(order, makerOrder, matchQuantity))

                order.Quantity -= matchQuantity
                makerOrder.Quantity -= matchQuantity

                if makerOrder.Quantity == 0 {
                    // Maker 订单完全成交,从链表移除
                    bestAskNode.Orders.Remove(item)
                }
                if order.Quantity == 0 {
                    break // Taker 订单完全成交
                }
            }
            
            if bestAskNode.Orders.Len() == 0 {
                // 该价格档位已空,从树中移除
                ob.asks.Delete(bestAskNode.Price)
            }
        }

        if order.Quantity > 0 {
            // 订单未完全成交,加入买盘
            ob.bids.Add(order)
        }

    } else { // SELL side, logic is mirrored
        // ...
    }
    return trades
}

工程坑点

  • 数据结构选择: 用 map+list 实现起来简单,但获取最优报价(min/max key)的效率不高。生产环境必须用红黑树或跳表,保证 O(log M) 的插入/删除和 O(1) 的最优报价查找。
  • 内存管理: 在 Go 或 Java 这类带 GC 的语言中,撮合引擎是性能热点,频繁创建 `Order` 和 `Trade` 对象会给 GC 带来巨大压力,导致STW(Stop-The-World)暂停,引发延迟抖动。必须使用对象池(sync.Pool in Go)来复用这些对象。
  • 确定性: 撮合逻辑必须是完全确定性的。给定相同的输入序列,必须产生完全相同的输出。这意味着不能使用任何依赖外部状态(如系统时间、随机数)或存在不确定行为的操作。这是实现高可用的基础。

性能优化与高可用设计

性能:榨干单核的艺术

撮合引擎的单线程特性,使其成为一个典型的 CPU-bound 应用。优化的核心思想是让这个单线程尽可能地贴近 CPU 执行,减少一切不必要的开销。

  • CPU 缓存友好: 避免指针跳转。订单簿的数据结构设计至关重要。使用数组代替链表(如果价格范围可预知),或者使用内存连续的自定义数据结构(如 B-Tree 的变体),可以极大地提高 CPU Cache命中率,减少从主存加载数据的延迟。
  • 避免系统调用: 撮合循环中每一次系统调用(如网络 I/O、文件 I/O)都是一次昂贵的上下文切换(用户态 -> 内核态 -> 用户态)。这就是为什么撮合引擎只做纯内存计算,而将网络通信和持久化等工作完全剥离到其他线程或进程。对于极限场景(HFT),甚至会采用内核旁路(Kernel Bypass)技术如 DPDK 或 Solarflare,在用户态直接操作网卡,完全绕开操作系统网络协议栈。
  • * 无锁化: 撮合引擎的单线程模型天然无锁。但与周边模块的交互需要小心。通常使用高并发无锁队列(如 LMAX Disruptor 模式)作为与外部(如网关、行情服务)通信的缓冲区。生产者(网关线程)将数据写入队列,唯一的消费者(撮合线程)从中读取,实现了线程间的解耦和无锁通信。

高可用:状态机复制的实践

单线程的撮合引擎是一个典型的单点。如何实现高可用?答案是利用其确定性,实现状态机复制(State Machine Replication)

撮合引擎可以被看作一个确定性状态机:`State_T+1 = F(State_T, Input_T)`。其中 `State` 是当前的订单簿,`Input` 是一个订单操作,`F` 是撮合函数。只要保证主备机拥有相同的初始状态,并以完全相同的顺序应用相同的输入流,它们在任何时间点的内部状态(订单簿)和产生的输出(成交回报)都将是完全一致的。

实现方案

  1. 主备(Active-Passive)架构:
    • 一台主撮合引擎(Active)正常处理订单并对外发布行情和成交。
    • 一台或多台备用引擎(Passive)潜伏。
    • 由 Sequencer 产生的有序订单流,通过可靠的消息队列(如 Kafka 单 partition topic)同时广播给主备引擎。
    • 备用引擎在内存中执行完全相同的撮合逻辑,维护一个与主引擎一模一样的订单簿副本,但不产生任何外部输出。
    • 通过心跳机制监控主引擎状态。一旦主引擎宕机,通过分布式锁(如 ZooKeeper/Etcd)或人工介入,将其中一台备用引擎提升为新的主引擎,并开始对外发布数据。切换过程中的延迟通常在秒级。
  2. 数据一致性保证: 整个方案的基石是那个由 Sequencer 保证的、全局有序的输入日志。只要日志不丢、不乱序,整个系统的状态就是可恢复和可复制的。这也是为什么 Kafka 或类似的分布式日志系统在现代金融架构中如此重要的原因。

架构演进与落地路径

构建一个交易所级别的撮合系统不可能一蹴而就,通常遵循一个分阶段的演进路径。

  1. 阶段一:单体 MVP (Minimum Viable Product)

    在一个进程内实现所有逻辑:网关、撮合、行情发布。使用内存中的数据结构,可能辅以简单的数据库做落地。这种架构简单直接,适合项目初期快速验证业务逻辑,服务于用户量和交易量不大的场景。它的瓶颈显而易见:无扩展性、无高可用。

  2. 阶段二:服务化拆分与高可用建设

    当业务量增长,需要将单体应用拆分为前述的微服务架构:网关、撮合引擎、行情服务、持久化服务等。引入核心的 Sequencer(可以使用 Kafka 或自研),并为撮合引擎建立主备热备机制。这是绝大多数中大型交易所采用的成熟架构,它在保证核心撮合性能的同时,提供了良好的高可用性和水平扩展能力(网关等无状态服务可以随意加节点)。

  3. 阶段三:多产品线/多资产分片 (Sharding)

    当交易对(如 BTC/USDT, ETH/USDT)数量巨大,单个撮合引擎实例无法承载所有交易对时,就需要进行水平分片。注意,撮合逻辑本身无法对单个交易对(如 BTC/USDT 的订单簿)进行分片,因为这会破坏价格优先原则。分片是在**交易对**这个维度上进行的。例如,一个撮合引擎集群,引擎A负责BTC/USDT和LTC/USDT,引擎B负责ETH/USDT和XRP/USDT。接入层和 Sequencer 需要感知这种路由关系,将不同交易对的订单路由到正确的撮合引擎实例。每个实例依然是单线程的,并有自己的主备集群。

  4. 阶段四:极致性能(HFT 场景)

    对于需要微秒级延迟的高频交易市场,将进行更深层次的优化。包括硬件层面的服务器托管(Co-location)、FPGA 加速撮合逻辑、内核旁路网络、以及更激进的内存管理和算法优化。这已进入一个高度专业化的领域,是技术与资本投入的终极竞赛。

总而言之,集合竞价与连续竞价是构建现代交易市场的两大基石。理解它们不仅是理解业务,更是对高性能、高可用分布式系统设计的一次深刻实践。从算法原理到工程实现,再到架构演进,每一步都充满了挑战与权衡,而这正是架构设计的魅力所在。

延伸阅读与相关资源

  • 想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
    交易系统整体解决方案
  • 如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
    产品与服务
    中关于交易系统搭建与定制开发的介绍。
  • 需要针对现有架构做评估、重构或从零规划,可以通过
    联系我们
    和架构顾问沟通细节,获取定制化的技术方案建议。
滚动至顶部