数字资产交易所(下):技术架构与系统设计

目录

交易所架构首先要回答的不是“每秒能撮合多少单”,而是:已经向用户确认的订单,故障后还能否恢复;已经承诺给订单的资金,会不会被另一笔订单再次使用。

上篇梳理了订单、保证金和账务约束。本篇沿一笔现货订单的处理过程,讨论接入、资金预留、撮合、清算、行情和钱包的系统边界。文中的架构与代码用于解释设计,不代表某家交易所的生产实现或性能实测。

一、先定义权威状态,再拆分服务

同一业务可以在多个系统中有副本,但必须明确谁有权改变事实:

对象 权威状态与职责 可异步构建的视图
订单与成交 有序处理命令,决定成交、撤销和剩余数量 订单查询、成交历史
账户与冻结 决定可用资金、预留与分录 账户展示、运营报表
行情 从已确认的市场事件构建快照和增量 K 线、历史深度
充值与提现 记录业务状态及链上交易关联 充提记录查询
配置与权限 保存适用范围、版本和生效点 网关缓存

本文采用“权威账户预留资金,再进入有序撮合,成交事件驱动幂等清算”的拆分方案。也可以把资金与撮合放在同一状态机内;两种方式各有耦合与扩展成本,不能把某一种服务拆分写成唯一解。

一个容易遗漏的阶段是:资金已冻结,但订单尚未确定是否进入撮合。此时需要持久化预留记录和投递状态,反复投递同一订单 ID,直到得到明确接受或拒绝结果。超时不能直接解冻,因为撮合可能已经接受订单。

对于跨交易对复用同一余额的账户,预留要在账户权威端统一串行或原子更新。每个撮合分片各自读取一份余额缓存再扣减,无法避免重复承诺。

二、接入层要处理重试,不只是验签

协议不决定完整链路的延迟

REST、WebSocket 和 FIX 可以承载不同客户的请求与回报,内部也可以使用 gRPC 或其他消息协议。选型取决于客户端生态、会话语义、序列管理和运维能力,不存在脱离网络与实现的“WebSocket 固定 1 毫秒”。

先定义确认语义,再量延迟:

  • 请求接收:网关完成基本解析,尚未承诺接受订单。
  • 订单接受:资金与订单状态已达到约定的持久化边界。
  • 成交回报:撮合结果已确定。
  • 账务可见:相关清算完成,查询能看到该版本。

把第一项的延迟称为“下单到成交”,会掩盖后面排队、日志与结算的成本。

签名、时间窗口和幂等各解决一个问题

HMAC 是一种 API 认证选择,具体服务也可能支持非对称签名。签名内容需按协议明确覆盖请求方法、路径、查询参数或请求体及时间信息,双方必须使用相同的规范化字节序列。

服务端使用安全的比较方式验签,并限制允许的时间偏差与最大有效窗口。时间窗口只能限制旧请求的有效期,不能阻止窗口内重复提交。

交易请求还应携带客户端业务 ID:

  1. 以账户与客户端业务 ID 建立唯一约束。
  2. 保存请求内容摘要及处理状态。
  3. 同 ID、同内容的重试返回已有状态。
  4. 同 ID、不同内容拒绝,避免把旧确认误用于新订单。
  5. 在结果未知时提供查询入口,不建议客户端立即换 ID 再下一单。

身份认证之后仍要校验 API 权限、账户状态、产品与地区权限。验签通过不代表该密钥可以提现。

限流计数不能用时间戳充当唯一成员

Redis 有序集合中的成员唯一;再次添加相同成员会更新其分数,而非增加一条记录。ZADD 文档

因此,把毫秒时间戳同时作为 score 和 member,会把同毫秒请求合并。若采用滑动日志,应以服务端为每次尝试分配的唯一 ID 为 member,以时间为 score,在同一个原子脚本中完成清理、计数、判断与插入。

