高性能交易系统核心:成交明细(Fill)的合并、存储与查询架构设计

在股票、期货或数字货币等高频交易系统中,成交明细(Fill)是记录系统行为最核心、最高频的数据之一。一个繁忙的交易对每秒可产生数千甚至数万笔成交。如何高效处理这股数据洪流,在保证数据准确性的前提下,实现低延迟入库、高效率查询并控制存储成本,是衡量一个交易系统后端架构水平的关键指标。本文将从底层原理到工程实践,系统性剖析一套高性能、可演进的成交明细处理架构,为面临类似挑战的技术负责人和高级工程师提供一份可落地的设计蓝图。

现象与问题背景

当一个订单在撮合引擎中被部分或完全成交时,就会产生一笔或多笔成交明-细(Fill)。一个典型的 Fill 数据结构通常包含成交ID、订单ID、用户ID、交易对、价格、数量、方向、时间戳等关键信息。在系统初期,最直观的设计是将每一笔 Fill 直接写入关系型数据库(如 MySQL)。

随着交易量的增长,这种简单粗暴的方式会迅速暴露出一系列问题:

  • 存储爆炸:海量的、结构高度相似的小记录会迅速撑爆磁盘。例如,一笔 Fill 记录可能占用 128 字节,每日 10 亿笔成交就会产生超过 100GB 的原始数据,一个月就是数 TB,存储成本和管理难度剧增。
  • 数据库写入瓶颈:高频的 `INSERT` 操作对数据库是巨大的压力。每一笔写入都可能涉及日志刷新(WAL)、索引更新(尤其是 B+ Tree 的页面分裂)、网络往返。很快,数据库的 IOPS 和连接数会成为整个系统的瓶颈。

  • 查询性能雪崩:针对用户的查询,如“查询我今天在 BTC/USDT 上的所有成交记录”或“汇总我的总成交额与平均成交价”,需要扫描大量细碎的行,即使有索引,性能也随数据量增长而线性下降。生成月度账单这类聚合查询更是可能引发数据库长时间的慢查询,影响线上服务。
  • 账单与清算复杂:下游的账单、清算系统需要基于原始 Fill 进行聚合运算。如果直接从生产库读取,会加剧其负载;如果进行数据同步(ETL),又会引入延迟和一致性问题。

问题的本质在于,原始的、离散的成交明细流与后端存储系统、查询系统的处理模型之间存在根本性的不匹配。前者是事件流(Event Stream),而后者(尤其是传统 RDBMS)更擅长处理状态聚合后的记录。因此,架构设计的核心目标,就是在两者之间构建一个高效的桥梁。

关键原理拆解

在设计解决方案之前,我们必须回归计算机科学的基础原理。理解这些原理,能让我们做出更合理、更具前瞻性的架构决策。

(学术风)

1. 时间与空间局部性原理 (Principle of Locality)
这是贯穿整个计算机体系(从 CPU Cache 到分布式存储)的核心思想。在我们的场景中,它体现在:一个用户的多笔成交、或者同一个交易对上的多笔成交,在时间上往往是连续或高度集中的。这意味着,短时间内到达系统的 Fill 数据具有极高的相关性。利用这一特性,我们可以将多个逻辑上相关的 Fill 在写入物理存储前进行合并(Merge),将多次小的、随机的 I/O 操作,聚合成一次大的、顺序的 I/O 操作,这对于机械硬盘和 SSD 都能带来数量级的性能提升。

2. 写放大效应 (Write Amplification)
在存储系统中,写放大指实际写入物理存储的数据量大于逻辑上要写入的数据量的现象。在 B+ Tree 索引的数据库中,插入一条很小的数据可能导致整个数据页(通常是 4KB 或 16KB)的读、修改、写回,甚至引发页面分裂,连锁更新上层节点。同样,LSM-Tree(Log-Structured Merge-Tree)架构的数据库(如 RocksDB、ClickHouse)虽然优化了写入,但后台的 Compaction 过程也会产生写放大。将 1000 笔 Fill 合并成一笔再写入,可以极大地降低逻辑写入次数,从而从源头上缓解数据库的写放大问题。

3. 数据模型:行式存储 vs. 列式存储 (Row-based vs. Column-based Storage)
关系型数据库如 MySQL 采用行式存储,即将一条记录的所有字段连续存放在一起。这对于“获取某笔成交的所有信息”(`SELECT * WHERE trade_id = ?`)这类点查非常高效。但对于“计算某用户今日某交易对的总成交量”(`SELECT SUM(quantity) WHERE user_id = ? AND …`)这类分析型查询(AP)则效率低下,因为它需要读取每一行的大量无关数据(如 trade_id, order_id 等)。

