从集合竞价到连续撮合:解构两种核心交易模式的底层机制与架构权衡

金融交易系统的核心是其撮合引擎(Matching Engine),其根本使命在于以公平、高效的方式匹配买卖双方的意图。在所有撮合模式中,以“集合竞-价”为代表的双向拍卖模式和以“连续竞价”为代表的持续交易模式是构建现代电子化交易市场的两大基石。本文旨在为中高级工程师与架构师深度剖析这两种模式。我们将不仅停留在业务概念,而是深入到底层的数据结构、算法复杂度、CPU 缓存行为,并最终探讨在构建大规模、低延迟交易系统时,如何在性能、一致性与可用性之间做出关键的架构权衡。

现象与问题背景

在任何一个成熟的证券或数字资产交易所,我们通常会观察到两种截然不同的交易阶段。例如,在A股市场,上午9:15到9:25是“集合竞价”时间,用于确定当天的开盘价;而9:30之后则进入“连续竞价”阶段,订单被实时撮合。这种设计并非偶然,而是为了解决不同场景下的核心问题。

集合竞价(Collective Auction),本质上是一种批处理(Batch Processing)模式。它在特定时间窗口内(如开盘、收盘或盘中临时暂停后)收集所有买卖订单,然后根据特定算法计算出一个单一的“成交价”,该价格能够最大化成交量。其核心目标是价格发现(Price Discovery)。在信息不对称或流动性不足的时刻(如隔夜后大量新闻和情绪积累的开盘时刻),通过汇集所有意图,找到一个市场最广泛认可的“公允价格”,避免因单笔早期交易的偶然性导致价格剧烈波动。

连续竞价(Continuous Auction),则是一种流式处理(Stream Processing)模式。一旦进入该阶段,系统遵循“价格优先、时间优先”的原则。当一个新订单进入系统,它会立即尝试与订单簿(Order Book)中已存在的、价格最优的对手方订单进行匹配。这种模式提供了极高的即时性,价格随每一笔新订单和成交而实时变动。其核心目标是流动性供给与即时成交,满足交易者对速度的极致追求。

这两种模式的并存,引出了架构师必须面对的根本问题:

  • 如何在同一个系统中高效地实现并切换这两种截然不同的算法?
  • 这两种算法对底层数据结构、计算复杂度、系统吞吐和延迟有何根本性影响?
  • 支撑这两种模式的系统架构,在面对高并发、低延迟和高可用挑战时,需要做出哪些不同的设计决策?

理解这些问题,是设计任何高性能交易系统的起点。

关键原理拆解

作为一名严谨的计算机科学家,我们必须将业务现象剥离,回归到底层的计算原理。撮合引擎的本质是状态机,其核心是围绕“订单簿”这一数据结构进行的操作。两种模式的差异,根源在于它们操作订单簿的算法哲学不同。

1. 核心数据结构:订单簿(Limit Order Book, LOB)

订单簿是存储所有未成交限价单(Limit Order)的集合,分为买单(Bid)和卖单(Ask)两边。从数据结构角度看,每一边都需要高效地支持以下操作:

  • 插入(Insert):新订单按价格和时间顺序加入。
  • 删除(Delete):订单被取消或完全成交。
  • 查找最优报价(Find Best):即时找到最高买价(Best Bid)和最低卖价(Best Ask)。

教科书式的实现会告诉你使用两个平衡二叉搜索树(如红黑树),一个按价格降序存买单,一个按价格升序存卖单。这能保证所有操作的平均时间复杂度为 O(log N),其中 N 是订单簿中订单的数量。然而,在真实工程中,考虑到价格通常是离散的(例如,股票的最小价格变动单位是0.01元),更优化的结构是使用两个哈希表(或Map)来映射价格到订单队列。例如,map<Price, Deque<Order>>。这样,查找特定价格的复杂度是 O(1)(或哈希表的平均复杂度),而找到最优价格则需要一个额外的有序结构(如跳表或平衡树)来维护价格列表,其复杂度为 O(log P),其中 P 是订单簿中的价位(Price Level)数量。在绝大多数市场,P 远小于 N,这是一种显著的优化。

更进一步,从CPU和内存的角度看,指针追逐(Pointer Chasing)密集的树形结构对CPU缓存极不友好。顶级的交易系统会使用更紧凑的数据结构,如基于数组的堆(Heap)或定制的、内存连续的B-Tree变体,以最大化缓存局部性(Cache Locality)。