还需决定:限制的是网络尝试次数还是去重后的业务订单数;Redis 故障时是拒绝、降低本地配额还是允许特定查询。交易、撤单和只读查询可以采用不同策略,不能让批量下单耗尽配额后连撤单都无法执行。

三、撮合需要有序状态变更,不等于全系统单线程

对一个订单簿,单写者事件循环是一种直接的实现:所有会改变其状态的命令进入同一序列,依次应用。这减少了共享可变状态,也便于回放。

单线程不是确定性的充分条件,也不是唯一实现。 如果代码直接读取当前时钟、随机数、可变配置或外部价格,单线程回放仍可能产生不同结果。需要把影响结果的输入及版本纳入序列,固定金额精度、舍入规则和排序规则。

多个订单簿可以并行。低活跃交易对也可以共享一个工作线程,只要每个订单簿始终保持串行所有权;反之,为每个交易对开线程并绑定 CPU,并不会自动提供足够容量。

数据结构应支持真实操作

经典订单簿由价格索引、同价位 FIFO 队列及订单 ID 索引组成。设有 P 个价位、某价位有 K 个订单:

操作 一种常见实现及复杂度
新增价位 平衡树插入,O(log P)
同价位追加 链表尾部追加,O(1)
定位订单 哈希索引,平均 O(1)
移除已定位节点 保留双向链表节点引用时 O(1)
移除空价位 平衡树删除,O(log P)
最优价查询 缓存最优价位时 O(1);不能把该结论直接套给任意树 API

普通 LinkedList.remove(order) 仍需要扫描。跳表的指针跳转也不天然具有更好的缓存局部性,需结合分配方式与工作负载测量。

每次成交不仅更新订单剩余数量,还要减少价位总量;订单耗尽时清理 ID 索引,价位耗尽时清理价格索引。否则查询会出现虚假深度或空的最优价位。

用一个最小例子核对价格与数量

下面的 Python 示例只处理已经通过资金与权限校验的限价买单,对现有卖盘立即撮合并取消余量,相当于限定范围的 IOC 演示。价格、数量均使用整数最小单位;它不实现完整订单管理、STP、费用或持久化。

from collections import deque
from dataclasses import dataclass

@dataclass
class Ask:
    order_id: str
    remaining: int

def match_buy(asks, limit_price, quantity):
    if quantity <= 0 or limit_price <= 0:
        raise ValueError("price and quantity must be positive")
    remaining = quantity
    events = []
    for price in sorted(asks):
        if price > limit_price or remaining == 0:
            break
        level = asks[price]
        while level and remaining:
            maker = level[0]
            filled = min(remaining, maker.remaining)
            remaining -= filled
            maker.remaining -= filled
            events.append((maker.order_id, price, filled))
            if maker.remaining == 0:
                level.popleft()
        if not level:
            del asks[price]
    return tuple(events), remaining

asks = {
    6_501_000: deque([Ask("A", 50), Ask("B", 30)]),
    6_502_000: deque([Ask("C", 100)]),
}
trades, cancelled = match_buy(asks, 6_502_000, 100)
assert trades == (
    ("A", 6_501_000, 50),
    ("B", 6_501_000, 30),
    ("C", 6_502_000, 20),
)
assert cancelled == 0
assert 6_501_000 not in asks
assert asks[6_502_000][0].remaining == 80

这里价格单位为 0.01 USDT,数量单位为 0.01 BTC,因此对应上篇的 1 BTC、均价 65,012 USDT。返回不可变的成交元组,也避免把会在下一次处理时清空的共享列表交给异步消费者。

生产实现还要分别处理 GTC、FOK、Post-Only、市价金额上限等语义。例如 FOK 不能先成交一部分、再发现深度不足;检查与执行应处于同一有序状态边界。STP 则应在实际可能成交的对手单上按规则处理,不能因为用户在对侧挂过任意价格的单就拒绝请求。

Disruptor 只负责进程内协调

使用 Disruptor 时,单生产者指的是只有一个线程发布到 Ring Buffer,不是只有一个撮合消费者。多个 API 线程并发发布时,应使用多生产者配置,或先交给唯一发布者。

