高频交易系统的命门:撮合引擎输入流防抖与去重设计

在高频交易、数字货币撮合等对正确性与延迟要求极致的场景中,一个重复的下单或撤单请求,轻则导致用户资产损失与客诉,重则可能引发系统性风险。本文面向资深工程师与架构师,将从分布式系统的第一性原理出发,剖析撮合引擎输入流所面临的重复请求挑战,并层层递进,探讨从基于 Redis 的简单实现到结合 WAL 与内存计算的极限优化方案,及其在真实工程环境下的架构权衡与演进路径。

现象与问题背景

在理想世界中,客户端发送一次请求,服务端精确地执行一次。但在真实的分布式环境中,这是一个无法被保证的奢望。一个发往撮合引擎的订单请求(Order Request),从客户端到最终被引擎处理,需要经过漫长的链路:客户端SDK、公网、负载均衡器、网关、业务服务。这个链路中的任何一个环节都可能因为网络抖动、设备重启、GC aPuse 等原因导致“超时”,而超时的常规处理策略就是“重试”。

重复请求的根源主要来自以下几个方面:

  • 客户端/SDK 主动重试:客户端在发出请求后,在设定的超时时间内未收到明确的成功或失败响应,为了保证业务成功,它会使用相同的参数重新发起一次请求。这是最常见的重复请求来源。
  • 中间件重试:请求经过的各类代理服务器(如 Nginx)或消息队列(如 Kafka/RocketMQ),在下游服务未能及时确认(ACK)时,可能会触发自身的重发机制。例如,Nginx 的 proxy_next_upstream 配置在遇到 timeout 时会将请求转发到下一个上游实例。
  • TCP 协议层重传:在更底层,TCP 协议本身就包含超时重传机制。虽然应用层通常感知不到,但在极端网络条件下,应用层超时可能已经触发,而底层的TCP报文仍在重传,导致多个应用层请求最终都成功到达了服务端。

对于一个普通的 Web 服务,重复创建一个用户可能只是返回一个“用户已存在”的错误。但对于撮合引擎,一个 `BUY 1 BTC @ 60000 USD` 的订单如果被重复执行两次,意味着用户的购买意图和资金被错误地放大了一倍。这会直接导致账务错误、仓位风险敞口扩大,是交易系统绝对无法容忍的致命缺陷。因此,设计一个高性能、高可用的防抖与去重层,是撮合系统的“命门”所在。

关键原理拆解

要解决重复请求问题,我们需要回到计算机科学的基础原理:幂等性(Idempotency)。在教授的视角下,幂等性是一个数学概念,指一个操作无论执行一次还是执行多次,其产生的结果都是相同的。形式化地描述为 f(x) = f(f(x))

然而,在工程实践中,尤其是对于状态机(State Machine)模型,如撮合引擎的订单簿(Order Book),这个定义需要被更严格地诠释为:一个操作无论在服务端被执行多少次,对系统状态的改变都等同于其仅被成功执行了一次。这背后涉及到三个核心的计算机科学概念:

  • 唯一标识(Unique Identifier):系统必须有能力识别出两个物理上不同的请求在逻辑上是否是“同一个操作”。这要求每一个需要保证幂等性的操作,都必须携带一个由调用方生成的、在一定时间窗口和业务范畴内唯一的标识符,我们通常称之为 `Request ID` 或 `Client Order ID`。这是实现幂等性的基石。
  • 原子性的“检查并设置”(Check-And-Set):服务端在处理请求时,必须有一个原子操作来完成“检查该请求ID是否已处理”和“标记该请求ID为已处理”这两个步骤。如果分两步走,在高并发下会产生经典的 Race Condition,导致两个线程同时通过检查,进而重复执行。这在操作系统层面对应于 `Test-and-Set` 或 `Compare-and-Swap` (CAS) 等原子指令。在分布式环境中,我们则依赖 Redis 的 `SETNX`、ZooKeeper 的节点创建或数据库的唯一键约束来实现。
  • 有界的记忆(Bounded History):系统不可能永久地存储所有处理过的请求ID,这会导致存储无限增长。因此,幂等性保证必须是在一个明确定义的时间窗口内。例如,我们可以承诺“一个小时内的重复请求会被正确处理”。这个窗口的长度需要仔细权衡,它必须大于“请求发出”到“所有可能的重试都已结束”的最大时间。这个窗口的设计,直接关系到内存管理和系统的数据生命周期策略。