列式存储(如 ClickHouse, Apache Druid)则将每一列的数据连续存储。进行聚合查询时,系统只需读取 `user_id`、`quantity` 这几列的数据,I/O 量大幅减少。同时,同一列的数据类型相同、重复度高,非常有利于数据压缩。因此,对于历史成交数据的归档和分析,列式存储是天然的选择。

4. 幂等性与分布式消息系统 (Idempotency & Distributed Messaging)
在分布式系统中,网络抖动或节点故障可能导致消息被重复投递(At-Least-Once Delivery)。我们的 Fill 处理系统必须保证,同一笔 Fill 被处理多次,其最终结果和只处理一次完全相同,这就是幂等性。实现幂等性的关键是为每一笔不可再分的业务操作赋予一个全局唯一的标识符。在我们的场景中,原始的 `TradeID` 就是天然的幂等键。处理系统在持久化之前,需要检查该 `TradeID` 是否已被处理过,或者利用数据库的唯一约束来防止重复写入。

系统架构总览

基于以上原理,我们设计一套分层、解耦的成交明细处理架构。这套架构并非一步建成,而是可以根据业务量逐步演进。下图是其成熟形态的文字描述:

  • 数据源 (Source): 撮合引擎是 Fill 数据的唯一生产者。它完成一笔撮合后,生成不可变的 Fill 记录。
  • 消息队列 (Message Queue): 撮合引擎将 Fill 作为消息,发送到高吞吐的消息队列(如 Apache Kafka)中。Kafka 在此扮演了几个关键角色:

    • 解耦:将撮合引擎与下游消费系统解耦,允许下游独立扩缩容或维护。
    • 缓冲/削峰:应对交易高峰期的瞬间流量,保护后端系统不被冲垮。
    • 持久化与可重放:提供数据持久化能力,当下游系统异常时,数据不会丢失,恢复后可从指定位置(Offset)重新消费。

    Fill 消息可以按 `symbol` 或 `user_id` 进行分区(Partitioning),以保证同一实体相关的消息被同一个消费者实例顺序处理。

  • 实时合并服务 (Real-time Merging Service):
    这是一个有状态的流处理服务(可以自研,也可以基于 Flink 等框架)。它订阅 Kafka 中的 Fill 主题,在内存中对数据进行实时合并。它按预设的合并键(如 `UserID + Symbol + Side + Price`)将短时间窗口内(例如 100 毫秒或 1000 条消息)的 Fill 聚合成一条 `MergedFill` 记录。
  • 分层存储 (Tiered Storage):
    合并后的数据根据其访问频率和时效性,写入不同的存储系统,构成冷热数据分离体系:

    • 热数据层 (Hot Tier): 用于存储近期(如过去 24 小时或 7 天)的成交数据。这部分数据查询频率最高,要求低延迟。可选用高性能的关系型数据库(如 MySQL with InnoDB,并按时间范围分区),或专门的时间序列数据库(如 InfluxDB)。
    • 冷数据层 (Cold Tier): 用于长期归档历史数据。查询频率低,但单次查询涉及的数据量可能巨大,以分析型查询为主。列式数据库(如 ClickHouse)是理想选择。
  • 数据归档与查询路由:
    一个后台任务(Data Archiver)定期将热数据层的过期数据迁移到冷数据层。同时,提供一个统一的查询服务(Query Router),它能解析查询请求的时间范围,智能地将请求路由到热数据层、冷数据层,或两者皆有,最后合并结果返回给调用方(如用户前端、后台管理系统、清算系统)。

核心模块设计与实现

(极客风)

理论讲完了,我们来点硬核的。talk is cheap, show me the code。下面是几个核心模块的实现要点和伪代码。

1. 数据结构定义

首先,明确我们的输入(原始 Fill)和输出(合并后的 Fill)。


// 原始成交明细,从撮合引擎/Kafka 接收
type RawFill struct {
    TradeID   int64     // 全局唯一成交ID, 幂等性关键
    OrderID   int64     // 订单ID
    UserID    int64     // 用户ID
    Symbol    string    // e.g., "BTC/USDT"
    Price     string    // 使用高精度字符串避免浮点数问题
    Quantity  string    // 同上
    Side      string    // "BUY" or "SELL"
    Timestamp int64     // Nanosecond timestamp
}

// 合并后的成交记录,准备写入数据库
type MergedFill struct {
    UserID           int64
    Symbol           string
    Side             string
    Price            string
    TotalQuantity    string  // 合并后的总数量
    StartTimestamp   int64   // 窗口内第一笔Fill的时间戳
    EndTimestamp     int64   // 窗口内最后一笔Fill的时间戳
    ComponentTradeIDs []int64 // [可选] 用于审计追溯的原始TradeID列表
}