预分配槽位可以降低部分分配成本,但业务事件、金额对象和下游消息仍可能分配内存。Busy Spin 会持续占用 CPU,不能在大量线程共享少量核心时盲目使用。Disruptor 用户指南

接入请求经过资金预留、有序日志和持久化确认后应用到撮合状态机,再产生事件

图中展示的是本文选择的提交顺序,Ring Buffer 可以承载其中的进程内阶段,但它本身不提供磁盘持久化、跨机复制或资金事务。

对象池、定点整数和堆外内存也应按测量结果引入。定点乘法要检查溢出;对象复用前要确认下游不再引用;System.nanoTime() 用于本进程耗时测量,不能代替可持久化的业务时间。

四、持久化确认是架构中的一道边界

“写过日志”与“承诺故障后不丢”之间,隔着操作系统缓存、磁盘和副本故障域。

一种保守处理顺序是:排序后的命令写入可恢复日志,满足复制与持久化策略后标记提交,再应用撮合状态并输出确认。也可以提前进行推测计算,但在提交前不能发布不可撤销的成交承诺。

日志与快照需要记录什么

对象 必要信息
命令日志 分片、序列、业务 ID、命令内容、配置版本及校验信息
提交边界 已满足持久化承诺的最大连续序列
快照 同一边界的订单簿、关联状态、版本及最后应用序列
输出事件 可由输入序列和事件序号稳定派生的 ID
下游进度 已处理事件或连续序列,以及对应事务状态

FileChannel.write 可能只写部分数据;实现要循环写完,并区分写入完成与 force 的保证范围。Java FileChannel

批量刷盘可以摊薄开销,但尚未刷盘的批次不能提前宣称满足持久性。还需识别日志尾部截断、校验失败和序列缺口。快照生成要固定状态边界,不能一边修改同一对象一边随意序列化,再把文件名中的序号当成一致性证明。

恢复时加载经过验证的快照,回放其后的已提交命令。回放不能再次广播提现或重复收取费用;输出使用稳定 ID,让下游能够识别重复事件。

恢复时间取决于状态大小、积压命令量、回放速度、校验与下游追平,不能仅凭“五分钟一次快照”承诺三秒恢复。

五、订单状态与资金清算必须一起设计

订单接受后可能直接全部成交,不必先成为公开挂单。部分成交后撤单,只撤销未成交余量;撤单请求被收到,也不等于它在撮合序列中先于成交。

事件顺序 有效结果
先全部成交,后处理撤单 保留成交,撤单不能把成交撤回
先部分成交,后撤单 保留已成交数量,取消余量
先撤单成功,后出现对手盘 被撤余量不能继续参与撮合
接受结果未知,客户端重试 查询或返回同一业务订单,不能新增第二笔

条件单还要记录触发源、触发序列和生成订单 ID。触发、创建订单与投递状态需要可恢复关联;只从内存条件树删掉后再发消息,崩溃可能让订单永久消失。

冻结操作要原子记录金额与业务身份

以下是数据库事务方案的伪代码,省略具体表结构与隔离级别:

开始事务
  创建唯一的 reservation(account_id, order_id),保存请求摘要
  若已存在:核对摘要,返回已有结果
  校验 amount > 0
  原子更新 available -= amount, frozen += amount
    条件:available >= amount,并满足版本或锁约束
  若未更新:回滚,返回余额不足或重试冲突
  写入冻结分录与待投递订单
提交事务

幂等记录、余额更新与分录不能分三次独立提交,否则会出现“钱扣了但记录没写”或“记录存在但钱没扣”。若采用日志驱动状态机,则这些变更应是同一条已提交命令的确定性结果。

成交清算要按资产记账

上篇示例的 0.5 BTC 成交,买方付出 32,532.50 USDT,卖方收到 32,483.75 USDT,平台收取 48.75 USDT。BTC 和 USDT 各自净变动为零。