本质上,设计一个去重系统,就是在设计一个分布式的、有界的、高性能的、支持原子性写入的状态机,用于记录近期已处理的请求集合。

系统架构总览

一个健壮的撮合系统,其输入流处理架构通常是分层的。幂等性检查模块(我们称之为 Idempotency Validator)应该作为进入核心撮合逻辑前的最后一道防线,紧贴着撮合引擎。其在系统中的位置如下:

请求链路:
Client -> SLB -> Gateway Cluster -> [Idempotency Validator] -> Sequencer -> Matching Engine Core

文字描述这幅架构图:

  1. 客户端(Trader/Bot)通过 API 或 SDK 发出带有唯一 `ClientOrderID` 的请求。
  2. 请求经过负载均衡(SLB)到达网关(Gateway)集群。网关负责 TLS 卸载、认证鉴权、协议转换等通用逻辑。
  3. 网关将合法的业务请求转发给幂等性校验模块。这是我们设计的核心。
  4. Idempotency Validator 模块接收请求,提取 `UserID` 和 `ClientOrderID` 组成一个唯一的 Key。它会查询一个高速状态存储(State Store),原子地检查该 Key 是否存在。
  5. 如果 Key 已存在,意味着这是重复请求,Validator 直接丢弃或返回一个“重复订单”的响应。
  6. 如果 Key 不存在,Validator 原子地将该 Key 写入 State Store 并设置一个过期时间(TTL),然后将请求原封不动地透传给下游。
  7. 通过校验的请求进入序列器(Sequencer),Sequencer 负责对所有并发请求进行全局排序,确保进入撮合引擎的指令流是严格有序的。
  8. 最后,序列化的指令流被撮合引擎核心(Matching Engine Core)消费,进行订单匹配和状态更新。

这里的关键在于 State Store 的选择和实现。它必须具备极低的读取延迟、高吞吐的原子写入能力以及高可用性。

核心模块设计与实现

作为一名极客工程师,我们直接来看代码和工程细节。去重逻辑的核心在于如何高效、原子地实现“检查并设置”。

方案一:基于 Redis 的经典实现

Redis 是这个场景下最常用的工具。它的单线程模型天然保证了命令的原子性,而 `SET` 命令强大的参数组合(`NX` 表示仅在 key 不存在时设置,`PX` 表示以毫秒为单位设置过期时间)使其成为实现分布式锁和幂等性检查的利器。

唯一 Key 的设计:
Key 的设计至关重要,必须包含能唯一标识一次业务操作的所有信息。通常的格式是:
idempotency:{service_name}:{user_id}:{client_order_id}
例如:idempotency:spot_trading:10086:20230401_abc123

这样设计可以避免不同业务、不同用户之间的 key 冲突。

Go 语言实现示例:


package validator

import (
	"context"
	"fmt"
	"time"

	"github.com/go-redis/redis/v8"
)

const (
	// 幂等性记录的保留窗口,例如24小时
	deduplicationWindow = 24 * time.Hour
	redisKeyPrefix      = "idempotency:spot_trading"
)

// RedisStateStore 是基于Redis的状态存储实现
type RedisStateStore struct {
	client *redis.Client
}

// NewRedisStateStore 创建一个新的RedisStateStore实例
func NewRedisStateStore(client *redis.Client) *RedisStateStore {
	return &RedisStateStore{client: client}
}

// CheckAndSet atomically checks for the existence of a key and sets it if not present.
// It returns true if the operation is new, false if it's a duplicate.
func (s *RedisStateStore) CheckAndSet(ctx context.Context, userID, clientOrderID string) (bool, error) {
	// 1. 构造唯一的幂等性 Key
	key := fmt.Sprintf("%s:%s:%s", redisKeyPrefix, userID, clientOrderID)

	// 2. 使用 SET ... NX PX ... 原子命令
	// SET key "1" NX PX 86400000
	// NX: Not Exists, 只有当 key 不存在时才进行设置操作
	// PX: Millisecond-based TTL
	// 这个命令会原子地完成“检查”和“设置”,返回 true 表示设置成功(新请求),返回 false 表示 Key 已存在(重复请求)
	wasSet, err := s.client.SetNX(ctx, key, "1", deduplicationWindow).Result()
	if err != nil {
		// 如果Redis挂了,这里会返回错误。为了系统安全,应倾向于拒绝请求(Fail-fast)
		return false, fmt.Errorf("redis command failed: %w", err)
	}

	// 3. wasSet 为 true,说明是第一次请求
	return wasSet, nil
}