2. 算法分析:连续竞价 vs. 集合竞价

连续竞价算法: 这是一个典型的贪心算法。当一个新订单(如买单 B)到达时,算法的逻辑是:

  1. 查找卖单簿中的最低报价(Best Ask)P_ask
  2. 如果 B.price >= P_ask,则存在价格交叉(Cross),可以成交。
  3. 从价格为 P_ask 的订单队列中,按时间顺序(FIFO)取出最早的卖单进行匹配。
  4. 更新双方订单的剩余数量,生成成交记录(Trade Tick)。
  5. 如果买单 B 仍有剩余数量,重复步骤1-4,直到 B 被完全撮合,或者其价格不再优于 Best Ask。
  6. 如果 B 仍有剩余,则将其插入买单簿。

该算法的复杂度主要在于查找和匹配过程。使用我们之前讨论的优化数据结构,单笔订单的撮合过程复杂度大致为 O(M * log P),其中 M 是该订单穿越的价位数量,P 是总价位数量。

集合竞价算法: 这个算法的目标是全局最优,而非局部贪心。其目标是找到一个价格 P_match,使得在该价格上,买方愿意支付高于等于 P_match 的累计订单量,与卖方愿意接受低于等于 P_match 的累计订单量之间的重叠部分最大化。其标准算法步骤如下:

  1. 将所有有效买单按价格从高到低排序,价格相同的按时间排序。
  2. 将所有有效卖单按价格从低到高排序,价格相同的按时间排序。
  3. 构建累计需求曲线和累计供给曲线。对每一个唯一的价格点 P,计算出价格 >= P 的总买单量,和价格 <= P 的总卖单量。
  4. 遍历所有可能的价格点,计算每个价格点上的可成交量(min(累计买量, 累计卖量))。
  5. 找到使可成交量最大的那个价格点,作为最终的成交价。

这里面还有一些复杂的“平价规则”(Tie-breaking rules),例如当多个价格都能产生最大成交量时,如何选择。这通常由监管或交易所规则定义。该算法的计算密集度远高于连续竞价,其复杂度至少为 O(N log N)(排序所有订单),或者 O(P)(如果预先构建了按价位聚合的视图,P是价位数)。这解释了为什么它只能周期性地、在交易暂停时运行。

系统架构总览

一个支持双模式的撮合系统,其宏观架构通常可以被描绘为一条高度优化的数据处理流水线。我们用文字来描述这幅架构图:

1. 接入层 (Gateway): 这是系统的边界。通常由一组服务器构成,负责处理与客户端(券商、量化交易机构)的连接。它使用标准的金融信息交换协议(如FIX)或私有的二进制协议,通过TCP长连接接收订单请求。这一层的核心职责是:会话管理、认证鉴权、协议解析和基本的风控检查(如订单合法性)。高性能网关会利用Linux的epoll机制处理大量并发连接。

2. 排序层 (Sequencer): 这是保证系统公平性的命脉。所有来自不同网关的订单请求,必须在这里被赋予一个全局唯一的、严格递增的序列号。整个系统的正确性都建立在这个全序(Total Order)之上。在最简单的实现中,它可以是一个单点的、内存中的队列。在分布式系统中,这演变为一个共识问题,可以通过Raft/Paxos协议或一个专用的日志系统(如Kafka,但通常是延迟更低的自研方案)来实现。排序器的输出是一个确定性的、可重放的指令流。

3. 撮合核心 (Matching Engine Core): 这是业务逻辑的核心。它消费来自排序器的指令流。为了追求极致的性能,单个交易对(如 BTC/USDT)的撮合逻辑几乎总是运行在单个线程中。这避免了复杂的锁机制和随之而来的上下文切换开销,保证了撮合的确定性和极低的延迟。这个线程会被绑定到特定的CPU核心(CPU Affinity)上,以最大化利用CPU缓存。系统通过在内存中维护订单簿状态,根据当前系统模式(集合竞价或连续竞价)执行相应的算法。当模式切换时,引擎会接收一个特殊的控制指令。