这里的余额变动示例不等同于完整会计科目表。借贷方向取决于资产、负债与收入科目,不能把“买入 0.5 BTC”和“支出 32,500 USDT”简单列为同币种借贷相等。

最直接的数据库方案是在一个事务中写入成交幂等键、买卖双方分录、费用与冻结调整。账户分片后跨分片交易无法自动获得这个事务边界,需要明确协调与中间清算状态;按用户分别串行,只能解决各自并发,不能单独保证双方原子结算。

如果账务尚未应用某笔成交,新获得资产就不能通过另一条余额缓存提前重复使用。读模型异步并不意味着权威资金约束也可以随意最终一致。

Kafka 不会替外部账本实现恰好一次

Kafka 内部事务可以协调主题输出与消费位点,但消费后写入外部数据库,还需要该数据库配合。可在同一数据库事务中记录已处理事件与分录,提交后再确认消费;重复投递命中幂等记录。Kafka 4.1 消息语义

同理,不能用“先写库、再发 Kafka”冒充原子双写。使用事务 outbox 等方案时,要监控积压并允许重复投递。

六、风控与行情各自需要连续的状态

前置检查不是一份旧余额快照

静态参数可以在网关快速过滤;资金与持仓限制必须回到权威状态校验。买单、卖单、市价单、合约开仓使用不同资金模型,不能一律检查“报价币 ≥ price × quantity”。

价格带需要有效的参考价及过期策略。保证金监控还要处理成交、撤单、资金费用、划转和标记价格更新;它可以通过事件驱动解耦,但必须有延迟上限和积压处置,不能当作无时限的后台分析任务。

强平事件进入与普通订单协调的权威序列,防止旧风险快照触发重复平仓。指数与标记价格规则、保险基金和 ADL 条款应引用具体产品版本,不使用一个未经验证的通用排名公式。

快照与增量必须能够接起来

深度增量不仅要有内容,还应带交易对、序列和对应状态版本。客户端先缓存增量,再获取快照,丢弃快照之前的更新,并从匹配的连续位置开始应用;发现缺口就重新同步。

若协议发送的是价位当前总量,客户端应覆盖该价位;如果发送变化量,才是累加。重复消费对两者的影响不同,协议必须说明。

已提交市场事件同时构建深度和成交视图,客户端用快照接续连续增量,缺口时重新同步

按交易对分区有助于保持分区内顺序,但仍需处理多生产者、分区迁移和事件缺失。低延迟出口也只能发布符合约定确认边界的数据,不能因为绕开 Kafka 就提前发布尚未提交的成交。

K 线按成交时间和序列聚合,去重后计算成交量,明确迟到修正、无成交周期与最终封口语义。只在收到下一笔成交时关闭上一根 K 线,会让无成交市场迟迟没有结束标志。

慢客户端应有有界缓冲、合并或断开重连策略。一个连接阻塞不能拖住整个撮合输出。减少系统调用与内核旁路也要区分:io_uring 仍使用内核接口,不能直接写成与 DPDK 相同的 kernel bypass。

七、钱包需要保留链的差异

充值幂等键不能只有交易哈希

同一笔链上交易可能向多个用户输出资金,或包含多个代币转账事件。Bitcoin RPC 就区分交易中的 vout。gettransaction

因此,充值标识应包含链与具体转账定位:UTXO 可使用 txid 与输出索引,EVM 日志可使用交易哈希与日志索引并校验合约地址;原生币内部转账等情况需要相应的链适配规则。

扫描必须保存游标和区块关联,支持补扫与重组处理;事件已入账后发生重组,要执行明确的差错处置,不能简单删除历史分录。确认策略未知的链应拒绝入账或转人工处理,不能默认“12 个确认”。

提现状态机要覆盖结果未知

提现至少区分已预留、审核中、已构造、已签名、已广播、已确认和待调查等状态。审批对象应绑定目标地址、网络、资产、金额与费用约束;签名前再次校验,防止审批后内容变化。

