惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Security Archives - TechRepublic
Security Archives - TechRepublic
T
The Exploit Database - CXSecurity.com
P
Proofpoint News Feed
Scott Helme
Scott Helme
NISL@THU
NISL@THU
Cisco Talos Blog
Cisco Talos Blog
C
Cybersecurity and Infrastructure Security Agency CISA
AWS News Blog
AWS News Blog
V
Vulnerabilities – Threatpost
J
Java Code Geeks
U
Unit 42
The GitHub Blog
The GitHub Blog
H
Help Net Security
T
Tenable Blog
aimingoo的专栏
aimingoo的专栏
Jina AI
Jina AI
Spread Privacy
Spread Privacy
Apple Machine Learning Research
Apple Machine Learning Research
人人都是产品经理
人人都是产品经理
L
Lohrmann on Cybersecurity
T
Threatpost
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Engineering at Meta
Engineering at Meta
A
About on SuperTechFans
I
InfoQ
Microsoft Azure Blog
Microsoft Azure Blog
B
Blog
L
LINUX DO - 最新话题
K
Kaspersky official blog
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
T
Threat Research - Cisco Blogs
C
Check Point Blog
T
The Blog of Author Tim Ferriss
有赞技术团队
有赞技术团队
宝玉的分享
宝玉的分享
Help Net Security
Help Net Security
Google DeepMind News
Google DeepMind News
A
Arctic Wolf
Y
Y Combinator Blog
N
News | PayPal Newsroom
M
MIT News - Artificial intelligence
Latest news
Latest news
H
Hacker News: Front Page
Blog — PlanetScale
Blog — PlanetScale
腾讯CDC
I
Intezer
爱范儿
爱范儿
F
Fortinet All Blogs
P
Palo Alto Networks Blog
C
CERT Recently Published Vulnerability Notes

土法炼钢兴趣小组的算法知识备份

国密算法与国密 TLS 系列索引 【系统架构设计】架构质量属性:不只是"高可用高性能" 【系统架构设计百科】告警策略:如何避免"狼来了" 【系统架构设计】CQRS:读写分离的架构哲学 【系统架构设计】空间架构:极端扩展场景的解法 【系统架构设计】微服务架构深度审视:优势、代价与适用边界 【系统架构设计】扩展性原理:水平、垂直与对角扩展 【系统架构设计】无状态设计:扩展的第一步也是最难的一步 【系统架构设计】缓存架构:从本地到分布式的多级缓存体系 【系统架构设计】管道与过滤器:Unix 哲学的架构表达 【系统架构设计】复杂性管理:架构的核心战场 【系统架构设计】消息队列架构:异步解耦的设计与陷阱 【系统架构设计】CDN 架构:全球加速的设计原理 【系统架构设计】连接池设计:被忽视的性能杀手 【系统架构设计】弹性设计模式:熔断器、舱壁与超时 【系统架构设计】高可用设计模式:冗余、故障转移与仲裁 【系统架构设计】容量规划:从拍脑袋到数据驱动 【系统架构设计】数据库扩展:分库分表的工程实践与替代方案 【系统架构设计】SLO 工程:可靠性的量化管理 【系统架构设计】性能建模:用数学思维分析系统瓶颈 【系统架构设计】混沌工程:主动验证系统的韧性 【系统架构设计】零拷贝与内存映射:数据搬运的极致优化 【系统架构设计】线程模型:从 thread-per-request 到协程 【系统架构设计】容灾架构:多活与灾备设计 【系统架构设计】数据库性能模式:索引、查询与连接管理 【系统架构设计】数据建模:从关系范式到文档模型的真实权衡 【系统架构设计】吞吐量优化:批处理、流水线与并发模型 【系统架构设计】流处理架构:从批处理到实时的范式迁移 【系统架构设计】搜索引擎架构:倒排索引之上的系统设计 【系统架构设计】时序数据架构:监控与 IoT 的存储设计 【系统架构设计】数据迁移与版本化:在线不停机的数据演进 【系统架构设计】数据湖与数据仓库:分析架构的演进路线 【系统架构设计】API 网关设计:入口层的职责边界 【系统架构设计】应用层数据一致性模式:在正确性与性能之间走钢丝 【系统架构设计】多模数据库选型:Polyglot Persistence 的工程实践 【系统架构设计】服务发现与注册:动态拓扑的基础设施 【系统架构设计】配置管理架构:从配置文件到配置中心 【系统架构设计】全链路压测:大规模系统的性能验证 【系统架构设计】幂等性设计:分布式环境下的安全重试 【系统架构设计】契约测试与 Schema 演进:服务间的信任协议 【系统架构设计】长连接与推送架构:WebSocket、SSE 与 MQTT 【系统架构设计】延迟分析:从 P50 到 P999 的全链路追踪 【系统架构设计百科】DDD 战术模式:聚合、实体与值对象 【系统架构设计百科】防腐层与开放主机服务:系统集成的 DDD 方案 【系统架构设计百科】领域事件与事件风暴:从业务到架构的桥梁 【系统架构设计百科】CQRS + Event Sourcing 完整实战:从领域建模到部署 【系统架构设计百科】DDD 与微服务:用领域模型划分服务边界 【系统架构设计】DDD 战略设计:限界上下文与上下文映射 【系统架构设计百科】认证架构:从 Session 到 JWT 到 OIDC 【系统架构设计】API 设计哲学:REST vs GraphQL vs gRPC 的真实权衡 排序算法专题:从 TimSort 到并行排序 【密码学百科】国密算法体系:SM2/SM3/SM4/SM9 全景解读 【密码学百科】承诺方案:Pedersen 承诺、向量承诺与多项式承诺 【密码学百科】不经意传输与隐私信息检索:OT、OT 扩展与 PIR 【密码学百科】门限密码学:门限签名、门限解密与分布式密钥生成 完美哈希:从理论到 gperf 实践 【密码学百科】安全多方计算:从 Yao 的混淆电路到实用 MPC 【密码学百科】同态加密:从 Paillier 到全同态加密(FHE) 【密码学百科】零知识证明系统:zk-SNARKs、zk-STARKs 与 Bulletproofs 【密码学百科】概率论与密码分析:生日攻击、差分分析与线性分析 【密码学百科】计算复杂性与归约:密码安全性证明的基石 【密码学百科】秘密共享:Shamir 方案、VSS 与安全多方计算入口 【密码学百科】椭圆曲线代数:Weierstrass 方程、点群运算与曲线选择 【密码学百科】离散对数与配对密码学:从 DLP 到 BLS 签名 【密码学百科】格密码数学基础:SVP、LWE 与格基约化 【密码学百科】抽象代数:群、环、域的密码学视角 【密码学百科】有限域算术:GF(2^n) 运算与在 AES/ECC 中的应用 【密码学百科】数论进阶:二次剩余、椭圆曲线上的 Weil 配对 【密码学百科】密码学简史:从凯撒密码到量子时代 【密码学百科】威胁模型与安全目标:CIA 三要素之外 【密码学百科】Kerckhoffs 原则与现代密码设计哲学 【密码学百科】随机性:密码学的基石 【密码学百科】信息论入门:熵、完美保密与 Shannon 定理 【密码学百科】分组密码原理:Feistel 网络与 SPN 结构 【密码学百科】AES 逐步拆解:SubBytes 到 MixColumns 的数学 【密码学百科】分组密码工作模式全览:ECB/CBC/CTR/OFB/CFB 【密码学百科】流密码:RC4 的兴衰与 ChaCha20 的崛起 【密码学百科】密码学哈希函数:MD5→SHA-2→SHA-3 的进化之路 【密码学百科】MAC 与 HMAC:消息认证的正确姿势 【密码学百科】认证加密(AEAD):GCM、ChaCha20-Poly1305 与 OCB 【密码学百科】密钥派生函数:HKDF、PBKDF2、Argon2 与密码存储 【密码学百科】公钥密码的数论基础:模运算、群、原根 【密码学百科】RSA 从原理到攻击:教科书 RSA 为什么不安全 【密码学百科】Diffie-Hellman 密钥交换与离散对数问题 【密码学百科】椭圆曲线密码学(ECC):从几何直觉到点群运算 【密码学百科】数字签名:ECDSA、EdDSA 与 Schnorr 签名 【密码学百科】现代密钥交换:X25519、ECDHE 与前向保密 【密码学百科】混合加密与 KEM/DEM 范式:ECIES 与 HPKE 【密码学百科】填充方案:PKCS#1 v1.5、OAEP 与 PSS 【密码学百科】TLS 协议全解析:从握手到 0-RTT 【密码学百科】PKI 与数字证书:信任链的构建与崩塌 【密码学百科】密码认证协议:从 SRP 到 OPAQUE 【密码学百科】零知识证明入门:如何证明你知道而不泄露 【密码学百科】安全信道构造:Noise 协议框架与 Signal 协议 【密码学百科】密钥管理工程:HSM、KMS 与密钥生命周期 【密码学百科】侧信道攻击:从时序攻击到功耗分析 【密码学百科】密码学实现陷阱:三层漏洞分类、审计工具链与系统性预防 密码敏捷性:如何设计可升级的密码系统 【密码学百科】OpenSSL/BoringSSL 架构剖析:ENGINE、Provider 与 FIPS 模块 排序基准测试:用数据说话
【金融科技工程】撮合引擎实现:撮合算法、价格优先时间优先、状态机、低延迟工程
Liao Tonglang · 2026-04-22 · via 土法炼钢兴趣小组的算法知识备份

