在高频交易、数字货币撮合等对正确性与延迟要求极致的场景中,一个重复的下单或撤单请求,轻则导致用户资产损失与客诉,重则可能引发系统性风险。本文面向资深工程师与架构师,将从分布式系统的第一性原理出发,剖析撮合引擎输入流所面临的重复请求挑战,并层层递进,探讨从基于 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
文字描述这幅架构图:
- 客户端(Trader/Bot)通过 API 或 SDK 发出带有唯一 `ClientOrderID` 的请求。
- 请求经过负载均衡(SLB)到达网关(Gateway)集群。网关负责 TLS 卸载、认证鉴权、协议转换等通用逻辑。
- 网关将合法的业务请求转发给幂等性校验模块。这是我们设计的核心。
- Idempotency Validator 模块接收请求,提取 `UserID` 和 `ClientOrderID` 组成一个唯一的 Key。它会查询一个高速状态存储(State Store),原子地检查该 Key 是否存在。
- 如果 Key 已存在,意味着这是重复请求,Validator 直接丢弃或返回一个“重复订单”的响应。
- 如果 Key 不存在,Validator 原子地将该 Key 写入 State Store 并设置一个过期时间(TTL),然后将请求原封不动地透传给下游。
- 通过校验的请求进入序列器(Sequencer),Sequencer 负责对所有并发请求进行全局排序,确保进入撮合引擎的指令流是严格有序的。
- 最后,序列化的指令流被撮合引擎核心(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。
架构:
- 一个进程内的高性能并发数据结构,如 Java 的 `ConcurrentHashMap` 或 Go 的 `sync.Map`,用于存储请求ID。我们称之为 `In-Memory Set`。
- 所有写入 `In-Memory Set` 的操作,都必须先将该操作(如 `ADD_ID: some_request_id`)同步地、顺序地写入一个本地磁盘文件,即 WAL。
- 当进程重启时,通过回放 WAL 文件来重建 `In-Memory Set` 的完整状态。
- 一个后台线程负责定期清理内存和 WAL 中过期的请求ID。
– 只有当 WAL 写入成功后,才更新内存中的 `In-Memory Set` 并将请求放行给下游。
伪代码实现:
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 方案。这是一个高投入、高风险、高回报的决策,必须有充分的数据支撑和技术储备。
总而言之,撮合引擎的输入流防抖与去重,是一个从基础的幂等性原理,到分布式组件选型,再到高可用与性能优化的综合性工程问题。理解其背后的原理和权衡,并选择与业务阶段相匹配的架构,是每一位系统架构师的必修课。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。