本文面向具备一定分布式系统和底层原理基础的中高级工程师,旨在深入剖析“多对多”撮合系统的核心——批量撮合算法。我们将从金融交易系统(尤其是股票市场的集合竞价)的真实需求出发,回归到算法与数据结构的数学本质,最终落到可落地的架构设计、核心代码实现、性能优化与高可用性考量。我们将探讨如何在海量订单并发下,高效、公平地计算出那个能实现最大成交量的“黄金价格点”,并给出从单体到分布式集群的完整架构演进路径。
现象与问题背景
在绝大多数人的认知里,交易撮合是“连续”的——一笔买单进来,立刻寻找价格匹配的卖单,成交;反之亦然。这种模式被称为连续撮合(Continuous Matching),它追求的是最低的“撮合延迟”。然而,在许多特定场景下,系统需要的并非极致的速度,而是极致的“公平”与“价格发现”。例如,股票市场每日的开盘与收盘阶段,就是典型的“集合竞价”(Call Auction)时期。
想象一下开盘前的场景:在过去数小时的休市期间,积累了大量的买卖订单。如果采用连续撮合,那么谁的订单先到(哪怕早到1毫秒),谁的价格就可能成为开盘价,这对后到的订单极不公平,且价格极易被少数大单操纵。因此,系统需要一种机制,将这一个时间段内所有的订单视为一个整体,在某个精确的时间点(如9:25:00),以一个统一的价格完成尽可能多的成交。这个过程,就是批量撮合(Batch Matching),或称多对多撮合。
核心问题可以抽象为:给定一个买单集合和一个卖单集合,每个订单包含价格和数量,如何找出一个单一的成交价格(Equilibrium Price),使得在这个价格下,能够成交的总数量(Matched Volume)最大化?
一个朴素到近乎暴力的想法是:遍历所有买单,再遍历所有卖单,形成一个 `O(N*M)` 的复杂度。这在真实世界中是完全不可接受的。一个大型交易所,在集合竞价期间可能会有数百万笔订单,平方级别的复杂度会带来灾难性的计算延迟。我们需要一个在理论上高效,在工程上稳健的解决方案。
关键原理拆解
要解决这个问题,我们必须回归到经济学和计算机科学的基础原理。这个问题的本质,是寻找供需曲线的交点,使得交易量最大化。让我们用更严谨的数学语言来描述它。
首先,我们不需要关心每一笔单独的订单,而应该关心在每个价格档位(Price Level)上的总委托数量。这是一个关键的思维转变,它将问题从处理`N`个订单简化为处理`P`个价格档位,通常 `P << N`。
接下来,我们定义两个核心的累积函数:
- 累积买方需求函数 `Buy(p)`:代表在所有大于等于价格 `p` 的价位上,总共愿意购买的数量。这是一个单调递减的阶梯函数。价格越高,愿意买的人越少。
- 累积卖方供给函数 `Sell(p)`:代表在所有小于等于价格 `p` 的价位上,总共愿意出售的数量。这是一个单调递增的阶梯函数。价格越高,愿意卖的人越多。
对于任何一个可能成交的价格 `p`,能够实际成交的数量,取决于买卖双方中较少的那一方,即 `MatchedVolume(p) = min(Buy(p), Sell(p))`。我们的最终目标,就是找到一个价格 `p*`,使得 `MatchedVolume(p*)` 最大。
Let O_buy = { (price_i, quantity_i) } be the set of buy orders.
Let O_sell = { (price_j, quantity_j) } be the set of sell orders.
Define Buy(p) = Σ quantity_i for all i where price_i >= p.
Define Sell(p) = Σ quantity_j for all j where price_j <= p.
Find p* such that MatchedVolume(p*) = max( min(Buy(p), Sell(p)) ) for all possible prices p.
这个模型清晰地揭示了算法的核心。我们不需要测试无限个价格,只需要测试所有订单中出现过的价格档位即可。因为在两个相邻的价格档位之间,`Buy(p)` 和 `Sell(p)` 的值是恒定的,所以 `min(Buy(p), Sell(p))` 的值也不会改变。因此,最优解一定存在于所有委托价格的集合中。
基于此,我们可以设计出高效的算法:
- 聚合:遍历所有买卖订单,将相同价格的订单数量合并。得到两个 `Map<Price, TotalQuantity>`,一个用于买单,一个用于卖单。时间复杂度 `O(N)`,N为总订单数。
- 排序:提取所有出现过的唯一价格档位,并进行排序。时间复杂度 `O(P log P)`,P为唯一价格档位数。
- 累积:
- 从最高价到最低价,计算每个价格档位的累积买入量 `Buy(p)`。
- 从最低价到最高价,计算每个价格档位的累积卖出量 `Sell(p)`。
这一步可以在 `O(P)` 时间内完成。
- 寻找最优解:遍历所有价格档位 `p`,计算 `min(Buy(p), Sell(p))`,并记录下使得该值最大的价格 `p*` 和对应的最大成交量。时间复杂度 `O(P)`。
整个算法的最终时间复杂度为 `O(N + P log P)`。考虑到 `P` 通常远小于 `N`,这在性能上相比 `O(N^2)` 是天壤之别,完全满足工业级应用的需求。
系统架构总览
在讨论具体实现之前,我们先拉高视角,看一个支持批量撮合的交易系统宏观架构。它不仅仅是一个算法,而是一个包含数据流入、持久化、计算、结果分发的完整系统。
一个典型的最小化高可用架构可以描述如下:
- 接入网关(Gateway):集群化部署,负责客户端连接管理、协议解析(如FIX协议)、基础认证与风控。接收到订单后,并不直接处理,而是将其封装成统一格式的消息,发送到消息队列。
- 削峰填谷:应对集合竞价开始前瞬间涌入的流量洪峰。
- 顺序保证:在单个 partition 内,订单严格有序,保证了处理的确定性。
- 持久化与可恢复性:即使撮合引擎宕机,也可以从上次消费的 offset 重新开始,保证订单不丢失。这是构建无状态撮合引擎的基础。
- 撮合引擎(Matching Engine):核心计算单元。这是一个有状态的服务,它从 Kafka 消费订单,在内存中构建订单簿(Order Book)。在预设的撮合时间点(例如 09:25:00),触发批量撮合算法。为保证高可用,通常采用主备(Active-Standby)模式。
- 行情网关(Market Data Gateway):撮合结果(成交回报、行情快照)会生成新的消息,发送到另一个 Kafka Topic。行情网关消费这些消息,并向外广播实时行情。
- 持久化存储(Persistence):通常是关系型数据库如 MySQL 或分布式数据库。撮合引擎会定期将内存中的订单簿状态和成交结果异步刷入数据库,用于清结算、历史查询和冷备份。撮合的关键路径绝对不能直接读写数据库。
- 消息队列/日志系统(Sequencer):这是系统的“主动脉”。我们通常使用 Kafka 或类似的分布式日志系统。所有订单请求必须先写入这个队列。它提供了三大核心价值:
这个架构将系统的读(行情)写(下单)路径分离,并通过 Kafka 作为核心缓冲和解耦层,使得撮合引擎可以专注于高性能的内存计算,同时保证了系统的可扩展性与容灾能力。
核心模块设计与实现
现在,我们下沉到撮合引擎内部,用“极客工程师”的视角来审视代码实现和其中的坑点。
数据结构与订单簿
首先,价格不能用浮点数!这是金融系统开发的铁律。浮点数存在精度问题,会导致灾难性的错误。所有价格和金额都必须使用定点数,通常是 `int64` 或 `Decimal` 类型。例如,用 `int64` 存储价格,`100.23` 元会被存储为 `1002300`(假设精度到分,再乘以100)。
批量撮合前,我们需要将从 Kafka 消费的订单流实时聚合到内存中的订单簿里。这里的数据结构选择至关重要。
// Order represents a single order from a user.
type Order struct {
ID string
Price int64 // Use int64 for fixed-point math
Quantity int64
Side OrderSide // BUY or SELL
}
// BatchMatcher holds the state for a single batch matching cycle.
type BatchMatcher struct {
// Key: price, Value: total quantity at this price level
buyLevels map[int64]int64
sellLevels map[int64]int64
}
// AddOrder ingests an order and aggregates its quantity.
// This is called for every incoming order before matching.
func (m *BatchMatcher) AddOrder(order Order) {
if order.Side == BUY {
m.buyLevels[order.Price] += order.Quantity
} else {
m.sellLevels[order.Price] += order.Quantity
}
}
这里的 `buyLevels` 和 `sellLevels` 就是前面原理层提到的聚合结果。使用 `map` 是最直接、最灵活的方式,`O(1)` 的平均时间复杂度用于插入和更新。
核心撮合算法实现
下面是核心算法的 Go 语言实现。代码逻辑严格遵循了我们之前拆解的步骤。注意其中的注释,它们解释了每一步的逻辑和复杂度。
// FindEquilibrium finds the price and quantity that maximizes trading volume.
func (m *BatchMatcher) FindEquilibrium() (equilibriumPrice, maxVolume int64) {
// 1. Collect all unique price levels. O(P)
priceSet := make(map[int64]struct{})
for p := range m.buyLevels {
priceSet[p] = struct{}{}
}
for p := range m.sellLevels {
priceSet[p] = struct{}{}
}
prices := make([]int64, 0, len(priceSet))
for p := range priceSet {
prices = append(prices, p)
}
// 2. Sort the unique prices. O(P log P)
sort.Slice(prices, func(i, j int) bool {
return prices[i] < prices[j]
})
// 3. Calculate cumulative buy and sell quantities. O(P)
cumBuyQty := make(map[int64]int64)
var currentBuyQty int64
// Iterate from highest price to lowest for cumulative buy
for i := len(prices) - 1; i >= 0; i-- {
p := prices[i]
currentBuyQty += m.buyLevels[p]
cumBuyQty[p] = currentBuyQty
}
cumSellQty := make(map[int64]int64)
var currentSellQty int64
// Iterate from lowest price to highest for cumulative sell
for i := 0; i < len(prices); i++ {
p := prices[i]
currentSellQty += m.sellLevels[p]
cumSellQty[p] = currentSellQty
}
// 4. Find the price that maximizes matched volume. O(P)
maxVolume = 0
equilibriumPrice = 0 // Or a default reference price
for _, p := range prices {
// Buy(p) is the cumulative quantity at prices >= p
// We get this from our pre-calculated map
buyQty := cumBuyQty[p]
// Sell(p) is the cumulative quantity at prices <= p
sellQty := cumSellQty[p]
matchedVolume := min(buyQty, sellQty)
if matchedVolume > maxVolume {
maxVolume = matchedVolume
equilibriumPrice = p
}
// IMPORTANT: Handle tie-breaking rules here. (See next section)
}
return equilibriumPrice, maxVolume
}
func min(a, b int64) int64 {
if a < b {
return a
}
return b
}
对抗层:真实世界的复杂性与权衡
上面的代码找到了最大成交量,但真实世界的交易所规则要复杂得多。一个极度重要的“坑点”是:当多个价格都能产生相同的最大成交量时,应该选择哪一个?
这个问题没有唯一的数学最优解,必须由业务规则来定义,且规则必须绝对确定,以防市场操纵。常见的 tie-breaking 规则链如下:
- 最小剩余量原则:选择那个使得未成交量 `|Buy(p) - Sell(p)|` 最小的价格。这个原则旨在让市场达到最“平衡”的状态。
- 参考价原则:如果最小剩余量原则仍无法唯一确定价格(例如,一个价格买方剩余多,另一个价格卖方剩余多,但绝对剩余量相同),则选择最接近某个参考价(如昨日收盘价)的价格。
- 价格优先原则:如果上述规则都失效,则由交易所规定一个最终的倾向。例如,沪深交易所规定,若存在两个价格点,则取这两个价格的算术平均值作为成交价。而其他交易所可能规定取较高的或较低的那个。
这些规则必须在上述代码的第四步中,当 `matchedVolume == maxVolume` 时,作为一个复杂的 `if-else` 逻辑链来实现。这部分代码往往比核心算法本身更复杂,也是最容易出错的地方。
另一个权衡在于算法的空间与时间。如果价格档位是离散且范围可控的(例如,股价范围在[0.01, 2000.00]之间,最小单位是0.01),我们可以用一个巨大的数组代替 `map` 来存储价格档位。`prices[10023]` 直接对应价格 `100.23`。这样做的好处是:
- 消除了 `O(P log P)` 的排序开销,因为数组索引天然有序。
- 提高了 CPU Cache 的命中率,因为内存是连续的。
坏处是空间占用巨大且不灵活。对于价格范围不固定的资产(如加密货币),这种方法并不可行。这是一个典型的空间换时间策略。
性能优化与高可用设计
对于撮合引擎,性能和可用性是生命线。
性能优化:
- 内存预分配:在集合竞价开始前,可以根据历史数据预估订单量和价格档位数,提前分配好 `map` 和 `slice` 的容量,避免运行时频繁的内存分配和扩容,这会引发GC压力和性能抖动。
- CPU亲和性:将撮合引擎的主线程绑定到特定的CPU核心上(CPU Affinity),可以减少线程在核心间的切换开销,并更好地利用L1/L2 Cache。
- 无锁化编程:在订单簿构建阶段,如果需要并发处理,可以考虑使用更高级的并发技术,如按价格范围分段加锁,或者在特定场景下采用 disruptor 这样的无锁队列模型来消费订单流,从而避免锁竞争。
高可用设计:
我们的架构中,撮合引擎是单点。虽然 Kafka 保证了数据不丢失,但引擎宕机到恢复是需要时间的。主备(Active-Standby)设计是标准实践。
- 状态复制:主引擎(Active)在处理来自 Kafka 的每一条消息时,不仅更新自己的内存状态,还需要通过一个可靠的通道将这条消息(或状态变更日志)同步给备引擎(Standby)。备引擎完全按照相同的顺序应用这些消息,从而复制出与主引擎一模一样的内存状态。
- 心跳与脑裂:主备之间需要有高频的心跳检测。为了防止脑裂(两个引擎都认为自己是主),需要一个外部的仲裁者,通常是 ZooKeeper 或 etcd。主引擎需要周期性地持有 ZK/etcd 中的一个临时节点(Lease),如果主引擎宕机,Lease 会过期,备引擎监听到变化后会尝试获取 Lease,成功后才能升级为主。
- 快速恢复:即使有主备,冷启动一个撮合引擎也可能很慢,因为它需要从 Kafka 的某个 offset 开始回放大量历史订单。因此,主引擎需要定期将自己的内存状态(订单簿快照)持久化到像 Redis 或分布式文件系统中。当一个新引擎(或重启的引擎)启动时,它可以先加载最新的快照,然后只从 Kafka 中该快照对应 offset 之后的位置开始消费,这极大地缩短了恢复时间(RTO)。
架构演进与落地路径
没有一个架构是“一步到位”的,它必须随着业务的增长而演进。
第一阶段:单体高可用撮合引擎
在业务初期,一个采用主备模式的单机撮合引擎足以应对。所有交易对(如股票A、股票B)都在这一个引擎实例中处理。此时的优化重点是单机性能,如算法效率、内存管理、GC调优。这个阶段的瓶颈通常是单个服务器的CPU和内存上限。
第二阶段:按交易对垂直拆分(Sharding)
当交易对数量和单个交易对的订单量持续增长,单机无法承载时,就需要进行垂直拆分。架构演进为:
- 路由网关:在接入网关之后增加一个路由层,它根据订单的交易对(如`symbol="AAPL"`)将订单发送到不同的 Kafka Topic(如 `orders-aapl`、`orders-goog`)。
- 撮合引擎集群:部署多组撮合引擎,每组(依然是主备模式)只负责处理一个或一部分交易对。`engine-group-1` 消费 `orders-aapl`,`engine-group-2` 消费 `orders-goog` 等等。
这种架构实现了水平扩展,不同交易对的撮合过程互不影响。这是目前绝大多数交易所采用的核心架构模式。
第三阶段:全球化多活部署
对于顶级的全球化交易所,需要在全球多个数据中心(如东京、伦敦、纽约)部署撮合引擎,以降低用户的网络延迟。这引入了新的巨大挑战:
- 数据同步:一个在纽约下的单,如何能与一个在东京下的单进行撮合?这需要极低延迟的跨数据中心数据复制方案,比如使用 Kafka MirrorMaker2 或专门的跨洋专线。
- 一致性与分区:通常会采用区域化撮合(Region-local Matching)的策略。用户的请求会被路由到最近的数据中心,订单也只在那个区域的订单簿里。但对于某些需要全球统一订单簿的业务,就必须面对分布式系统中最困难的问题——跨地域数据一致性、网络分区容错(CAP理论的现实挑战),这通常需要借助类似 Google Spanner 这样的全球分布式数据库,或者在应用层实现复杂的分布式共识算法。这是架构演进的终极形态,也是挑战最大的阶段。
从一个高效的 `O(N + P log P)` 算法出发,我们构建了一个高可用、可扩展的撮合系统,并规划了其应对未来业务增长的演进路径。这正是架构师的工作:连接理论与现实,平衡当前与未来,在约束中构建优雅而强大的系统。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。