本文旨在为中高级工程师与技术负责人,系统性拆解高频交易系统中处理做市商报价(Market Maker Quoting)的核心技术挑战与架构设计。我们将从流动性枯竭的业务场景出发,下探到操作系统内核、网络协议栈与数据结构的底层原理,剖析关键代码实现与工程陷阱,并最终给出一套从简单到复杂的架构演进路径。全文不谈概念,只讲实战中的技术权衡与实现细节。
现象与问题背景
在一个没有做市商(Market Maker, MM)或做市商不活跃的交易市场,我们会观察到几个典型现象:买卖价差(Spread)巨大、订单簿(Order Book)深度不足、以及价格波动剧烈。对于一个股票或数字货币交易对,买一价是 99.8,卖一价却是 100.5,这意味着普通交易者(Taker)需要承受巨大的交易成本。更严重的是,当一个稍大的市价单进来时,由于缺乏足够的对手方订单,价格会瞬间被“砸穿”或“拉爆”,造成所谓的“插针”行情。这种市场缺乏流动性(Liquidity),对交易平台和用户都是灾难性的。
做市商的核心职责就是为市场提供流动性。他们通过持续不断地向市场报出双边买卖价格(Two-sided Quotes),例如,在任何时刻都维持一个买价 100.1 和一个卖价 100.2,并承诺按此价格接受一定数量的交易。这极大地缩小了买卖价差,增加了市场深度。为了激励做市商,交易所通常会提供手续费返还(Rebate)等激励措施,并对其做市行为提出明确的做市义务(Market Making Obligations),例如:
- 报价时长(Presence):在 95% 的交易时间内,必须有合格报价在订单簿上。
- 报价价差(Spread):双边报价的价差不得超过某个阈值,如 0.2%。
- 报价数量(Size):双边报价的挂单量不得低于某个最小值,如 10,000 美元等值的资产。
这对交易系统的技术实现提出了严峻挑战:做市商为了规避风险和捕捉套利机会,会以极高的频率更新他们的报价,通常是毫秒甚至微秒级别。一个热门交易对,可能收到数十个做市商每秒数千次的报价更新。系统必须在保证公平性(Fairness)、低延迟(Low Latency)和高吞吐(High Throughput)的前提下,正确、高效地处理这些报价流。
关键原理拆解
要构建一个能承载高频做市商报价的系统,我们必须回到计算机科学的基础原理,理解瓶颈究竟在哪里。这并非“炫技”,而是在工程选型时做出正确决策的基石。
(教授声音)
1. 数据结构:订单簿的内存表示
订单簿的本质是一个按价格优先、时间优先排序的买单和卖单集合。任何新订单或报价的插入、取消、匹配操作,都要求高效地找到价格对应的位置。从算法角度看,这是一个典型的动态有序集合问题。
- 平衡二叉搜索树(Balanced Binary Search Tree):如红黑树(Red-Black Tree)或 AVL 树,是教科书式的解决方案。其插入、删除、查找操作的平均和最坏时间复杂度均为 O(log N),其中 N 是订单簿一侧的订单数量。这保证了即使订单簿很深,操作性能也不会线性下降。C++ 的 `std::map` 或 Java 的 `TreeMap` 底层就是红黑树。
- 跳表(Skip List):一种概率性数据结构,通过多层链表实现类似二叉搜索树的功能,期望时间复杂度也是 O(log N)。其实现相比红黑树更简单,且在并发场景下更容易实现无锁(Lock-Free)操作。
- 数组 + 哈希表:在价格档位(Tick Size)固定的情况下,可以使用一个大数组来表示价格档位,数组元素是一个链表,存储该价格的所有订单。配合哈希表快速定位订单ID。这种方式在价格连续且密集时,可以实现 O(1) 的查找,但空间浪费和处理稀疏价格是其主要缺点。
对于高频做市,报价的更新本质上是“取消旧报价,增加新报价”的组合。这意味着订单簿数据结构必须支持极高频的删除和插入操作,O(log N) 的复杂度是性能基线。
2. 网络通信:内核态与用户态的鸿沟
网络延迟是做市商的生命线。当一个网络包从网卡到达应用程序,它经历的路径漫长得惊人:网卡 DMA 到内核缓冲区 -> 硬中断 -> 软中断 -> 协议栈处理(TCP/IP) -> 数据从内核空间拷贝到用户空间 -> 应用程序被唤醒并读取数据。这个过程中,多次内存拷贝和上下文切换(Context Switch)是主要的延迟来源。
- TCP vs UDP:TCP 提供可靠的、有序的字节流服务,但握手、挥手、确认(ACK)、重传、拥塞控制机制都会引入不可预测的延迟。高频报价场景对延迟的确定性要求极高,因此行业主流采用 UDP 或基于 UDP 的自定义可靠协议(如 Aeron)。应用层自己处理序列号和重传,以换取对延迟的极致控制。
- 内核旁路(Kernel Bypass):为了彻底消除内核协议栈的开销,DPDK、Solarflare OpenOnload 等技术允许应用程序直接从用户空间读写网卡缓冲区。这绕过了整个内核网络栈,将延迟从几十微秒降低到个位数微秒。代价是更高的开发复杂度和对特定硬件的依赖。
3. 并发模型:决定论与锁的代价
撮合引擎的核心逻辑必须是确定性的(Deterministic)。给定相同的输入序列,必须产生完全相同的输出。这使得多线程并行处理同一个订单簿变得极其困难,因为锁的调度和线程竞争会引入不确定性。因此,行业标准做法是:单线程处理一个交易对(或一个分区)的撮合逻辑。这彻底避免了撮合核心的锁竞争,保证了逻辑的纯粹和高效。并发压力被转移到了 I/O 线程和业务逻辑的前后处理环节。
系统架构总览
一个完整的做市商报价处理系统,其架构远不止一个撮合引擎。它是一个分层、解耦的分布式系统。我们可以用文字勾勒出一幅典型的架构图:
- 接入层(Gateway):这是系统的门户,面向做市商。通常是集群化部署的。每个 Gateway 节点负责与若干做市商建立长连接(通常是 TCP 或 WebSocket),进行协议解析(如 FIX 或自定义二进制协议)、身份认证和会话管理。Gateway 是无状态的,可以水平扩展。
- 风控与预处理层(Risk & Pre-processing):Gateway 解码后的报价请求,不会直接发往撮合引擎。它会先经过一个极低延迟的风控模块,进行前置检查,如保证金是否足够、仓位是否超限、报价是否符合做市义务规则等。这一层是防止恶意或错误报价搞垮市场的关键防线。
- 序列发生器/排序器(Sequencer):所有合法的交易指令(包括做市商报价、普通用户订单)都必须经过一个全局唯一的排序器。Sequencer 的作用是为每个消息赋予一个严格递增的序列号,确保全市场事件的唯一“时间线”。这是实现公平性的基石。所有后续模块都按照这个序列号来处理事件。
- 撮合引擎集群(Matching Engine Cluster):这是系统的核心。通常按交易对或用户 ID 进行分区(Sharding)。每个分区是一个独立的撮合引擎实例,由一个单线程循环(Event Loop)驱动,按照 Sequencer 给予的顺序,串行处理消息。例如,`BTC-USDT` 的所有操作都在 Engine-1 上处理,`ETH-USDT` 在 Engine-2 上。
- 行情发布与数据总线(Market Data & Bus):撮合引擎产生的成交回报(Trade)、订单簿变更(Depth Update)等事件,会发布到一个高吞吐、低延迟的消息总线(如 Kafka 或自研的 Aeron/UDP Multicast 系统)上。所有需要市场数据的下游系统(如行情推送服务、历史数据服务、清结算系统)都从这里订阅数据。
- 持久化与清结算(Persistence & Settlement):成交数据会被异步写入数据库进行持久化,并由清结算系统进行后续的资金和持仓变更。这一步对延迟不敏感,可以异步处理。
在这个架构中,做市商的一笔报价更新(Quote)的生命周期是:`MM Client -> Gateway -> Risk -> Sequencer -> Matching Engine -> Market Data Bus -> MM Client (ACK)`。整个环路的延迟(Round-trip Time)是衡量系统性能的关键指标。
核心模块设计与实现
(极客声音)
1. 网关层的二进制协议与零拷贝
别用 JSON!别用 Protobuf!在高频场景,这些通用序列化方案的开销都无法接受。我们需要的是定长的、无需解析上下文的二进制协议。一个典型的报价消息体可能长这样:
// 64 字节的报价消息结构体,内存对齐
struct QuoteMessage {
uint8_t msg_type; // 1 byte, 消息类型, e.g., 0x01 for New Quote
uint8_t _padding[7]; // 7 bytes, 内存对齐
uint64_t quote_id; // 8 bytes, 客户端生成的报价ID
uint64_t symbol_id; // 8 bytes, 交易对ID
int64_t bid_price; // 8 bytes, 买价 (用定点数表示, e.g., 实际价格 * 10^8)
uint64_t bid_qty; // 8 bytes, 买量
int64_t ask_price; // 8 bytes, 卖价
uint64_t ask_qty; // 8 bytes, 卖量
uint64_t timestamp; // 8 bytes, 客户端时间戳 (ns)
};
在 Gateway 收到 TCP 字节流时,不要急着 `malloc` 新内存去拷贝。直接在一个大的环形缓冲区(Ring Buffer)里进行处理。这叫“零拷贝”(Zero-Copy)思想。用一个指针标记已处理位置,另一个指针标记新数据位置。你的解析代码直接操作这个 buffer。
// Go 语言伪代码示例
func handleConnection(conn net.Conn) {
// ringBuffer 是一个预分配的大 byte slice
for {
// 从 conn 读取数据到 ringBuffer 的可用空间
n, err := conn.Read(ringBuffer[writePos:])
if err != nil { break }
writePos += n
// 循环处理 buffer 中完整的消息
for writePos - readPos >= 64 { // 64 是消息长度
// 直接在 ringBuffer 上进行类型转换,没有内存拷贝!
msg := (*QuoteMessage)(unsafe.Pointer(&ringBuffer[readPos]))
// 校验和处理消息...
processMessage(msg)
readPos += 64
}
// 如果 buffer 满了,需要移动剩余数据到头部
if readPos > 0 {
copy(ringBuffer[0:], ringBuffer[readPos:writePos])
writePos -= readPos
readPos = 0
}
}
}
还有,别忘了设置 `TCP_NODELAY` socket 选项,禁用 Nagle 算法。否则操作系统会为了凑齐一个大的 TCP 包而缓存你的小消息,造成几十毫秒的延迟,这是绝对的杀手。
2. 撮合引擎的事件循环与报价处理
撮合引擎的核心是一个死循环,不断地从上游(Sequencer)的队列里取消息并处理。没有任何锁,就是一个纯粹的状态机。
// C++ 伪代码
class MatchingEngine {
private:
// key: price, value: list of orders at that price
std::map> bids; // 买盘,价格从高到低
std::map asks; // 卖盘,价格从低到高
// key: order_id, value: order details
std::unordered_map order_map;
// key: quote_id, value: {bid_order_id, ask_order_id}
std::unordered_map quote_to_orders;
public:
void run() {
while (true) {
// 从消息队列阻塞式获取下一条指令
auto command = command_queue.pop();
// 模式匹配处理不同指令
if (auto quote_msg = std::get_if(&command)) {
handle_quote(*quote_msg);
} else if (auto order_msg = std::get_if(&command)) {
handle_new_order(*order_msg);
}
// ...
}
}
void handle_quote(const QuoteMessage& msg) {
// 1. 做市商报价更新,本质是原子性的“先删后增”
// 查找旧的报价对应的订单
auto it = quote_to_orders.find(msg.quote_id);
if (it != quote_to_orders.end()) {
// 如果存在,先取消旧的买卖订单
cancel_order_internal(it->second.bid_order_id);
cancel_order_internal(it->second.ask_order_id);
quote_to_orders.erase(it);
}
// 2. 将新的双边报价作为两个独立的限价单加入订单簿
auto bid_order_id = generate_order_id();
auto ask_order_id = generate_order_id();
add_limit_order_internal(bid_order_id, msg.symbol_id, Side::BUY, msg.bid_price, msg.bid_qty);
add_limit_order_internal(ask_order_id, msg.symbol_id, Side::SELL, msg.ask_price, msg.ask_qty);
// 3. 记录新的 QuoteID 和订单ID的映射关系
quote_to_orders[msg.quote_id] = {bid_order_id, ask_order_id};
}
// ... cancel_order_internal 和 add_limit_order_internal 的实现
};
这里的关键是,做市商的报价更新(`handle_quote`)必须是原子操作。不能出现旧的买单被取消了,但新的报价还没挂上,中间有一个时间窗口。由于是单线程处理,这个原子性是天然保证的。`quote_to_orders` 这个哈希表至关重要,它维护了外部世界(做市商)的 `QuoteID` 和内部世界(撮合引擎)的 `OrderID` 之间的映射关系。
性能优化与高可用设计
系统能跑起来只是第一步,能在真实的高频冲击下活下来,才是真正的考验。
性能压榨到极致
- CPU 亲和性(CPU Affinity):把你的关键线程绑死在独立的 CPU 核心上。比如,Gateway 的 I/O 线程绑在 Core 1,Sequencer 绑在 Core 2,`BTC-USDT` 撮合引擎绑在 Core 3。这可以避免线程在核心之间被操作系统调度来调度去,最大化利用 CPU Cache(L1/L2),减少缓存失效(Cache Miss)带来的巨大延迟。
- 内存预分配与对象池:在系统启动时,就把所有可能用到的对象(如 Order、Quote、Node 等)在一个巨大的内存池里创建好。运行时需要新对象,就从池里取;对象用完了,就放回池里。这彻底避免了运行期间 `malloc/new` 带来的不确定性延迟和内存碎片。
- 热点数据与伪共享(False Sharing):对于被多个线程频繁访问的数据(例如,从 I/O 线程到撮合线程的队列),要警惕伪共享问题。如果两个线程需要的数据恰好在同一个缓存行(Cache Line,通常是 64 字节)里,一个线程修改数据会导致另一个线程的缓存行失效,即使它们修改的并不是同一个变量。解决方法是在数据结构中进行缓存行填充(Padding)。
高可用与容灾
单点故障是不可接受的。我们的设计必须能在任何组件失效时快速恢复。
- 网关/风控层:这些是无状态服务,通过简单的负载均衡(如 LVS/Nginx)就可以实现高可用。一个节点挂了,流量自动切到其他节点。做市商客户端需要有重连机制。
- 撮合引擎/序列发生器:这是有状态的核心。业界最常见的做法是主备复制(Active-Passive)。主节点(Primary)处理所有业务,同时将所有输入指令(已经被 Sequencer 序列化)同步到一个或多个备用节点(Replica)。备用节点在内存中以完全相同的顺序重放(Replay)这些指令,从而与主节点保持毫秒级的状态同步。
权衡点:同步复制还是异步复制?同步复制保证了数据不丢失(RPO=0),但主节点必须等备节点确认后才能继续,增加了交易延迟。异步复制延迟低,但主节点突然宕机时,最后几条指令可能还没来得及复制到备节点,造成少量数据丢失。对于交易系统,通常选择同步复制,并通过专用的万兆网络来最小化复制延迟。
- 故障切换(Failover):当监控系统(如 ZooKeeper 或 etcd 的心跳检测)发现主节点失联,会自动触发切换流程。备用节点被提升为新的主节点,并接管服务的虚拟IP(VIP)。整个切换过程理想情况下应该在秒级完成。
架构演进与落地路径
一口吃不成胖子。一个完美的系统都是逐步演进出来的。落地时可以遵循以下路径:
第一阶段:单体 MVP (Monolithic MVP)
所有模块(Gateway, Matching, Publishing)都在一个进程内,通过内存队列通信。交易对也都在一个撮合实例里。这种架构最简单,便于快速验证业务逻辑。适用于业务初期,交易量和做市商数量都很少的场景。
第二阶段:分层微服务化 (Layered Microservices)
当单体性能达到瓶颈,首先进行纵向拆分。将 Gateway、Risk Control、Matching Engine、Market Data Publisher 拆分为独立的服务。它们之间通过低延迟消息中间件(如 Aeron 或自研的 UDP 框架)通信。这使得每一层都可以独立扩展。例如,可以增加更多 Gateway 节点来接入更多做市商。
第三阶段:撮合引擎分区 (Matching Engine Sharding)
随着交易对增多和单一交易对的交易量上升,单个撮合引擎成为瓶颈。此时需要进行横向拆分,即分区(Sharding)。将不同的交易对分配到不同的撮合引擎实例上。这就需要一个智能的路由层(通常在 Gateway 或独立的路由服务中实现),根据消息中的 `symbol_id` 将其转发到正确的撮合引擎分区。这是扩展系统吞吐能力的关键一步。
第四阶段:多地域部署与全球化 (Geo-Distribution)
对于全球性的交易所,为了服务不同地区的用户并降低网络延迟,需要在全球多个数据中心(如东京、伦敦、纽约)部署完整的交易集群。这引入了跨地域数据同步和流动性分割的巨大挑战。通常采用的策略是区域自治,每个区域有自己的撮合中心,但通过复杂的风控和清算系统在后台打通账户体系,并通过特定做市商或内部流动性桥接机制来平衡各区域的流动性。
最终,一个看似简单的“报价”功能,背后是整个计算机科学体系在性能、一致性、可用性等多个维度上的极致运用和权衡。理解并掌握这些底层原理和工程实践,是构建任何高性能金融交易系统的必经之路。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。