4. 行情与持久化 (Market Data & Persistence): 撮合核心产生的输出——成交记录(Trades)和订单簿变更(L2 Updates)——被分发到两个下游。一是行情发布系统,它通常使用UDP组播(Multicast)将市场数据以最低延迟广播给所有订阅者。二是持久化模块,它将指令流和产生的状态变更写入持久化日志,用于系统崩溃后的恢复和审计。

核心模块设计与实现

我们现在切换到极客工程师的视角,深入几个关键模块的实现细节和坑点。

订单簿的务实实现

教科书里的红黑树在实践中并不完美。我们通常用一种混合结构。以Go语言为例,可以这样设计:


// Order represents a single limit order
type Order struct {
    ID        uint64
    Price     int64 // Use int64 to avoid float precision issues, e.g., 10.5__ -> 1050
    Quantity  int64
    Timestamp int64 // For time priority
    // ... other fields
}

// OrderQueue is a FIFO queue of orders at the same price level
type OrderQueue struct {
    head *list.Element // Using container/list for O(1) add/remove
    tail *list.Element
}

// OrderBook for a single instrument
type OrderBook struct {
    // Bids are sorted from high to low price
    // Asks are sorted from low to high price
    bids *treemap.Map // Using a third-party treemap for sorted price levels
    asks *treemap.Map // Keys are price (int64), values are *OrderQueue
}

// AddOrder adds a new order to the book.
func (ob *OrderBook) AddOrder(order *Order) {
    // 1. Get the price map (bids or asks)
    // 2. Look up the OrderQueue for the order's price
    // 3. If queue doesn't exist, create it and add it to the treemap
    // 4. Add the order to the tail of the queue (FIFO)
}

代码解读与坑点:

  • 价格表示: 绝对不要用浮点数表示价格!精度问题会带来灾难。应该将其乘以一个固定的倍数(如10000)转换为整数(int64或decimal类型)进行处理。
  • 数据结构选择: 这里我们用了一个 `treemap`(可以理解为平衡树实现的Map)来保持价格的有序性,使得查找最优报价(`treemap.Min()` / `treemap.Max()`)的复杂度为 O(log P)。每个价格位点后面挂一个双向链表 `OrderQueue`,保证了在同一价位上新订单的插入和老订单的删除都是 O(1) 的,完美实现了价格优先、时间优先。
  • 内存管理: 在C++等语言中,需要极其小心内存分配。频繁的 `new/delete` 会造成内存碎片和性能抖动。顶级系统会使用内存池(Object Pool)来复用订单和队列节点对象。

集合竞价算法的工程实现

集合竞价的计算量很大,必须高效实现。下面是其核心逻辑的伪代码:


func (me *MatchingEngine) RunCollectiveAuction() (matchPrice int64, matchVolume int64) {
    // 1. Get all unique price levels from both bids and asks
    allPrices := collectAndSortUniquePrices(me.orderBook.bids, me.orderBook.asks)

    var bestPrice int64 = -1
    var maxVolume int64 = 0

    // 2. Iterate through each potential price to find the one with max volume
    for _, p := range allPrices {
        cumulativeBidVol := calculateCumulativeVolume(me.orderBook.bids, p, "gte") // vol for price >= p
        cumulativeAskVol := calculateCumulativeVolume(me.orderBook.asks, p, "lte") // vol for price <= p

        currentVolume := min(cumulativeBidVol, cumulativeAskVol)

        if currentVolume > maxVolume {
            maxVolume = currentVolume
            bestPrice = p
        } else if currentVolume == maxVolume {
            // Apply tie-breaking rules, e.g., choose price closer to yesterday's close
            bestPrice = applyTieBreakingRule(bestPrice, p, me.previousClose)
        }
    }
    
    // 3. If a match price is found, execute trades at that single price
    if bestPrice != -1 {
        executeMatchesAtPrice(me.orderBook, bestPrice, maxVolume)
    }

    return bestPrice, maxVolume
}

代码解读与坑点:

  • 性能瓶颈: `calculateCumulativeVolume` 函数是性能热点。如果每次都完整遍历 `treemap`,效率会很低。一个常见的优化是,在遍历价格 `allPrices` 的过程中,增量计算累计量。当价格从高到低移动时,累计买量是递增的,可以 O(1) 更新;反之亦然。
  • 确定性: 整个计算过程必须是完全确定性的。Tie-breaking 规则必须清晰无歧义。例如,A股市场的规则就非常具体,包括如何选择最接近前收盘价的价格等。任何不确定性都会导致主备系统状态不一致。
  • 原子性: 从开始计算到执行撮合的整个过程必须是原子的。在此期间,不能有新的订单进入或取消订单的操作影响这个计算快照。这通常通过一个状态锁或模式标志来实现。