引子

撮合引擎(Matching Engine)是交易所的心脏:从证券、期货、期权到加密货币 CEX,每一笔成交价格的形成都发生在一段不超过几十微秒的代码里。它是一个看似简单、实则对工程师心智负担极重的子系统——数据结构只有一个”订单簿(Order Book)“,算法只有一个”价格时间优先(Price-Time Priority)“,但围绕它的所有周边:确定性回放、主备一致性、订单类型组合爆炸、自成交预防、集合竞价、分片与 Gateway 协同,构成了现代交易所工程师最难啃的一块骨头。

上一篇《交易所核心系统架构》里我们把撮合引擎当作一个黑盒放在图中央。本文打开这个黑盒:订单是怎么进入的?订单簿是怎么存的?匹配是怎么发生的?为什么几乎所有主流交易所都选择”单线程、无锁、按 symbol 分片”这条路?为什么说”撮合必须是确定性的”,这一句话同时决定了数据结构选型、随机数使用、时间戳来源、甚至日志格式?

本文面向两类读者:一类是想自己写一个玩具撮合引擎、理解 Nasdaq ITCH/OUCH 协议背后机制的工程师;一类是正在交易所、券商、或加密所工作,需要对撮合引擎的性能瓶颈、升级路径、灾备切换做技术判断的架构师。我们会从数据结构讲到 LMAX Disruptor,再到 Rust/Go 的可运行示例,最后给出一份”撮合引擎代码审查清单”。


一、订单簿:撮合引擎唯一的核心数据结构

1.1 订单簿的逻辑模型

中央限价订单簿(Central Limit Order Book,CLOB)是所有主流交易所撮合引擎的底层模型。它由两侧组成:

  • 买方(Bid / Buy Side):按价格从高到低排列
  • 卖方(Ask / Sell Side):按价格从低到高排列

每一侧在同一价格上会有多笔订单,按到达时间先后排队,形成价格层(Price Level)内的 FIFO 队列。最优买价(Best Bid)和最优卖价(Best Ask)之间的差额叫买卖价差(Spread),它是流动性的最直接度量。

                        ASK (卖方, 升序)
                        ┌─────────────────────────┐
                 100.05 │ O7(200) O8(150)         │  350
                 100.04 │ O5(100)                 │  100
                 100.03 │ O3(500) O4(300) O6(200) │ 1000    ← Best Ask
                        ├─────────────────────────┤
    ─── SPREAD 0.01 ────┤
                        ├─────────────────────────┤
                 100.02 │ O1(300) O2(400)         │  700    ← Best Bid
                 100.01 │ O9(600)                 │  600
                 100.00 │ O10(1000)               │ 1000
                        └─────────────────────────┘
                        BID (买方, 降序)

当新订单到达并且可成交时(买单价格 ≥ 最优卖价,或卖单价格 ≤ 最优买价),撮合发生;否则订单被加入到相应价格层的队尾。

1.2 数据结构选型

撮合引擎对订单簿的核心操作只有四个:

操作 频率 目标复杂度
查询最优价(Best Bid / Best Ask) 极高 O(1)
插入新订单 O(log N) 或 O(1)
撤单(按 order_id) O(1)
弹出最优价层的队头订单 O(1)

满足这组需求的典型实现有三种:

平衡树(红黑树 / AVL)+ 哈希索引

最常见的做法。用 std::map<Price, PriceLevel>(C++)或 BTreeMap<Price, PriceLevel>(Rust)按价格排序存储价格层,每个价格层内部是一个双向链表实现的 FIFO 队列。再维护一个 HashMap<OrderId, ListNode*> 用于 O(1) 定位到节点实现快速撤单。

  • 优点:通用、易理解、对价格范围无限制
  • 缺点:每次插入 O(log N)、缓存不友好、指针追逐多

跳表(Skip List)

Redis ZSET 的做法,与平衡树复杂度相同,但实现更简单、插入删除的常数因子略低,易于做 lock-free 变种。一些 Go 实现(如 goex/go-exchange 的早期版本)使用跳表。

定点价格梯(Fixed-Tick Price Ladder)

当价格精度是固定档位(tick size,如股票 0.01 元、期货 0.5 点)且波动范围有界时,可以用一个数组直接存储所有价格层,下标 = (price - min_price) / tick_size

  • 最优价查询:维护一个 best_bid_idx 指针,O(1)
  • 插入 / 撤单:O(1)
  • 代价:内存占用大(即使没有订单的价格档位也占用一个槽),价格范围必须预估

CME Globex、Nasdaq INET、以及多数追求极致低延迟的撮合引擎都采用数组价格梯。Jane Street 有一篇著名博客描述了它如何用数组(而非红黑树)把撮合延迟压到 3 微秒以内。对于加密货币撮合(价格波动大、tick size 可能变化),仍以红黑树 / 跳表为主。

1.3 价格层内部结构

价格层内部始终是 FIFO 队列(对价格时间优先规则而言)。但它不仅要支持队尾入队、队头出队,还要支持中间删除(撤单)。因此典型实现是双向链表 + HashMap 索引

PriceLevel {
    price:   i64
    qty:     u64   // 该价格层总量(O(1) 查询用)
    head:    OrderNode*
    tail:    OrderNode*
}