广播响应丢失时,优先查询或重播同一已签名交易,避免新建一次独立转账。替换交易需关联同一提现业务及原交易,协调 EVM nonce 或 UTXO 锁定,防止并发使用。

链适配可以统一业务接口,但不能抹平链级语义:

差异 对系统设计的影响
账户与 UTXO nonce 管理与输入选择分别实现
费用规则 支持实际链的费用模型,不能统一为 Gas Price
确认与最终性 按链定义状态,不能只依赖一个高度差
代币与原生资产 转账识别、精度、归集和手续费资产不同
批量能力 多输入多输出不代表能把所有 EVM 地址归集合成一笔交易

热、温、冷钱包的资金比例应根据提现需求和风险限额确定。MPC、多签和 HSM 可以组合使用,但“某个私钥没有完整出现”不等于没有单点业务权限风险;签名方仍需独立核验交易意图。

八、故障切换要先证明旧主不能继续写

同一订单簿不能由两个互不协调的实例独立接受命令,再事后合并成交。但多个副本可以在一致序列上执行状态机,多个分片也可以分布在不同机房;“只能一台机器工作”不是由确定性推导出的结论。

故障切换依次隔离旧写入者、确定提交边界、恢复并校验新实例,最后开放流量

选主协议需要保证日志与任期安全,写入口和下游还需拒绝旧任期或失效凭据产生的动作。仅检测不到心跳,不足以证明旧主已经停止。Raft 的安全性依赖提交规则与选举限制,而不只是“多数副本收过日志”。Raft 论文

RPO 必须绑定故障模型

保存方式 能承诺什么
内存中已处理,尚未持久化 进程故障就可能丢失
本地持久化,异步复制 本机磁盘保留时可恢复;主机或磁盘丢失可能丢掉未复制部分
满足约定故障域的持久化复制后确认 在对应故障模型内可争取已确认数据零丢失
快照加连续日志 快照用于缩短恢复,不由快照间隔直接决定 RPO

Kafka 的 acks=all 需要结合 ISR、最小同步副本、选举策略与持久化假设理解,也不能一句话等同于任何事故下 RPO=0。系统的恢复时间还包括发现、隔离、选主、回放、校验和开放流量,各阶段都要实测。

发布也是状态转换

网关可以按请求灰度,但同一订单簿不能随意将 1% 命令送到另一个独立状态实例。撮合升级可先做影子回放,比对输出与状态,再按分片或经过验证的切换边界迁移。

规则和数据模式更新要记录生效序列,并验证新版本能否回放旧日志。存在不兼容迁移时,安排可控维护与业务降级比假定所有变更都能无感热更更可靠。

九、用故障测试决定优化顺序

性能目标需要写清运行环境、订单分布、撤单比例、盘口深度、持久化配置与测量边界。撮合核心耗时、排队耗时、端到端确认和行情延迟分别报告 P50/P95/P99,不能拿纯内存微基准替代完整交易链路。

在调 CPU 绑定或对象池前,先验证这些场景:

场景 应保持的业务不变量
重复下单、响应丢失 同一业务 ID 只对应一个订单
同账户跨交易对并发 总资金承诺不超过可用资源
部分成交与撤单竞争 成交数量加取消余量等于原始数量
日志写入中断与切主 已确认命令可恢复,未提交结果不外泄
消费重放与中途崩溃 分录不重复、余额不漏记
行情丢包 识别序列缺口并重新同步
充值重复扫描、提现广播超时 同一业务效果不重复
规则升级后回放 使用正确版本,输出差异可解释

随后再根据瓶颈调整价格索引、线程分配、批量大小、序列化和存储。语言与中间件帮助实现这些约束,却不能替代它们。

交易所系统的可靠性最终体现在:每个已确认结果都有明确的资金、顺序和持久化依据,故障后也能沿同一份证据恢复。把这些边界设计清楚,性能优化才有不会破坏的基准。

评论