// OrderService 是处理订单的业务服务
type OrderService struct {
	validator *RedisStateStore
	// ... other dependencies
}

// CreateOrder 是处理下单请求的方法
func (svc *OrderService) CreateOrder(ctx context.Context, orderRequest *Order) error {
	isNew, err := svc.validator.CheckAndSet(ctx, orderRequest.UserID, orderRequest.ClientOrderID)
	if err != nil {
		// Redis 故障,记录日志并返回服务不可用
		log.Printf("Idempotency check failed for user %s, order %s: %v", orderRequest.UserID, orderRequest.ClientOrderID, err)
		return ErrServiceUnavailable
	}

	if !isNew {
		// 重复请求,可以直接确认成功或返回特定错误码
		log.Printf("Duplicate order request detected for user %s, order %s", orderRequest.UserID, orderRequest.ClientOrderID)
		return ErrDuplicateOrder
	}

	// 是新请求,继续执行核心的下单逻辑
	// ... processTheOrder(...)
	return nil
}

坑点分析:

  • TTL 的设定deduplicationWindow 必须大于客户端、网关所有环节的超时时间之和,并加上足够的网络延迟冗余。如果设得太短,一个合法的、延迟较高的重试请求可能会被误判为新请求。例如,客户端超时 30 秒,网关超时 10 秒,那么这个窗口至少要设置为几分钟甚至一小时以上才足够安全。
  • Redis 故障:当 Redis 实例本身不可用时,CheckAndSet 会返回错误。此时系统处于“幂等性失效”状态。架构上必须有明确的决策:是选择熔断(fail-fast),拒绝所有请求以保证数据不错乱;还是降级(fail-open),暂时放行所有请求,但事后需要有复杂的数据核对与修复流程。对于交易系统,永远选择 fail-fast

方案二:内存计算 + WAL (Write-Ahead Log)

当延迟要求达到微秒级别时,即使是本地部署的 Redis,其网络栈和上下文切换带来的开销(通常在 100-500 微秒)也可能无法接受。LMAX Disruptor 等顶级交易架构采用纯内存计算来消除网络IO。但内存数据易失,如何保证持久性?答案是 WAL。

架构:

  1. 一个进程内的高性能并发数据结构,如 Java 的 `ConcurrentHashMap` 或 Go 的 `sync.Map`,用于存储请求ID。我们称之为 `In-Memory Set`。
  2. 所有写入 `In-Memory Set` 的操作,都必须先将该操作(如 `ADD_ID: some_request_id`)同步地、顺序地写入一个本地磁盘文件,即 WAL。
  3. – 只有当 WAL 写入成功后,才更新内存中的 `In-Memory Set` 并将请求放行给下游。

  4. 当进程重启时,通过回放 WAL 文件来重建 `In-Memory Set` 的完整状态。
  5. 一个后台线程负责定期清理内存和 WAL 中过期的请求ID。

伪代码实现:


inMemorySet = new ConcurrentHashMap(); // Key -> ExpiryTimestamp
walFile = openFile("dedupe.wal", APPEND_MODE);

function checkAndSet(requestID) {
    // 1. 优先检查内存,这是一个无锁或低锁的快速路径
    if (inMemorySet.containsKey(requestID)) {
        return DUPLICATE;
    }

    // 2. 构造 WAL 日志条目
    logEntry = "ADD," + requestID + "," + calculateExpiry();

    // 3. 同步刷盘WAL,这是最关键的瓶颈和保障
    // 必须确保数据落到物理磁盘,而不仅仅是OS page cache
    walFile.write(logEntry);
    walFile.fsync(); // Force flush to disk

    // 4. WAL 写入成功后,更新内存
    inMemorySet.put(requestID, calculateExpiry());

    return NEW_REQUEST;
}

坑点与权衡:

  • 性能瓶颈:这个方案的瓶颈从网络 IO 转移到了磁盘 IO。为了极致性能,需要使用专门为低延迟优化的文件系统和硬件(如 NVMe SSD),并可能采用 `mmap` 等高级 IO 技术。即便如此,`fsync` 的调用仍然是一个不可忽视的开销。
  • WAL 文件管理:WAL 文件会无限增长,必须有配套的快照(Snapshot)和压缩(Compaction)机制。例如,每小时生成一个内存快照,并清理掉比快照更早的 WAL 文件。这大大增加了系统的复杂性。
  • 高可用:这是单点方案。要做到高可用,需要主备复制。主节点不仅要处理业务,还要通过网络将 WAL 流实时同步给备用节点,备用节点同样在内存中应用这些日志。这本质上是在手写一个简化版的数据库主从复制协议。