OrderNode {
    order_id, account_id, qty, ts_ns
    prev, next
}

Index: HashMap<OrderId, OrderNode*>

这样插入、撤单、队头出队都是 O(1)。唯一的 O(log N) 操作是新建 / 删除价格层时对树的操作。

1.4 冰山订单与隐藏单的存储

冰山订单(Iceberg) 在订单簿中只暴露一部分显示量(Visible Qty / Peak),剩余为隐藏量(Hidden Qty / Reserve)。实现上,每次显示量被吃掉,引擎自动从隐藏量”补货”,但补货后的新切片会在队尾重新排队(因为时间优先要求:后来的量不能优先于同价位的其他订单)。

Iceberg Order: display=100, reserve=900
 ┌──────────────────────────────────┐
 │ PriceLevel 100.03 queue:         │
 │  A(300) B(100-visible) C(200)    │ ← B 是冰山,显示 100
 └──────────────────────────────────┘
                ↓ B 被吃掉 100
 ┌──────────────────────────────────┐
 │  A(300) C(200) B'(100-visible)   │ ← 补货后 B' 重新排队尾
 │  (B reserve: 800)                │
 └──────────────────────────────────┘

隐藏单(Hidden Order / Iceberg with display=0) 则完全不出现在公开行情中,但在成交时仍然按价格 / 时间优先参与撮合,且通常让利于显示单(见 1.5)。

1.5 L1 / L2 / L3 行情视图

订单簿对外输出的行情有三种深度:

  • L1(Top of Book):只包含最优买 / 卖价及数量
  • L2(MBP,Market By Price):按价格层聚合,例如买 10 档、卖 10 档
  • L3(MBO,Market By Order):每一笔订单作为独立事件(Nasdaq ITCH 协议就是 L3)

L3 信息是撮合引擎内部状态的最完整对外投影,但只给做市商、专业交易者;普通用户看到的是 L2 或 L1。详见下一篇《行情分发》。


二、匹配算法:从价格时间优先到 Pro-Rata

2.1 价格时间优先(Price-Time Priority)

绝大多数现货、股票、加密交易所采用价格时间优先

  1. 先比价格(更优价优先)
  2. 同价格比时间(先到优先)

伪代码:

function match(incoming_order):
    while incoming_order.qty > 0:
        best_level = opposite_book.top()
        if best_level is None: break
        if not crosses(incoming_order.price, best_level.price): break

        maker_order = best_level.head
        trade_qty = min(incoming_order.qty, maker_order.qty)
        emit_trade(incoming_order, maker_order, best_level.price, trade_qty)

        maker_order.qty -= trade_qty
        incoming_order.qty -= trade_qty
        if maker_order.qty == 0:
            best_level.pop_head()
            if best_level.empty(): opposite_book.remove(best_level)

    if incoming_order.qty > 0 and incoming_order.type == LIMIT:
        same_book.insert(incoming_order)

成交价取 maker(被动方) 的挂单价格,而非 taker(主动方)的价格——这是”价格改进(Price Improvement)“的来源,也是和”询价 / RFQ”模式的本质区别。

2.2 Pro-Rata(按比例分配)

在某些高度集中、买卖价差经常为一个 tick 的市场(如 CME 某些短期利率期货、欧洲 Bund),价格时间优先会导致”谁快谁通吃”、做市商过度拥堵。Pro-Rata 规则把到来的订单量按比例分配给同一价格层的所有挂单:

PriceLevel 100.03 (total 1000):
  A(600), B(300), C(100)

Incoming SELL 500 @ 100.03:
  A 分得 500 * 600/1000 = 300
  B 分得 500 * 300/1000 = 150
  C 分得 500 * 100/1000 = 50

Pro-Rata 鼓励挂大单,但引入了另一个问题:大单可能被故意挂出去再撤单(Spoofing 嫌疑),因此监管对 Pro-Rata 市场的 Layering/Spoofing 监控更严。

2.3 混合规则:Pro-Rata with Top Order / Allocation

为了缓解”大单垄断”,很多期货市场采用混合规则

  • Top Order Priority:价格层中时间最早的订单(“头单”)先吃一部分(如 40%),剩余 60% 按 Pro-Rata 分给所有人
  • Price-Time + Pro-Rata:先按时间优先吃掉一定比例,剩余按 Pro-Rata
  • Market Maker Allocation:做市商账户有单独优先层,先于普通账户成交(常见于新上市合约启动初期,激励做市商提供流动性)

CME Globex 针对不同合约有多种匹配算法:FIFO、Allocation、Pro-Rata、Threshold Pro-Rata、Configurable TPRO、LMM(Lead Market Maker)Allocation。每个合约在上市前就固定,不允许随意切换。

2.4 集合竞价(Call Auction)

连续竞价(Continuous Trading)之外,交易所在开盘、收盘(以及某些情况的临时停复牌)会进行集合竞价:接受订单但不立即撮合,在某个时间点以一个统一价格一次性成交最大量。

集合竞价定价算法通常遵循最大成交量 → 最小未匹配量 → 参考价附近三优先原则:

  1. 能使当次成交量最大的价格
  2. 如多个价格都能达到最大成交量,选择买卖剩余未成交量最小的
  3. 如仍有多个,选择距离前一成交价(或参考价)最近的
  4. 仍并列,取中间值或按交易所规则定
flowchart TD
    A[订单持续进入集合竞价簿] --> B[T-1s 停止接单]
    B --> C[计算每个候选价的可成交量]
    C --> D{是否存在唯一最大量价格?}
    D -- 是 --> E[该价即开盘价]
    D -- 否 --> F[选剩余量最小的]
    F --> G{仍有并列?}
    G -- 是 --> H[取最接近参考价的]
    G -- 否 --> E
    E --> I[以开盘价统一成交 按价格时间优先分配]
    I --> J[进入连续竞价]

上交所、深交所开盘集合竞价 9:15–9:25(其中 9:20–9:25 不允许撤单),收盘集合竞价 14:57–15:00。港交所、欧交所、Nasdaq 都有自己的集合竞价窗口。

2.5 自成交预防(Self-Trade Prevention, STP)

同一交易账户(或关联账户组)的买卖单在订单簿中相遇时,按法规和交易所规则不应成交(会被视为 wash trade,操纵市场嫌疑)。STP 策略常见有:

策略 处理方式
Cancel Taker (CT) 撤销主动方新单
Cancel Maker (CM) 撤销被动方旧单
Cancel Both (CB) 双方全撤
Decrement Both (DB) 按较小量同时减少;若一方归零则撤,另一方保留剩余

实现上,在匹配循环检测到 incoming.account_group == maker.account_group 时分叉走 STP 分支。注意:STP 会影响价格发现(被迫跳过一些挂单),所以通常只针对同一账号、或同一账号组(如 master-subaccount 关系),不跨账户。

Binance、Bybit 公开 API 中 STP 模式是下单参数之一:selfTradePreventionMode=EXPIRE_TAKER|EXPIRE_MAKER|EXPIRE_BOTH|NONE。Coinbase 默认 Decrement and Cancel。


三、订单类型的实现

订单类型是撮合引擎复杂度的主要来源。下表概括最常见的订单类型及其在引擎内部的实现要点:

类型 含义 实现要点
Limit (GTC) 限价单,Good-Till-Cancel 撮合剩余部分挂入订单簿
Market 市价单 按对手方价格逐档吃,直到填满或对手方空;剩余直接拒绝(A 股)或按最差价保护拒绝(美股 NBBO 保护)
IOC Immediate-or-Cancel 可成交部分立即成交,剩余撤销
FOK Fill-or-Kill 如不能全部立即成交则全部撤销
GTD Good-Till-Date 带过期时间的 GTC;引擎或定时任务在到期时自动撤销
Post-Only / Maker-Only 只允许做 maker 如果会立即成交则撤销(不当 taker)
Stop / Stop-Limit 触发式 价格触发条件满足后转为 Market / Limit 单进入撮合
Iceberg 冰山 显示量 + 隐藏量,显示量吃完从隐藏量补货并重排
Hidden / Dark 隐藏单 不出现在公开行情;通常成交优先级低于显示单
Peg 钉盘单 价格跟随最优买 / 卖价自动调整
MOC / LOC Market/Limit-on-Close 仅参与收盘集合竞价

3.1 Stop 单的引擎实现

Stop 单不进入订单簿,而是进入一个独立的”待触发单”结构,按触发价索引(再按 symbol 分片)。撮合引擎每次成交后,拿最新成交价去该结构里找”触发价被穿过”的 Stop 单,把它们转为 Limit/Market 注入主撮合循环。

关键约定:Stop 单的触发与进入撮合之间必须是同一个事件循环内的连续步骤,否则会被抢跑。这也是为什么几乎所有撮合引擎都坚持单线程(见第六章)。

3.2 FOK 的二阶段扫描

FOK 要求”要么全部立即成交,要么全部撤销”。实现上先预扫描订单簿对手侧,计算若以该价格吃进所有可成交单,累计量是否 ≥ 订单量。若是,正式进入匹配循环;若否,直接拒绝。预扫描不得修改簿状态。

3.3 GTD 的过期清理

GTD / GTC with expiry 需要一个过期清理线程时间轮,到期后把订单从簿中撤出。清理本身必须通过撮合主循环的消息队列(而非直接修改簿),以保持事件顺序的确定性。


四、确定性:撮合引擎的第一性原则

4.1 为什么必须确定性

“确定性(Deterministic)”指:给定相同的初始状态和相同的输入序列,撮合引擎必须产生逐字节相同的输出(成交记录、订单簿快照、行情事件)。这是以下能力的前提:

  1. 主备热备与故障切换:备机重放主机的输入流即可得到相同状态
  2. 审计与回放:监管、风控、客户纠纷时需要回放撮合过程
  3. 跨机房灾备:异地备机可以从 WAL 回放建立一致副本
  4. 压力测试与回归:可以录制生产流量,在测试环境完全重放

任何违反确定性的代码(如 time.Now()rand.Intn()、goroutine 调度、HashMap 遍历顺序)都必须被输入化——即把不确定来源变成输入事件的一部分。

4.2 时间戳的处理

撮合引擎使用的时间戳必须来自输入事件(Gateway 打上的 ingress 时间),而不是引擎内部 time.Now()。这样,备机重放时间戳来自同一条输入流。

# 错误做法
func onOrder(order *Order) {
    order.AcceptedAt = time.Now()   // 非确定性
    book.Add(order)
}

# 正确做法
type Input struct {
    SeqNo      uint64
    IngressTs  int64  // Gateway 打上,随 WAL 持久化
    Payload    Order
}
func onInput(in *Input) {
    in.Payload.AcceptedAt = in.IngressTs
    book.Add(&in.Payload)
}

4.3 序列号与输入流

所有进入撮合的请求(下单、撤单、改单、定时触发、行情快照请求……)被 Gateway 层统一编号成一个单调递增的输入序列,以该序列为 WAL 写入顺序、也是撮合处理顺序。输出事件(Trade、Ack、Reject、BookUpdate)同样带连续 seq,供下游消费与回放对齐。

Input WAL:
  seq=1001 ts=...  NEW_ORDER  order_id=A qty=100 px=10.03
  seq=1002 ts=...  NEW_ORDER  order_id=B qty=50  px=10.03
  seq=1003 ts=...  CANCEL     order_id=A
  seq=1004 ts=...  NEW_ORDER  order_id=C qty=200 px=10.04  ← 触发撮合

Output WAL:
  seq=2001 ref=1004 TRADE     taker=C maker=B qty=50 px=10.03
  seq=2002 ref=1004 ACK       order_id=C remaining=150
  seq=2003 ref=1004 BOOK      level=10.04 +150

4.4 随机数与并发

如果业务确实需要随机(如 Pro-Rata 中有零头时的分配),从输入事件里带入种子,或用 hash(seq_no) 作为确定性随机源。严禁使用 /dev/urandom 或系统 RNG。

撮合引擎核心一律单线程(见第六章),这是解决并发不确定性最简单粗暴的办法。


五、内存结构 + WAL + 快照 + 主备

5.1 状态机模型

撮合引擎本质上是一个纯函数:

next_state, output_events = match(prev_state, input_event)

State = 订单簿 + 待触发单 + 账户持仓(部分引擎把持仓剥离出去),Input 来自上游 Gateway 的有序流,Output 是成交 + 行情事件 + ACK。这是典型的状态机复制(State Machine Replication)模型——与 Raft、Multi-Paxos 管理的状态机完全同构。

5.2 WAL(预写日志)

每条输入在进入撮合前先落盘 WAL。WAL 的几个关键设计:

  • 写盘模式O_DSYNC / fsync 每条都落盘(安全但慢),或每 N 毫秒 / M 条批量 fsync(折中)
  • 存储:本地 NVMe SSD(低延迟);分布式场景可用 Raft 日志(如 CockroachDB / etcd 风格),但增加 1–2 次 RTT
  • 格式:通常是定长头 + 变长 payload(Protobuf / Cap’n Proto / FlatBuffers)
  • 轮转与归档:按大小 / 时间切段,冷段压缩转对象存储

5.3 快照

只有 WAL 无法应对长时间运行(重放整天 WAL 太慢)。引擎定期把整个订单簿序列化成快照(Snapshot),落盘时记录”对应到哪个 WAL seq”。恢复时:加载最近快照 + 重放该 seq 之后的 WAL。

快照的关键工程点:

  • 不得阻塞主循环:使用写时复制(Copy-on-Write),或在单线程主循环中 fork 出子进程(Redis RDB 思路)做真正的序列化
  • 一致性边界:快照必须对应一个精确的 applied_seq,不得”撕裂”
  • 格式稳定:跨版本升级时兼容新旧格式(字段加法兼容,字段语义变更必须走迁移)

5.4 主备同步

主备架构有两种主流模式:

Active-Passive with Log Shipping

主机把 WAL 条目流式发送给备机,备机应用相同事件序列,得到相同状态。切换时备机接管 Gateway 流量。这是 Nasdaq、Deutsche Börse、CME 等传统交易所使用的模式。

flowchart LR
    G[Gateway] -->|seq序列化| P[Primary ME]
    P -->|WAL| D1[(Local Disk)]
    P -->|Replicate| B[Backup ME]
    B -->|Apply same seq| D2[(Local Disk)]
    P --> M[Market Data]
    B -.standby.-> M

Raft / Paxos 复制

写入 WAL 本身就走共识协议,至少 2/3 副本落盘才返回。一致性更强(无需额外切换协议),但延迟高一档(多一次 RTT)。dYdX v4 链上 CLOB 撮合走 Cosmos SDK + CometBFT(Tendermint),走的就是这条路:撮合逻辑本身跑在多个全节点上,通过 BFT 共识达成一致。