性能优化与高可用设计

对于一个交易所级别的系统,微秒级的延迟和99.999%的可用性是基本要求。

极致的延迟优化

  • 内核旁路(Kernel Bypass): 传统的网络协议栈(TCP/IP)涉及多次内核态/用户态切换和内存拷贝,延迟在几十微秒级别。使用如 DPDK 或 Solarflare Onload 这样的技术,应用程序可以直接在用户态读写网卡缓冲区,将网络I/O延迟降低到个位数微秒。这是顶级做市商和交易所的标配。
  • CPU亲和性与缓存优化: 将撮合线程、网络I/O线程绑定到不同的、隔离的CPU核心上。确保撮合核心的L1/L2缓存尽可能“热”,即总是包含订单簿数据。避免线程在核心之间被操作系统调度,从而导致缓存失效。这就是所谓的“机械共鸣(Mechanical Sympathy)”。
  • 无锁化编程: 线程间通信是延迟的主要来源。网关接收订单后,通过无锁队列(如Disruptor Ring Buffer)将其传递给撮合线程。这种设计避免了锁的争用,提供了极高的吞吐和稳定的低延迟。

高可用架构(HA)

单点故障是不可接受的。撮合引擎的HA通常采用主备(Active-Passive)模式。

  • 确定性是基石: 撮合引擎的逻辑必须是100%确定性的。只要给定相同的输入序列,无论在哪台机器、哪个时间运行,都必须产生完全相同的输出。这意味着不能使用任何随机数、系统时间(用于逻辑判断)、或未初始化的内存。
  • 主备复制: 主机(Active)执行撮合,并将经过排序器排序的指令流实时复制给备机(Passive)。备机在内存中完全同步地重放(Replay)这个指令流,构建起与主机一模一样的内存状态(订单簿)。
  • 心跳与故障切换: 主备机之间通过独立网络进行心跳检测。当主机宕机(或网络隔离),一个外部的仲裁者(如ZooKeeper或etcd)或备机自身检测到心跳超时,会触发切换流程。备机立刻接管,成为新的主机。由于备机拥有最新的内存状态,切换可以做到秒级甚至亚秒级完成,业务中断时间极短。

架构演进与落地路径

构建这样复杂的系统不可能一蹴而就。一个务实的演进路径如下:

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

在一个进程内实现所有功能:网关、排序、撮合。撮合逻辑单线程。数据持久化到本地日志文件。这个阶段的目标是验证业务逻辑的正确性,支持有限的交易对。此时,性能和可用性不是首要矛盾。

第二阶段:服务化与高可用

将单体应用拆分为独立的微服务:Gateway、Sequencer、Matching Engine。引入主备复制机制,实现单机房内的HA。Sequencer成为保证数据一致性的关键节点。此阶段的目标是提供一个稳定可靠的生产系统。

第三阶段:水平扩展与性能优化

当单个交易对的交易量或总交易对数量超过单机极限时,必须进行水平扩展。引入分片(Sharding)机制,将不同的交易对(如BTC/USDT和ETH/USDT)分布到不同的撮合引擎集群(每个集群都是一组主备)。网关层变得更复杂,需要根据交易对路由订单。同时,引入UDP组播发布行情,并开始应用内核旁路等终极优化手段,以应对高频交易的需求。

第四阶段:异地多活与容灾

在多个地理位置分散的数据中心部署完整的撮合集群。这带来了跨数据中心数据复制的巨大挑战,网络延迟成为主要敌人。通常需要借助专线和复杂的复制协议(如基于Raft/Paxos的跨DC日志复制)来实现。这是金融系统容灾的最高形态,能够抵御机房级别的灾难。

总而言之,集合竞价与连续竞价不仅是两种交易业务模式,它们背后反映了批处理与流处理的计算范式差异。对于架构师而言,理解其算法原理、数据结构选择、以及对系统架构的深远影响,是在真实世界中构建稳健、高效、可扩展的金融级交易系统的必备内功。

延伸阅读与相关资源

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