第 28 章:证券交易所
简介
本章设计一个电子证券交易所。
其基本功能是高效地撮合买家与卖家。
主要交易所包括纽约证券交易所(NYSE)、**纳斯达克(NASDAQ)**等。
<img src="/images/chapter-28/world-stock-exchanges.png" alt="全球证券交易所" width="500" />
第一步:理解问题并确定设计范围
- 候选人:交易哪些证券?股票、期权还是期货?
- 面试官:为简化问题,只交易股票。
- 候选人:支持哪些订单操作——下单、撤单、改单?订单类型包括限价单、市价单和条件单吗?
- 面试官:支持下单和撤单;订单类型只考虑限价单。
- 候选人:需要支持盘后交易吗?
- 面试官:不需要,只考虑正常交易时段。
- 候选人:交易所的基本功能是什么?
- 面试官:客户可以提交或取消限价单,实时收到撮合成交结果,并实时查看订单簿。
- 候选人:交易所的规模如何?
- 面试官:数万名用户同时交易,约 100 个交易标的,每天有数十亿笔订单。还需要合规风险检查。
- 候选人:需要哪种风险检查?
- 面试官:简单的检查即可,例如限制某用户每天最多交易 100 万股苹果公司股票。
- 候选人:用户钱包如何参与?
- 面试官:下单前要确认客户资金充足;待成交订单所需资金必须冻结,直到订单处理完毕。
非功能需求
上述规模表明要设计的是中小型交易所,同时也应便于未来支持更多交易标的和用户。
其他非功能需求:
- **可用性:**至少 99.99%;停机可能损害声誉。
- **容错性:**需要容错与快速恢复机制,限制生产事故的影响。
- **延迟:**往返延迟应达到毫秒级,重点关注第 99 百分位延迟。其长期偏高会损害部分用户体验。
- **安全性:**提供账户管理,并通过 KYC 验证用户身份以满足法律要求;对公开资源实施 DDoS 防护。
粗略估算
- 100 个交易标的,每天 10 亿笔订单。
- 正常交易时段为 09:30 至 16:00,共 6.5 小时。
- QPS = 10 亿 / 6.5 / 3600 ≈ 43,000。
- 峰值 QPS = 5 × QPS ≈ 215,000。
- 开市时的交易量明显更高。
第二步:提出概要设计并达成共识
交易基础知识
先介绍与交易所相关的基本概念。
经纪商在交易所与终端用户之间提供中介服务,例如 Robinhood、Fidelity。
机构客户使用专用交易软件进行大额交易,需要特殊处理。例如,将大额订单拆分,以免冲击市场。
订单类型:
- **限价单:**指定固定价格买入或卖出;可能不能立即成交,也可能部分成交。
- **市价单:**不指定价格,按当前市场价格立即执行。
价格:
- **买价(Bid):**买家愿意买入股票的最高价格。
- **卖价(Ask):**卖家愿意卖出股票的最低价格。
美国市场的行情报价分为 L1、L2、L3 三个层级。
L1 行情包括最优买卖价及相应数量:
<img src="/images/chapter-28/l1-price.png" alt="L1 行情" width="500" />
L2 展示更多价格档位:
<img src="/images/chapter-28/l2-price.png" alt="L2 行情" width="500" />
L3 展示各档价格及该档位排队的数量:
<img src="/images/chapter-28/l3-price.png" alt="L3 行情" width="500" />
K 线展示指定时间区间内的开盘价、收盘价、最高价和最低价:
<img src="/images/chapter-28/candlestick.png" alt="K 线" width="500" />
FIX 是多数供应商使用的证券交易信息交换协议。以下是一条交易消息示例:
8=FIX.4.2 | 9=176 | 35=8 | 49=PHLX | 56=PERS | 52=20071123-05:30:00.000 | 11=ATOMNOCCC9990900 | 20=3 | 150=E | 39=E | 55=MSFT | 167=CS | 54=1 | 38=15 | 40=2 | 44=15 | 58=PHLX EQUITY TESTING | 59=0 | 47=C | 32=0 | 31=0 | 151=15 | 14=0 | 6=0 | 10=128 |概要设计
<img src="/images/chapter-28/high-level-design.png" alt="概要设计" width="500" />
交易流程:
- 客户通过交易界面下单。
- 经纪商将订单发送给交易所。
- 订单经客户端网关进入交易所;网关进行验证、限流、身份认证等,再转发给订单管理器。
- 订单管理器根据风险管理器设定的规则进行风险检查。
- 通过风险检查后,订单管理器确认钱包中有足够资金。
- 订单送至撮合引擎。撮合成功后,引擎分别为买卖双方产生一条执行记录(成交回报,fill)。订单按顺序处理,确保结果确定。
- 执行结果返回客户。
市场数据流程(M1–M3):
- 撮合引擎产生执行结果流,发送给市场数据发布器。
- 发布器构建 K 线图,并将其发送给数据服务。
- 市场数据保存在支持实时分析的专用存储中;经纪商连接数据服务以获取及时行情。
报表流程(R1–R2):
- 报表服务从订单和执行记录中收集所需字段并写入数据库。
- 报表字段包括
client_id、价格、数量、订单类型、成交数量和剩余数量。
交易流程位于关键路径,其他流程则不在关键路径上,因此它们的延迟要求不同。
交易流程
交易流程位于关键路径,必须为低延迟充分优化。
核心是撮合引擎(也称交叉撮合引擎),主要负责:
- 维护每个交易标的的订单簿,即该标的买卖订单的列表。
- 撮合买卖订单;每次撮合分别为买方和卖方产生一条执行记录。该操作必须快速且准确。
- 将执行结果流分发为市场数据。
- 按确定的顺序产生撮合结果,为高可用性奠定基础。
接下来是序列器。它为每个入站订单和出站成交回报标记序列 ID,使撮合引擎具有确定性。
<img src="/images/chapter-28/sequencer.png" alt="序列器" width="500" />
为订单与成交回报编号的原因:
- 保证及时性与公平性;
- 支持快速恢复与重放;
- 提供恰好一次处理保证。
概念上可使用 Kafka 作为序列器,因为它相当于入站与出站消息队列;但为了降低延迟,这里自行实现。
订单管理器维护订单状态,并与撮合引擎交互:发送订单、接收成交回报。
订单管理器的职责:
- 将订单送去进行风险检查,例如确认用户交易量小于 100 万股。
- 检查用户钱包是否有足够资金执行订单。
- 将订单送入序列器,再交给撮合引擎;只传递撮合必需的信息,以减少带宽使用。
- 从序列器接收执行结果,再经客户端网关发送给经纪商。
实现订单管理器的主要挑战是状态转换管理。事件溯源是可行方案之一,下文会详细讨论。
最后,客户端网关接收用户订单并发送给订单管理器。其职责如下:
<img src="/images/chapter-28/client-gateway.png" alt="客户端网关" width="500" />
由于客户端网关在关键路径上,应保持轻量。
不同客户可使用不同的客户端网关。例如,托管交易服务器是经纪商租用、部署在交易所数据中心的交易引擎服务器:
<img src="/images/chapter-28/client-gateways.png" alt="客户端网关部署" width="500" />
市场数据流程
市场数据发布器从撮合引擎接收执行结果流,并据此重建订单簿和 K 线图。
数据随后发送给数据服务,由它向订阅者展示聚合结果:
<img src="/images/chapter-28/market-data.png" alt="市场数据" width="500" />
报表流程
报表服务不在关键路径上,但仍是重要组件。
<img src="/images/chapter-28/reporting-flow.png" alt="报表流程" width="500" />
它负责交易历史、税务报告、合规报告和结算等。报表流程对延迟的要求较低,更看重准确性与合规性。
API 设计
客户通过经纪商与交易所交互,完成下单、查看成交记录和市场数据、下载历史数据进行分析等操作。
客户端网关与经纪商之间使用 RESTful API 通信。
机构客户则使用专有协议,以满足低延迟需求。
创建订单:
POST /v1/order参数:
symbol:股票代码,字符串。side:买入或卖出,字符串。price:限价单价格,长整数。orderType:限价单或市价单(本设计只支持限价单),字符串。quantity:订单数量,长整数。
响应:
id:订单 ID,长整数。creationTime:系统创建订单的时间,长整数。filledQuantity:已成交数量,长整数。remainingQuantity:待成交数量,长整数。status:新建、已取消或已完全成交,字符串。- 其他属性与请求参数相同。
获取执行记录:
GET /execution?symbol={:symbol}&orderId={:orderId}&startTime={:startTime}&endTime={:endTime}参数:
symbol:股票代码,字符串。orderId:订单 ID,可选,字符串。startTime:查询开始时间,纪元时间 [11],长整数。endTime:查询结束时间,纪元时间,长整数。
响应:
executions:查询范围内执行记录的数组(属性见下)。id:执行记录 ID,长整数。orderId:订单 ID,长整数。symbol:股票代码,字符串。side:买入或卖出,字符串。price:成交价格,长整数。orderType:限价单或市价单,字符串。quantity:成交数量,长整数。
获取订单簿:
GET /marketdata/orderBook/L2?symbol={:symbol}&depth={:depth}参数:
symbol:股票代码,字符串。depth:每侧订单簿的深度,整数。
响应:
bids:包含价格和数量的买盘数组。asks:包含价格和数量的卖盘数组。
获取 K 线:
GET /marketdata/candles?symbol={:symbol}&resolution={:resolution}&startTime={:startTime}&endTime={:endTime}参数:
symbol:股票代码,字符串。resolution:K 线时间窗口长度,单位为秒,长整数。startTime:窗口开始的纪元时间,长整数。endTime:窗口结束的纪元时间,长整数。
响应:
candles:K 线数据数组(每条记录的属性见下)。open:开盘价,双精度浮点数。close:收盘价,双精度浮点数。high:最高价,双精度浮点数。low:最低价,双精度浮点数。
数据模型
交易所主要有三类数据:
- 产品、订单和执行记录;
- 订单簿;
- K 线图。
产品、订单和执行记录
产品描述交易标的属性,例如产品类型、交易代码和界面显示代码。
这类数据变化不频繁,主要用于界面展示。
订单代表买卖指令,执行记录代表撮合后输出的结果。
数据模型如下:
<img src="/images/chapter-28/product-order-execution-data-model.png" alt="产品、订单和执行记录数据模型" width="500" />
三个流程都会使用订单和执行记录:
- 在关键路径中,它们在内存中处理以获得高性能,并由序列器保存及恢复。
- 报表服务将它们写入数据库,用于生成报告。
- 执行记录传给市场数据服务,用于重建订单簿和 K 线图。
订单簿
订单簿是某个交易标的的买卖订单列表,按价格档位组织。
高效的数据结构需要满足:
- 常数时间查找,例如查询某价格档位或档位区间的成交量;
- 快速新增、执行与取消订单;
- 查询最优买价和卖价;
- 遍历价格档位。
订单簿撮合示例:
<img src="/images/chapter-28/order-book-execution.png" alt="订单簿撮合" width="500" />
这笔大额订单成交后,买卖价差扩大,价格上涨。
订单簿实现伪代码:
class PriceLevel{
private Price limitPrice;
private long totalVolume;
private List<Order> orders;
}
class Book<Side> {
private Side side;
private Map<Price, PriceLevel> limitMap;
}
class OrderBook {
private Book<Buy> buyBook;
private Book<Sell> sellBook;
private PriceLevel bestBid;
private PriceLevel bestOffer;
private Map<OrderID, Order> orderMap;
}可用双向链表替代普通列表,进一步提高效率:
新订单加入链表尾部,时间复杂度为 O(1)。
撮合时从链表头部删除订单,时间复杂度为 O(1)。
撤单需要从订单簿中删除订单;利用
orderMap可在 O(1) 时间找到订单,而Order持有前驱节点引用,也可在 O(1) 时间删除。
市场数据服务也使用该数据结构重建订单簿。
K 线图
市场数据服务根据一个时间区间内的订单处理结果计算 K 线:
class Candlestick {
private long openPrice;
private long closePrice;
private long highPrice;
private long lowPrice;
private long volume;
private long timestamp;
private int interval;
}
class CandlestickChart {
private LinkedList<Candlestick> sticks;
}为避免内存占用过高,可采用:
- 预分配环形缓冲区保存 K 线,减少内存分配次数。
- 限制内存中的 K 线数量,将其余数据持久化到磁盘。
使用内存列式数据库(如 KDB)进行实时分析;收市后将数据保存到历史数据库。
第三步:详细设计
现代交易所与多数软件系统不同,有些会将几乎所有组件运行在一台大型服务器上。
下面分析其中的细节。
性能
交易所需要在所有延迟百分位上都保持良好表现。
降低延迟的方法:
- 减少关键路径上的任务数量。
- 减少每项任务的耗时,包括减少网络和磁盘使用,或缩短任务执行时间。
为实现第一点,关键路径上移除所有非必要职责;甚至连日志记录也移除,以达到最佳延迟。
若沿用初始设计,服务间网络延迟以及序列器磁盘访问都会成为瓶颈。
初始设计的端到端延迟可达数十毫秒,但目标是数十微秒。
因此将组件放在同一台服务器上,并使用 mmap 作为事件存储,让各进程通信:
<img src="/images/chapter-28/mmap-bus.png" alt="mmap 事件总线" width="500" />
另一项优化是使用应用循环,即持续执行关键任务的 while 循环,并将其固定在同一个 CPU 上,避免上下文切换:
<img src="/images/chapter-28/application-loop.png" alt="应用循环" width="500" />
应用循环还能避免多个线程争夺相同资源造成的锁竞争。
mmap 是 UNIX 系统调用,可把磁盘文件映射到应用内存。
一种技巧是在代表共享内存的 /dev/shm 中创建文件,这样就完全不需要磁盘访问。
事件溯源
事件溯源已在数字钱包一章详细讨论,可参阅该章了解完整背景。
简言之,系统保存不可变的状态转换,而不是仅保存当前状态:
<img src="/images/chapter-28/event-sourcing.png" alt="事件溯源" width="500" />
- 左侧是传统模式。
- 右侧是事件溯源模式。
目前设计如下:
<img src="/images/chapter-28/design-so-far.png" alt="当前设计" width="500" />
- 外部系统通过 FIX 协议与客户端网关交互。
- 订单管理器收到新订单事件后进行验证,并加入内部状态,再将订单送到撮合核心。
- 若订单成交,就生成
OrderFilledEvent,并通过mmap发送。 - 其他组件订阅事件存储,分别完成各自的处理。
另一项优化是:所有组件都持有以库形式打包的订单管理器副本,减少订单管理相关的额外调用。
在这一设计中,序列器不再充当事件存储,而是作为单写入者,在将事件转发到事件存储前为其编序:
<img src="/images/chapter-28/sequencer-deep-dive.png" alt="序列器详解" width="500" />
高可用性
目标可用性为 99.99%,相当于每天最多停机 8.64 秒。
为达到目标,需要找出架构中的单点故障:
- 为撮合引擎等关键服务配置处于待命状态的备用实例。
- 尽量自动化故障检测与向备用实例的切换。
客户端网关等无状态服务可通过增加服务器轻松水平扩容。
对于有状态组件,非领导者副本可以处理入站事件,但不发布出站事件:
<img src="/images/chapter-28/leader-election.png" alt="领导者选举" width="500" />
可发送心跳检测主副本是否停止工作。
这种机制只在单台服务器内部有效。若要扩展到服务器级别,可配置整台服务器作为热备或温备,在故障时切换。
为跨副本复制事件存储,可使用可靠 UDP 加快通信。
容错性
如果温备实例也故障怎么办?虽然概率较低,仍须有所准备。
大型科技公司会把核心数据复制到多个城市的数据中心,以减轻自然灾害等影响。
需要考虑:
- 主实例故障后,何时以及如何切换到备用实例?
- 如何从备用实例中选出领导者?
- 所需的恢复时间是多少(RTO,恢复时间目标)?
- 哪些功能必须恢复?系统能否降级运行?
应对方法:
- Bug 可能同时影响主实例和副本;可通过混沌工程暴露此类边界情况与严重后果。
- 在充分了解系统故障模式之前,初期可手动执行故障切换。
- 主实例故障时可用领导者选举(如 Raft)确定接任副本。
跨服务器复制示例:
<img src="/images/chapter-28/replication-across-servers.png" alt="跨服务器复制" width="500" />
领导者选举任期示例:
<img src="/images/chapter-28/leader-election-terms.png" alt="领导者选举任期" width="500" />
有关 Raft 工作原理的细节,可参阅此文。
最后,还要考虑数据丢失容忍度:在造成严重后果之前,最多能丢失多少数据?答案决定备份频率。
证券交易所不能接受数据丢失,因此必须频繁备份,并依靠 Raft 复制降低丢失概率。
撮合算法
以下伪代码简要展示撮合过程:
Context handleOrder(OrderBook orderBook, OrderEvent orderEvent) {
if (orderEvent.getSequenceId() != nextSequence) {
return Error(OUT_OF_ORDER, nextSequence);
}
if (!validateOrder(symbol, price, quantity)) {
return ERROR(INVALID_ORDER, orderEvent);
}
Order order = createOrderFromEvent(orderEvent);
switch (msgType):
case NEW:
return handleNew(orderBook, order);
case CANCEL:
return handleCancel(orderBook, order);
default:
return ERROR(INVALID_MSG_TYPE, msgType);
}
Context handleNew(OrderBook orderBook, Order order) {
if (BUY.equals(order.side)) {
return match(orderBook.sellBook, order);
} else {
return match(orderBook.buyBook, order);
}
}
Context handleCancel(OrderBook orderBook, Order order) {
if (!orderBook.orderMap.contains(order.orderId)) {
return ERROR(CANNOT_CANCEL_ALREADY_MATCHED, order);
}
removeOrder(order);
setOrderStatus(order, CANCELED);
return SUCCESS(CANCEL_SUCCESS, order);
}
Context match(OrderBook book, Order order) {
Quantity leavesQuantity = order.quantity - order.matchedQuantity;
Iterator<Order> limitIter = book.limitMap.get(order.price).orders;
while (limitIter.hasNext() && leavesQuantity > 0) {
Quantity matched = min(limitIter.next.quantity, order.quantity);
order.matchedQuantity += matched;
leavesQuantity = order.quantity - order.matchedQuantity;
remove(limitIter.next);
generateMatchedFill();
}
return SUCCESS(MATCH_SUCCESS, order);
}该撮合算法采用 FIFO 原则,确定同一价格档位上的订单成交顺序。
确定性
前述序列器技术保证功能上的确定性。
事件实际发生的时间并不影响结果:
<img src="/images/chapter-28/determinism.png" alt="确定性" width="500" />
延迟的确定性也需监控,可通过第 99 或第 99.99 百分位延迟衡量。
例如,Java 垃圾回收事件可能引起延迟尖峰。
市场数据发布器优化
市场数据发布器接收撮合引擎的成交结果,并据此重建订单簿和 K 线图。
内存有限,因此只保留部分 K 线。客户可选择所需数据的粒度;更精细的数据可能需要支付更高费用:
<img src="/images/chapter-28/market-data-publisher.png" alt="市场数据发布器" width="500" />
环形缓冲区是首尾相接的固定大小队列。预分配空间可避免运行时分配,且该数据结构可实现无锁操作。
另一项优化是填充(padding),确保序列号不与其他数据共用缓存行。
市场数据分发公平性与多播
应确保订阅者同时收到数据;否则,先收到数据的一方会获得关键市场信息,并可能利用它操纵市场。
因此,可使用可靠 UDP 多播向订阅者发布数据。
互联网中的数据传输有三种方式:
- **单播:**一个源到一个目的地。
- **广播:**一个源到整个子网。
- **多播:**一个源到不同子网中的一组主机。
理论上,多播能让所有订阅者同时收到数据。
但 UDP 本身不可靠,数据可能无法送达所有人;可以通过重传改进。
同地部署
交易所允许经纪商将服务器部署在交易所所在的数据中心。
这能显著降低延迟,可视为高级服务。
网络安全
交易所的部分服务面向互联网,因此 DDoS 是一项挑战。可采取:
- 隔离公开服务和数据与私有服务,避免 DDoS 攻击影响最重要的客户。
- 用缓存层保存不经常更新的数据。
- 设计更易缓存的 URL 以增强 DDoS 防护,例如优先使用
https://my.website.com/data/recent,而非https://my.website.com/data?from=123&to=456。 - 建立有效的允许名单和阻止名单机制。
- 使用限流减轻 DDoS 攻击影响。
第四步:总结
其他值得注意的点:
- 并非所有交易所都把所有组件放在一台大型服务器上,但仍有一些这样做。
- 现代交易所越来越多地使用云基础设施,也使用自动做市商(AMM)以免维护订单簿。