5.5 切换与一致性

主备切换场景下要避免的最大灾难是脑裂下的双撮合。常见手段:

  • Fencing Token:主机启动时从外部协调服务(ZK / etcd)拿到单调递增 epoch,Gateway 只接受带最新 epoch 的主机
  • STONITH:切换前强制旧主下电
  • Gateway 层幂等:下游撮合以 (seq, epoch) 为幂等键,即使暂时双写也不会产生重复成交

六、性能工程:单线程、无锁、缓存友好

6.1 为什么是单线程

撮合引擎的计算非常轻——大部分订单的处理只是几次指针操作、一次树查找、一次链表操作——CPU 几十到几百纳秒就能完成。真正的瓶颈不是 CPU,而是:

  • 锁竞争与 cache-line 争抢
  • 非确定性(多线程排序)
  • 复杂度带来的 bug

所以业界共识是:单 symbol 的撮合必须单线程。多 symbol 通过分片(Sharding)并行:不同 symbol 分到不同撮合线程 / 实例上。BTC/USDT、ETH/USDT 各自跑一个 worker,没有任何跨 symbol 状态。

6.2 LMAX Disruptor 模型

LMAX(伦敦一家多资产交易所)2010 年开源了 Disruptor 模式,把并发队列换成了环形缓冲区 + CAS 序号,在一台机器上做到每秒 600 万 TPS,单次订单处理延迟稳定在微秒级。核心思想:

  • 环形数组预分配,无 GC、无 heap 分配
  • 单生产者多消费者多生产者单消费者,用原子序号协调
  • 消费者链式编排:JournalConsumer(写 WAL)→ ReplicationConsumer(发备机)→ MatchingConsumer(撮合核心)→ OutputConsumer(下游广播),每个阶段单线程,但多个阶段流水线并行
  • Mechanical Sympathy:cache-line padding 避免伪共享(false sharing),紧凑数据布局避免 cache miss

Disruptor 的流水线版本最终形态:

flowchart LR
    GW[Gateway<br/>多生产者] --> RB[Ring Buffer<br/>单一事件序列]
    RB --> J[Journal 线程<br/>WAL]
    RB --> R[Replication 线程<br/>发备机]
    RB --> ME[Matching 线程<br/>核心撮合]
    J -.fence.-> ME
    R -.fence.-> ME
    ME --> OUT[Output 线程<br/>广播]

撮合线程等待 Journal、Replication 都越过了自己要处理的 seq 才处理(通过 fence / barrier 等待),确保”撮合输出的都是已持久化、已复制的订单”。

6.3 缓存友好布局

  • 热路径数据结构尽量连续:PriceLevel 数组化、订单用 slab allocator 连续分配
  • 避免虚函数:订单类型用 enum + switch 而非多态(编译器内联、分支预测更友好)
  • 结构体字段按访问频率排列:热字段放前面落在同一 cache line
  • 避免 UTF-8 字符串:symbol 转为 u32 id,账户转为 u64 id

6.4 分片策略

symbol(合约) 分片是唯一广泛采用的策略。优点是跨分片零通信(订单、撤单、成交都在单一 symbol 内)。缺点是热门 symbol(如 BTC/USDT)单分片压力大,这时要靠垂直扩展(更快的 CPU、RDMA、内核旁路网络)。

按用户分片 不可行,因为一笔订单涉及两个用户(taker/maker),会触发跨分片事务,撮合立即退化成分布式事务。

按价格区间分片 在学术上被讨论过,但工程上没有严肃使用:价格会漂移,区间划分难以稳定。

6.5 低延迟网络栈

撮合引擎之外的延迟优化(与本文主题相关但不展开):

  • 内核旁路:DPDK、Solarflare Onload、ef_vi
  • 用户态 TCP:Seastar、mTCP
  • RDMA / InfiniBand
  • 网卡硬件时间戳、PTP 精确时间同步
  • CPU pinning、隔离核、禁用 HT、禁用 C-States、调低 IRQ coalescing

Nasdaq INET、Deutsche Börse T7、CME Globex 的”机柜到撮合”往返延迟在个位数微秒


七、代码示例:Rust 简化撮合引擎

下面给出一个可运行的 Rust 简化撮合引擎:价格时间优先、支持 Limit/Market/Cancel、单线程。真实引擎要再加 WAL、快照、STP、订单类型、统计、行情分发,但核心循环就是这些。

7.1 数据结构

use std::collections::{BTreeMap, HashMap, VecDeque};

pub type OrderId = u64;
pub type Price = i64;  // 定点价格, 乘以 tick=1e-4 得到实际价格
pub type Qty = u64;
pub type Ts = i64;

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Side { Buy, Sell }

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum OrdType { Limit, Market }

#[derive(Clone, Debug)]
pub struct Order {
    pub id: OrderId,
    pub side: Side,
    pub ord_type: OrdType,
    pub price: Price,   // Market 单忽略
    pub qty: Qty,
    pub account: u64,
    pub ts: Ts,
}

#[derive(Clone, Debug)]
pub struct Trade {
    pub taker: OrderId,
    pub maker: OrderId,
    pub price: Price,
    pub qty: Qty,
    pub ts: Ts,
}

7.2 订单簿

pub struct Book {
    // Buy 用 Reverse(Price) 让最大价在前; 这里用 BTreeMap 正序存, 取 iter().next_back() 得最大
    bids: BTreeMap<Price, VecDeque<OrderId>>,
    asks: BTreeMap<Price, VecDeque<OrderId>>,
    orders: HashMap<OrderId, Order>,  // 全量索引, 便于撤单
}

impl Book {
    pub fn new() -> Self {
        Self { bids: BTreeMap::new(), asks: BTreeMap::new(), orders: HashMap::new() }
    }

    pub fn best_bid(&self) -> Option<Price> { self.bids.keys().next_back().copied() }
    pub fn best_ask(&self) -> Option<Price> { self.asks.keys().next().copied() }

    fn insert(&mut self, o: Order) {
        let side_map = match o.side { Side::Buy => &mut self.bids, Side::Sell => &mut self.asks };
        side_map.entry(o.price).or_default().push_back(o.id);
        self.orders.insert(o.id, o);
    }

    pub fn cancel(&mut self, id: OrderId) -> Option<Order> {
        let o = self.orders.remove(&id)?;
        let side_map = match o.side { Side::Buy => &mut self.bids, Side::Sell => &mut self.asks };
        if let Some(q) = side_map.get_mut(&o.price) {
            if let Some(pos) = q.iter().position(|&x| x == id) {
                q.remove(pos);
            }
            if q.is_empty() { side_map.remove(&o.price); }
        }
        Some(o)
    }
}

生产级实现中,价格层应用双向链表而不是 VecDeque,以支持 O(1) 中间删除;VecDeque remove 是 O(N)。这里为了代码简短用 VecDeque。

7.3 匹配循环