注意:`Price` 和 `Quantity` 必须使用高精度数据类型,比如 Java 的 `BigDecimal` 或在 Go 中使用 `decimal` 库,直接用 `float64` 会导致精度丢失,这在金融系统中是灾难性的。

2. 内存合并逻辑

实时合并服务是核心。其本质是一个 stateful 的消费者。我们可以用一个 `map` 来维护当前窗口内的合并状态。


// 定义合并的Key
type MergeKey struct {
    UserID int64
    Symbol string
    Side   string
    Price  string
}

// 合并器状态
type Merger struct {
    // 线程安全的 map,或在单 goroutine 中处理
    pendingFills map[MergeKey]*MergedFill 
    // 用于触发刷新的计时器和计数器
    ticker *time.Ticker
    flushSizeThreshold int
    // ...
}

func (m *Merger) process(fill RawFill) {
    key := MergeKey{
        UserID: fill.UserID,
        Symbol: fill.Symbol,
        Side:   fill.Side,
        Price:  fill.Price,
    }

    // 关键合并逻辑
    if existing, found := m.pendingFills[key]; found {
        // key已存在,累加数量,更新时间戳
        currentQty, _ := decimal.NewFromString(existing.TotalQuantity)
        addedQty, _ := decimal.NewFromString(fill.Quantity)
        existing.TotalQuantity = currentQty.Add(addedQty).String()
        existing.EndTimestamp = fill.Timestamp
        // 如果需要审计,追加 TradeID
        // existing.ComponentTradeIDs = append(existing.ComponentTradeIDs, fill.TradeID)
    } else {
        // key不存在,创建新条目
        m.pendingFills[key] = &MergedFill{
            // ... 初始化字段
            TotalQuantity: fill.Quantity,
            StartTimestamp: fill.Timestamp,
            EndTimestamp: fill.Timestamp,
        }
    }

    // 检查是否达到批次大小阈值,达到则触发刷新
    if len(m.pendingFills) >= m.flushSizeThreshold {
        m.flush()
    }
}

// flush 将内存中的数据批量写入数据库
func (m *Merger) flush() {
    // 1. 锁定或拷贝 pendingFills,防止并发修改
    batch := m.cloneAndClearPendingFills()
    if len(batch) == 0 {
        return
    }

    // 2. 构造批量写入语句,例如 MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE
    // 或 PostgreSQL 的 INSERT ... ON CONFLICT ... DO UPDATE
    // 这里的幂等性处理至关重要!
    // 唯一键可以是 (UserID, Symbol, Side, Price, StartTimestamp)
    db.BulkInsertMergedFills(batch)
}

// 周期性刷新,由 ticker 触发
func (m *Merger) periodicFlush() {
    for range m.ticker.C {
        m.flush()
    }
}

工程坑点:

  • 并发安全:消费 Kafka 和执行 flush 的 goroutine/thread 必须正确同步,避免竞态条件。通常做法是将消费和处理放在一个单独的 goroutine 中,通过 channel 接收数据,实现无锁化。
  • Flush 触发机制:必须同时有基于时间和基于大小的两种触发机制。只基于时间,可能在流量低谷期产生很多空的小批量;只基于大小,在流量低谷期可能导致数据延迟非常大。
  • 优雅停机 (Graceful Shutdown):服务停止时,必须确保内存中所有 `pendingFills` 都被成功 flush 到数据库,否则会丢数据。这需要捕获 `SIGTERM` 等信号,并执行最后的 `flush` 操作。

3. 数据库表设计

以 MySQL 为例,热数据层的表结构可以这样设计:


CREATE TABLE `merged_fills_hot` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id` BIGINT UNSIGNED NOT NULL,
  `symbol` VARCHAR(32) NOT NULL,
  `price` DECIMAL(36, 18) NOT NULL,
  `side` TINYINT NOT NULL COMMENT '1:BUY, 2:SELL',
  `total_quantity` DECIMAL(36, 18) NOT NULL,
  `start_timestamp` BIGINT UNSIGNED NOT NULL COMMENT '窗口开始时间, ns',
  `end_timestamp` BIGINT UNSIGNED NOT NULL COMMENT '窗口结束时间, ns',
  `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (`id`),
  KEY `idx_user_symbol_ts` (`user_id`, `symbol`, `end_timestamp`),
  KEY `idx_ts` (`end_timestamp`)
) ENGINE=InnoDB
PARTITION BY RANGE (end_timestamp) (
  -- 这里可以预先定义好分区,并有脚本定期添加新分区
  PARTITION p202301 VALUES LESS THAN (1675209600000000000), -- 2023-02-01
  PARTITION p202302 VALUES LESS THAN (1677628800000000000)  -- 2023-03-01
  -- ...
);