性能优化与高可用设计

Trade-off 分析:Redis vs. In-Memory+WAL

  • 延迟:In-Memory+WAL 方案胜出。它可以将延迟控制在 10 微秒以下(取决于 `fsync`),而 Redis 方案通常在 100 微秒以上。
  • 吞吐量:取决于瓶颈。Redis 方案的瓶颈在 Redis Server 的单核处理能力和网络带宽。In-Memory+WAL 的瓶颈在本地磁盘的 IOPS。对于现代服务器,两者都能做到很高的吞吐,但 WAL 方案更容易通过绑定特定 CPU 和 IO 设备来消除干扰。
  • 实现复杂度:Redis 方案胜出。它利用了成熟的外部组件,代码简单,易于维护。In-Memory+WAL 方案几乎是在手造一个存储引擎,复杂度呈指数级增长。
  • 运维成本:Redis 方案胜出。Redis 有成熟的集群(Redis Cluster)、哨兵(Sentinel)方案和丰富的监控工具。WAL 方案的所有运维(备份、恢复、监控、故障切换)都需要自研。

结论是:对于绝大多数交易系统(99%),优化良好的 Redis 集群方案已经足够。只有在追求纳秒级竞争优势的自营高频交易(Proprietary HFT)领域,In-Memory+WAL 方案的投入产出比才可能是合理的。

高可用设计

幂等性校验模块是关键路径上的单点,必须保证高可用。

  • 对于 Redis 方案
    • 使用 Redis Sentinel(哨兵)模式实现主备自动切换。客户端需要连接到 Sentinel 来发现当前的 Master 节点。
    • 或者使用 Redis Cluster 模式。数据按 Key 哈希分片到多个 Master 节点,每个 Master 都有自己的 Slave。这不仅提供了高可用,还实现了水平扩展。
  • 对于 In-Memory+WAL 方案
    • 采用主备(Primary-Backup)模式。主节点接收所有写请求,并将 WAL 实时同步到备节点。
    • 需要一个类似 ZooKeeper 或 etcd 的协调服务来进行选主和脑裂(Split-Brain)裁决。
    • – 故障切换(Failover)逻辑需要精心设计,确保备节点在接管前已经应用了所有来自旧主节点的 WAL 日志,避免数据丢失。

架构演进与落地路径

一个复杂系统并非一蹴而就。正确的路径是根据业务发展阶段,逐步演进架构。

第一阶段:单点 Redis 快速启动

在系统初期,交易量不大,对可用性要求不是三个九或四个九。此时,最简单有效的方案就是将幂等性逻辑直接嵌入在网关或业务应用中,后端连接一个单点的 Redis 实例。这个阶段的目标是快速验证业务逻辑,并确保核心功能的正确性。运维上做好 Redis 的数据备份(RDB/AOF)。

第二阶段:高可用的 Redis 集群

随着业务量的增长和对SLA要求的提高,单点 Redis 成为瓶颈和风险点。此时需要将 Redis 升级为高可用架构。可以选择 Redis Sentinel 或 Redis Cluster。同时,将幂等性校验逻辑从业务代码中剥离,抽象成一个独立的、可复用的中间件或微服务,供所有需要幂等性的入口调用。这个阶段的重点是提升系统的健壮性和水平扩展能力。

第三阶段:性能极限压榨(可选)

如果业务进入了延迟极其敏感的领域(如外汇做市、期货高频套利),并且 Redis 已经成为瓶颈,那么可以考虑向 In-Memory+WAL 方案演进。这通常意味着组建一个专门的底层架构团队。实施策略上,可以采用灰度发布,让一小部分对延迟最敏感的流量(如做市商的 API)先切到新的内存方案上,而普通用户的流量继续走 Redis 方案。这是一个高投入、高风险、高回报的决策,必须有充分的数据支撑和技术储备。

总而言之,撮合引擎的输入流防抖与去重,是一个从基础的幂等性原理,到分布式组件选型,再到高可用与性能优化的综合性工程问题。理解其背后的原理和权衡,并选择与业务阶段相匹配的架构,是每一位系统架构师的必修课。

延伸阅读与相关资源

  • 想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
    交易系统整体解决方案
  • 如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
    产品与服务
    中关于交易系统搭建与定制开发的介绍。
  • 需要针对现有架构做评估、重构或从零规划,可以通过
    联系我们
    和架构顾问沟通细节,获取定制化的技术方案建议。
滚动至顶部