impl Book {
    pub fn place(&mut self, mut order: Order) -> Vec<Trade> {
        let mut trades = Vec::new();
        loop {
            if order.qty == 0 { break; }

            // 取对手方最优价
            let (opp_px, opp_q_front_id) = match order.side {
                Side::Buy => {
                    let Some((&px, q)) = self.asks.iter_mut().next() else { break; };
                    (px, *q.front().unwrap())
                }
                Side::Sell => {
                    let Some((&px, q)) = self.bids.iter_mut().next_back() else { break; };
                    (px, *q.front().unwrap())
                }
            };

            // 是否可成交
            let crosses = match (order.ord_type, order.side) {
                (OrdType::Market, _) => true,
                (OrdType::Limit, Side::Buy)  => order.price >= opp_px,
                (OrdType::Limit, Side::Sell) => order.price <= opp_px,
            };
            if !crosses { break; }

            // 自成交预防 (简化: Cancel Taker)
            let maker = self.orders.get(&opp_q_front_id).unwrap().clone();
            if maker.account == order.account {
                order.qty = 0;  // taker 全撤
                break;
            }

            let fill = order.qty.min(maker.qty);
            trades.push(Trade {
                taker: order.id,
                maker: maker.id,
                price: opp_px,
                qty: fill,
                ts: order.ts,
            });

            order.qty -= fill;

            // 更新 maker
            let maker_remaining = maker.qty - fill;
            if maker_remaining == 0 {
                self.cancel(maker.id);
            } else {
                self.orders.get_mut(&maker.id).unwrap().qty = maker_remaining;
            }
        }

        // 剩余挂簿 (Limit 且有剩余)
        if order.qty > 0 && matches!(order.ord_type, OrdType::Limit) {
            self.insert(order);
        }
        trades
    }
}

7.4 单元测试

#[cfg(test)]
mod tests {
    use super::*;

    fn ord(id: u64, side: Side, t: OrdType, px: Price, qty: Qty, acc: u64) -> Order {
        Order { id, side, ord_type: t, price: px, qty, account: acc, ts: id as i64 }
    }

    #[test]
    fn empty_book_limit_rests() {
        let mut b = Book::new();
        let trades = b.place(ord(1, Side::Buy, OrdType::Limit, 10_000, 100, 1));
        assert!(trades.is_empty());
        assert_eq!(b.best_bid(), Some(10_000));
    }

    #[test]
    fn partial_fill() {
        let mut b = Book::new();
        b.place(ord(1, Side::Sell, OrdType::Limit, 10_000, 100, 1));
        let trades = b.place(ord(2, Side::Buy, OrdType::Limit, 10_000, 60, 2));
        assert_eq!(trades.len(), 1);
        assert_eq!(trades[0].qty, 60);
        assert_eq!(b.best_ask(), Some(10_000));  // maker 剩 40 仍挂着
    }

    #[test]
    fn full_fill() {
        let mut b = Book::new();
        b.place(ord(1, Side::Sell, OrdType::Limit, 10_000, 100, 1));
        let trades = b.place(ord(2, Side::Buy, OrdType::Limit, 10_000, 100, 2));
        assert_eq!(trades.len(), 1);
        assert_eq!(trades[0].qty, 100);
        assert_eq!(b.best_ask(), None);
    }

    #[test]
    fn sweep_multiple_levels() {
        let mut b = Book::new();
        b.place(ord(1, Side::Sell, OrdType::Limit, 10_000, 30, 1));
        b.place(ord(2, Side::Sell, OrdType::Limit, 10_001, 40, 1));
        b.place(ord(3, Side::Sell, OrdType::Limit, 10_002, 50, 1));
        let trades = b.place(ord(4, Side::Buy, OrdType::Limit, 10_002, 100, 2));
        assert_eq!(trades.len(), 3);
        assert_eq!(trades.iter().map(|t| t.qty).sum::<u64>(), 100);  // 30 + 40 + 30
        assert_eq!(b.best_ask(), Some(10_002));  // O3 剩 20 仍挂
    }

    #[test]
    fn self_trade_prevents() {
        let mut b = Book::new();
        b.place(ord(1, Side::Sell, OrdType::Limit, 10_000, 100, 7));
        let trades = b.place(ord(2, Side::Buy, OrdType::Limit, 10_000, 50, 7));
        assert!(trades.is_empty());  // 同账户, taker 被撤
        assert_eq!(b.best_ask(), Some(10_000));  // maker 保留
    }

    #[test]
    fn market_no_counterparty() {
        let mut b = Book::new();
        let trades = b.place(ord(1, Side::Buy, OrdType::Market, 0, 100, 1));
        assert!(trades.is_empty());
        // 市价单不入簿
        assert_eq!(b.best_bid(), None);
    }

    #[test]
    fn cancel() {
        let mut b = Book::new();
        b.place(ord(1, Side::Buy, OrdType::Limit, 10_000, 100, 1));
        let canceled = b.cancel(1);
        assert!(canceled.is_some());
        assert_eq!(b.best_bid(), None);
    }
}

7.5 Go 版骨架

Go 的 BTree 需要引入第三方库(如 google/btree),示例骨架:

type Book struct {
    bids   *btree.BTreeG[*Level]  // 按 -price 排序
    asks   *btree.BTreeG[*Level]  // 按 price 排序
    orders map[uint64]*Order
}

type Level struct {
    price int64
    queue *list.List   // container/list 双向链表, 每个元素是 *Order
    total uint64
}

func (b *Book) Place(o *Order) []Trade {
    var trades []Trade
    for o.Qty > 0 {
        best := b.bestOpposite(o.Side)
        if best == nil || !crosses(o, best) { break }
        e := best.queue.Front()
        m := e.Value.(*Order)
        if m.Account == o.Account {
            o.Qty = 0  // 简化的 STP
            break
        }
        fill := min(o.Qty, m.Qty)
        trades = append(trades, Trade{Taker: o.ID, Maker: m.ID, Price: best.price, Qty: fill})
        o.Qty -= fill
        m.Qty -= fill
        if m.Qty == 0 {
            best.queue.Remove(e)
            delete(b.orders, m.ID)
            if best.queue.Len() == 0 { b.removeLevel(best) }
        }
    }
    if o.Qty > 0 && o.Type == Limit { b.insert(o) }
    return trades
}

八、订单状态机与撮合流程

8.1 订单状态机

stateDiagram-v2
    [*] --> Received: Gateway 接收
    Received --> Validated: 风控 / 资金冻结通过
    Received --> Rejected: 校验 / 资金不足
    Validated --> Accepted: 进入撮合队列
    Accepted --> PartiallyFilled: 部分成交
    Accepted --> Filled: 全部成交
    Accepted --> Canceled: 用户撤单
    Accepted --> Expired: GTD 到期
    PartiallyFilled --> Filled: 继续成交填满
    PartiallyFilled --> Canceled: 用户撤单剩余
    PartiallyFilled --> Expired: GTD 到期
    Filled --> [*]
    Canceled --> [*]
    Rejected --> [*]
    Expired --> [*]

8.2 撮合事件流

sequenceDiagram
    participant C as Client
    participant G as Gateway
    participant W as WAL
    participant ME as Matching
    participant MD as Market Data
    participant R as Risk/Clearing

    C->>G: NewOrder(BTC/USDT, BUY, 10, 65000)
    G->>G: 签名校验 / 限流
    G->>R: 冻结 10*65000 USDT
    R-->>G: OK
    G->>W: Append seq=N (NEW_ORDER)
    W-->>G: persisted
    G->>ME: dispatch by symbol
    ME->>ME: match loop (单线程)
    ME-->>G: Ack + Trades + BookDelta
    ME->>MD: 行情事件
    ME->>R: 成交 → 扣资金 / 加持仓
    G-->>C: Exec Report (8=FIX...)
    MD-->>C: L1/L2/L3 更新