关键设计:

  • 使用 `DECIMAL` 类型保证精度。
  • 在 `(user_id, symbol, end_timestamp)` 上建立联合索引,这是最常见的查询模式。
  • 按时间戳进行 `RANGE` 分区。这使得删除旧数据(`DROP PARTITION`)的操作几乎是瞬时的,远快于 `DELETE`。同时,查询如果带了时间范围,分区裁剪(Partition Pruning)可以极大地减少扫描的数据量。

性能优化与高可用设计

对抗与权衡 (Trade-offs)

没有完美的架构,只有取舍的艺术。

  • 合并窗口大小:窗口越大,合并率越高,数据库写入压力越小。但代价是数据可见性的延迟越高。对于需要近实时看到成交的场景(如风控、实时 PnL 计算),这个延迟可能是致命的。通常,100ms ~ 1s 是一个合理的折中范围。
  • 实时性 vs. 吞吐量:如果采用 Flink 等流计算框架,可以做到更低的延迟和更强的状态一致性保证(通过 Checkpointing),但其开发和运维复杂度远高于自研的简单合并服务。对于大多数场景,基于 Kafka Consumer Group 的微批处理已足够。
  • 数据一致性:从撮合引擎到 Kafka,再到合并服务,最后到数据库,这是一条异步数据管道。用户通过查询接口看到的数据,必然晚于实际成交时间。这个延迟必须被产品和业务方所接受。如果需要强一致性,那只能放弃异步架构,但系统吞吐量会急剧下降。

高可用设计

实时合并服务是有状态的,它的高可用是设计的难点。

  • 故障恢复:如果一个合并服务实例挂了,内存里的 `pendingFills` 怎么办?
    • 方案A (简单但可能不精确):依赖 Kafka 的 offset。当新的实例(或 Kubernetes 重启的 Pod)接管分区后,它会从上一个成功提交的 offset 开始消费。这保证了数据不丢,但可能会有一小部分数据(上次提交 offset 和进程崩溃之间的消息)被重复处理。数据库层的幂等写入(`ON DUPLICATE KEY UPDATE`)可以保证数据不错,但可能导致原本可以合并的 Fill 被分在了两个批次,合并效率略微下降。这对于绝大多数系统来说是完全可以接受的。
    • 方案B (复杂但精确):使用 Flink 等框架,它会将内存状态定期 checkpoint 到分布式存储(如 HDFS, S3)。故障恢复时,新实例从上一个成功的 checkpoint 恢复内存状态,再从对应的 Kafka offset 开始消费,可以实现端到端的 Exactly-Once。这是金融级清算系统可能会选择的方案。
  • 水平扩展:通过增加 Kafka 的分区数和合并服务的实例数,可以轻松实现水平扩展。Kafka 的 Consumer Rebalance 机制会自动将分区分配给新的实例。

架构演进与落地路径

一口吃不成胖子。一个稳健的系统是演进而来的,而不是一蹴而就设计出来的。

第一阶段:MVP(最小可行产品)

在业务初期,交易量不大。直接将撮合引擎产生的 Fill 通过一个异步任务写入 MySQL。此时不做合并,只做简单的批量 `INSERT`。重点是把业务跑起来,快速验证市场。这个阶段要密切监控数据库的写入性能和磁盘增长。

第二阶段:引入消息队列与微批处理

当数据库写入开始出现瓶颈时,引入 Kafka,将撮合引擎与数据库写入解耦。编写一个无状态的消费者,每次从 Kafka 拉取一批(`poll`)消息,然后直接批量 `INSERT` 到数据库。这一步解决了耦合和流量冲击问题,数据库写入压力得到一定缓解。

第三阶段:实现有状态合并服务

当写入量进一步增大,单纯的微批已无法满足需求时,将消费者改造为有状态的合并服务。实现上文详述的内存合并逻辑。这是架构的核心升级,能将数据库的写入 QPS 降低一到两个数量级。对于绝大多数中大型交易系统,这一阶段的架构已经足够稳健。

第四阶段:冷热数据分离与查询优化

系统平稳运行一两年后,历史数据不断累积,查询性能成为新的痛点。此时启动冷热数据分离项目。引入 ClickHouse 作为冷数据存储,开发数据迁移脚本,并构建统一的查询路由服务。这是一个对读路径的重大优化,需要周密的规划和数据校验。

通过这样分阶段的演进,每一阶段都只解决当前最突出的矛盾,技术投入与业务增长相匹配,既避免了初期过度设计带来的资源浪费,又保证了系统在每个发展阶段都有可靠的性能和扩展性。这才是资深架构师应有的节奏感和工程智慧。

延伸阅读与相关资源

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