九、开源实现参考

学习撮合引擎最好的方式是读开源代码:

  • liquibook(C++,Object Computing Inc.):最早期的开源 CLOB 撮合参考实现,代码清晰,包含标准订单类型与匹配算法。 https://github.com/enmotech/liquibook
  • exchange-core(Java,Maksim Zheravin):基于 LMAX Disruptor,号称单核百万 TPS 级别,是学习 Disruptor 风格撮合的最好参考。 https://github.com/exchange-core/exchange-core
  • dYdX v4 clob 模块(Go / Cosmos SDK):链上 CLOB 撮合,跑在 Cosmos validator 上,用 CometBFT 做共识。工程复杂度极高,但完整展示了”可验证撮合”的挑战。 https://github.com/dydxprotocol/v4-chain
  • Hummingbot / ccxt-pro:不是撮合引擎本身,但可以作为客户端接入开源撮合做压测。
  • Nasdaq ITCH/OUCH 协议文档:虽然撮合实现闭源,但协议文档公开,可以从”撮合对外输出”反推其内部结构。 https://www.nasdaq.com/solutions/trading-services
  • Jane Street / Optiver 技术博客:若干关于 lock-free 订单簿、数组价格梯、“为什么红黑树比不上数组”的一线分享。

十、工程坑点与选型建议

10.1 真实事件里的撮合教训

  • 2010 年美股闪电崩盘(Flash Crash):一笔巨量 E-mini 卖单触发流动性抽离,几分钟内道琼斯下跌近 1000 点。事后调查揭示 CME 与 NYSE 之间的熔断机制不一致、套利算法在撮合深度瞬间消失时的行为是重要诱因。
  • 2012 年 Knight Capital 事故:部署时旧代码和新代码在不同服务器共存,4 分钟内误发 397 亿美元委托,亏损 4.4 亿美元。与撮合引擎本身无关,但暴露了”撮合输入端”错单放大的可怕。
  • 2013 年光大乌龙指:自营策略程序缺陷,在 2 秒内误下 234 亿元市价买单,其中 72.7 亿元成交。上交所撮合引擎正常运作、保护性机制却不足以拦住”异常但合法”的巨单——此后”异常订单过滤”成为风控前置的硬性要求。
  • 2020 年东京证券交易所 arrowhead 4 宕机:共享硬盘故障加上自动切换逻辑判断错误,导致股票市场整日停摆。撮合引擎本身健康,但主备切换失败。教训是主备切换必须在平时频繁演练,至少每季度一次真实切换。

10.2 坑点清单

  1. 浮点价格是大坑:永远用定点整数(price_int = round(price * 10^decimals))。用 double 的系统都会在某一天遇到 0.1 + 0.2 != 0.3 的成交价问题。
  2. 时间戳单位不统一:ns、µs、ms 混用是常见事故源。全链路规定 ns / int64 最省心。
  3. 撤单和成交的竞态:用户发起撤单、恰好同一事件循环中该单被吃掉——必须按输入顺序处理:如果撤单 seq 在成交输入 seq 之前,撤单成功;否则撤单返回 TOO_LATE。
  4. 市价单深度击穿:无上限的 Market 单在薄簿下会扫光对手方,造成价格跳跃。必须加保护(NBBO、价格偏离阈值、熔断)。
  5. Iceberg 刷新与时间优先冲突:冰山补货必须排到队尾,否则演变成”无限优先级”的作弊手段。
  6. Stop 单风暴:价格触发一个 Stop 转成 Market,Market 又把价格打低,再触发更多 Stop——典型的 cascade。Nasdaq / CME 的”Stop Limit with Protection”、熔断机制就是针对这种情况。
  7. 确定性被打破的隐蔽来源:HashMap 迭代顺序、Go map range、rand、多线程日志、异步 GC——逐一审计。
  8. WAL 文件损坏:断电、文件系统问题、位翻转。每条记录加 CRC32,启动时校验;引入doublewritechecksum per segment
  9. 性能退化:某些价格层变空但结构体未清理、冰山订单堆积、过期 GTD 未清——定期”GC”或惰性清理。
  10. 监控盲区:撮合延迟的尾延迟(P99.9、P99.99)比均值重要得多。交易所 SLA 通常是”99.999% 订单 < X µs”,用直方图(HDR Histogram)记录。

10.3 选型建议

场景 建议
创业加密所、日均 < 10 万单 Go / Rust 单进程 + Postgres WAL 模拟,优先把 STP、订单类型、对账做对,别过度优化
专业加密所、日均百万单 Rust / C++,自建 WAL + 快照,按 symbol 分片,Disruptor 风格线程模型,P99 < 1ms 可达
传统证券 / 期货交易所 闭源商业引擎(Nasdaq INET、Cinnober TRADExpress、FIS MillenniumIT)+ 深度定制;自研团队 50+ 人,3 年以上周期
链上 CLOB(DeFi) 直接基于 Cosmos SDK / dYdX v4 分叉,撮合通过 BFT 共识;延迟档次牺牲,换来可验证性与无 KYC
教学 / 研究 exchange-core、liquibook 源码 + 本文 Rust 示例

10.4 落地清单

  • 价格 / 数量全部定点整数
  • 时间戳 ns / int64,来自 Gateway 且写入 WAL
  • 输入事件全部带单调递增 seq
  • 撮合核心单线程、无系统调用、无堆分配(热路径)
  • 订单簿:BTreeMap/SkipList + HashMap(OrderId→Node) + 双向链表
  • WAL:fsync 每条 or 每 N 条批量,带 CRC,按段轮转
  • 快照:CoW 或子进程,精确 applied_seq
  • 主备:Log Shipping 或 Raft,切换有 fencing token
  • STP:至少支持 Cancel Taker / Cancel Maker / Decrement Both 之一
  • 自动化回放测试:录制生产流量 → 测试环境 byte-identical 复现
  • 延迟直方图(HDR)+ P99 / P99.9 / P99.99 监控
  • 异常订单拦截前置到 Gateway(不进撮合)
  • 主备切换季度演练

十一、SVG:订单簿分层可视化

BTC/USDT 订单簿快照 (L2)

Price Bid Qty Ask Qty Depth Visualization

65,010 3.50

65,008 2.10

65,006 1.80

65,004 0.90

65,002 0.40 Best Ask

Spread = 2 USDT

65,000 0.60 Best Bid

64,998 1.50

64,996 2.80

64,994 3.20

64,992 4.10

每档内部 = 多个订单 FIFO 队列 (L3 中单独展开为独立事件)

十二、若干进阶话题

12.1 基准测试的正确姿势

评估撮合引擎性能时,常见的错误做法:

  • 只测均值:撮合引擎的价值在于尾延迟稳定。用 HDR Histogram 记录 P50/P99/P99.9/P99.99/Max。
  • 热身不足:JIT 预热、CPU 频率调度、分支预测训练需要几十万次迭代才稳定。基准开头 30 秒结果要丢弃。
  • Coordinated Omission:固定间隔发单忽略了”发单线程被阻塞时漏测的请求”。Gil Tene 的 HdrHistogram 有 recordValueWithExpectedInterval 专治。
  • Benchmark 在生产外机器:撮合微秒级,隔壁机器 docker daemon、cgroup 统计都会污染结果。

一个较严肃的测法:

cpupower frequency-set -g performance
taskset -c 3 ./me_bench  # pin 到隔离核
# isolcpus=3,5,7 在内核启动参数里
# irqbalance 停掉, 把所有 IRQ 绑到非撮合核

12.2 撮合引擎与风控的协同

撮合侧不应该做余额 / 仓位校验——那是风控(Pre-Trade Risk Check)的职责。但撮合侧必须做的检查:

  • 订单字段合法性(价格 / 数量 > 0,价格在 tick size 上对齐,数量在 lot size 上对齐)
  • 价格漂移保护(偏离参考价超过阈值直接拒绝)
  • 客户端时间戳与引擎时间差(防重放、防时钟漂移攻击)
  • Kill Switch:风控下发的”全撤 + 拒收”指令作为高优先级输入进入撮合队列

12.3 撮合引擎与配对撤单(Mass Cancel)

一个账户在极端行情下想”全撤”,如果撮合侧是单线程,一次 MassCancel 触发上千个 CANCEL 事件会堵住主循环几毫秒。实现技巧:

  • 把 MassCancel 展开成原子批处理:在主循环内一次遍历账户订单索引,标记为 canceled,但实际从簿里移除可以异步(打 tombstone)
  • 设置账户订单数上限(如单账户不超过 5000 活跃订单),否则 MassCancel 就是慢操作
  • 对做市商 API 提供”快速全撤”的独立接口(bypass 下单队列,直接进入 Kill Switch 通道)

12.4 Maker-Taker 费率与撮合

虽然费率计算通常在撮合之后(Clearing 侧)进行,但 maker/taker 标签是撮合时才能确定的:吃单方是 taker,挂单方是 maker。这个标签必须在 Trade 事件里携带,供清算侧计算不同费率。

某些交易所(如 Binance VIP 做市商项目)还有”负费率”——做市商挂单成交收到 rebate。此时引擎需要把”做市商标识”随 Trade 事件一并带出。

12.5 限频与反熔断

做市商经常”秒内报撤几千次”,这会压垮撮合。应对:

  • API 权重(Binance 模式):每个请求扣权重,账户权重在时间窗口内耗尽即拒
  • 最低挂单寿命:某些交易所要求挂单至少停留 X 毫秒才能撤(防 quote stuffing)
  • 熔断:一秒内单 symbol 成交量 / 价格变化超过阈值,暂停撮合进入冷静期

A 股有涨跌停(±10%、ST ±5%、注册板 ±20%)+ 当日 10% 异动停牌机制;美股用 LULD(Limit Up-Limit Down);加密所用价格带 / 自动去杠杆(ADL)。

12.6 撮合引擎升级:原地升级 vs 蓝绿切换

撮合引擎线上升级是高风险操作。两种主要策略:

Hot-Standby 升级: 1. 备机升级到新版本 2. 用主机 WAL 回放建立状态 3. 业务低峰窗口做主备切换 4. 观察新主机 30 分钟后升级旧主机为新备机

交易时段外停机升级:A 股等有明确盘后窗口的市场可以直接停机升级。

灰度 by symbol:先把小众 symbol 分片切到新版本,观察稳定后再切热门 symbol。但跨版本的行情格式兼容必须提前处理。

12.7 可观测性

撮合引擎的核心指标:

  • 延迟:入口(Gateway 接收)→ 撮合完成 → 出口(Exec Report 回发)每段的 P99/P99.9
  • 队列深度:Ring Buffer 占用率、落后多少
  • 订单簿规模:总订单数、价格层数、内存占用
  • WAL 延迟:Journal 确认到 Match 开始的间隔
  • 主备差:备机 applied_seq 与主机 committed_seq 差额
  • 成交速率:trades/s、volume/s,与日常基线对比触发告警
  • 错误率:校验失败、风控拒绝、STP 触发次数

日志必须结构化(JSON / Protobuf),带 trace_id(客户端请求贯穿)、seq(撮合内部排序)。延迟数据走 HDR Histogram 周期性采样导出到 Prometheus / VictoriaMetrics。


十三、交叉引用与小结

撮合引擎只解决一件事:在给定规则下,从输入订单序列产生确定的成交序列。它小而深:数据结构不超过三百行,但把 WAL、快照、主备、确定性、订单类型、集合竞价、STP 全部加齐后是一个三万行起步的系统。


参考资料

  1. LMAX Disruptor, “Disruptor: High Performance Alternative to Bounded Queues for Exchanging Data Between Concurrent Threads”, https://lmax-exchange.github.io/disruptor/disruptor.html
  2. CME Group, “Matching Algorithms”, https://www.cmegroup.com/confluence/display/EPICSANDBOX/Matching+Algorithms
  3. Nasdaq, “ITCH 5.0 Specification”, https://www.nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/NQTVITCHSpecification.pdf
  4. SEC / CFTC, “Findings Regarding the Market Events of May 6, 2010” (Flash Crash Report), 2010
  5. 上海证券交易所,“交易规则”,http://www.sse.com.cn/
  6. dYdX, “v4 Chain Technical Architecture”, https://docs.dydx.exchange/
  7. Peter Lawrey, “Low Latency in Java”, blog series, https://vanilla-java.github.io/
  8. Martin Thompson, “Mechanical Sympathy”, blog, https://mechanical-sympathy.blogspot.com/
  9. Maksim Zheravin, “exchange-core” source & design notes, https://github.com/exchange-core/exchange-core
  10. OCI, “liquibook”, https://github.com/enmotech/liquibook

上一篇《交易所核心系统架构:撮合、行情、做市、风控、清算》

下一篇《行情分发:MBP/MBO、快照+增量、组播/TCP、FIX/ITCH》

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · architecture / fintech

【金融科技工程】交易所核心系统架构:撮合、行情、做市、风控、清算

从订单网关到撮合引擎、从 L1/L2/L3 行情到清算与结算,系统梳理证券、期货、加密货币交易所的五大核心子系统;给出低延迟工程技术栈(Disruptor、Kernel Bypass、FPGA)、订单生命周期状态机、主流交易所(NYSE Pillar、Nasdaq INET、上交所新一代、CME Globex、Binance、dYdX v4)对比、以及 Flash Crash 与 Knight Capital 的工程教训。

2026-04-22 · architecture / fintech

【金融科技工程】金融科技工程全景:从支付到交易所的系统分类与读图

金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。

2026-04-22 · architecture / fintech

【金融科技工程】幂等、事务与一致性:SAGA、TCC、对账补偿

支付与账务系统里,"这笔操作能不能重放一遍"几乎是每一次故障复盘都会问到的问题。本文从网络重试的本质谈起,讲清楚幂等(idempotency)的三层设计、Idempotency-Key 的工程细节、订单状态机的落库方式,并横向对比 2PC、TCC、SAGA、可靠消息四种分布式事务方案,配合 Outbox Pattern、CDC、补偿策略与真实事故案例,给出一份可以直接落地的检查清单。

2026-04-22 · architecture / fintech

【金融科技工程】09 支付网关设计:路由、限流、补单、异步通知、签名与防重放

从业务系统到支付宝、微信、银联、Visa、Stripe、Airwallex,中间这一层"支付网关"承担了路由、限流、熔断、补单、签名、异步通知分发等几乎所有脏活累活。本文系统整理一个自研或半自研支付网关的工程设计,包括数据模型、双状态机、路由策略、密钥管理与可观测性。