对于任何一个高频、高吞吐的交易系统,数据是其命脉,也是其沉重的负担。订单、成交、行情等核心数据以惊人的速度增长,很快就会撑爆为在线交易优化的、昂贵的存储系统。本文旨在为中高级工程师和架构师提供一个关于交易系统数据归 antiga 与分级存储的深度指南。我们将从问题的本质出发,回归计算机科学的基本原理,剖析一个从热到冷、兼顾性能、成本与合规性的三层存储架构的实现细节、性能权衡与演进路径。
现象与问题背景
在一个典型的金融交易系统中,例如股票或数字货币交易所,数据生命周期呈现出极其陡峭的“温度”曲线。我们可以将数据根据其访问模式和业务价值分为三类:
- 热数据 (Hot Data): 通常指 T+0 或 T+1 的数据,即当天和前一交易日的数据。这包括正在活跃的订单、当日的成交记录、实时的账户持仓和资金变动。这些数据要求亚毫秒级的读写延迟,是交易核心链路、实时风控和盘中清算的生命线。它们通常存储在最高性能的媒介上,如内存数据库或基于 NVMe SSD 的高性能关系型数据库(如 MySQL/PostgreSQL)。
- 温数据 (Warm Data): 一般指过去 3 个月到 1 年内的数据。这类数据不再参与实时交易,但会被频繁查询,用于生成日终报表、月度账单、客户持仓查询、以及运营团队的常规数据分析。对温数据的访问延迟要求可以放宽到秒级,但仍然需要较高的查询并发能力。
- 冷数据 (Cold Data): 指一年以上甚至更久远的历史数据。这些数据访问频率极低,通常仅用于满足监管合规(如要求保存 5-7 年的交易记录)、司法调查、以及机器学习模型训练或策略回测。其访问可能是数月一次,对延迟的容忍度很高,可以接受分钟级的响应时间。
核心矛盾在于,如果将所有数据不加区分地存放在为热数据设计的高性能存储中,会带来三个无法回避的问题:
- 成本失控: 高性能存储(内存、NVMe SSD)的单位成本是低速存储(HDD、对象存储)的数十倍甚至数百倍。随着数据量线性增长,存储成本将成为巨大的运营开销。
– 性能衰减: 单一数据库实例中数据量过大(例如,单表百亿行),会导致索引膨胀、查询计划复杂度增加、B+Tree 层级加深,最终使得在线交易依赖的核心查询(如订单状态查询)性能严重下降,甚至引发数据库抖动,危及整个系统的稳定性。
– 运维灾难: 一个庞大的单一数据库实例,其备份、恢复、迁移、甚至 DDL 变更都成为高风险、耗时长的操作,极大地限制了系统的敏捷性和可维护性。
因此,设计一套能够自动将数据从热变冷、分级存储的归档架构,不是一个可选项,而是一个成熟交易系统必须具备的核心能力。
关键原理拆解
在深入架构设计之前,我们必须回归到计算机科学的底层原理。分级存储并非一个新的概念,它的思想根植于计算机体系结构本身,并由几个核心理论支撑。
(教授声音)
首先是局部性原理 (Principle of Locality)。这是整个计算机存储体系(从 CPU Cache 到内存到磁盘)的基石。它包含两个方面:时间局部性(Temporal Locality),即一个数据被访问后,在短期内很可能再次被访问;以及空间局部性(Spatial Locality),即一个数据被访问后,其物理上相邻的数据也可能很快被访问。交易数据完美地诠释了时间局部性:今天的订单状态会被频繁查询和更新,而一年前的订单则几乎无人问津。我们的分级存储架构,本质上是在系统架构层面,对局部性原理的一次宏观应用,将“热”的数据放在快速的“缓存”(热存储)中,而将“冷”的数据放在慢速的“主存”(冷存储)中。
其次是信息生命周期管理 (Information Lifecycle Management, ILM)。这是一个更为宏观的数据管理策略,它认为数据和生命体一样,有其诞生、活跃、衰退、消亡的周期。ILM 的核心思想是,根据数据在生命周期中所处的不同阶段,为其匹配相应成本、性能和可用性的存储资源。在我们的场景中,一个订单的生命周期就是:创建(热)-> 终态(热)-> 近期查询(温)-> 长期归档(冷)-> 最终销毁(遵从合规要求)。一个优秀的归档系统必须是 ILM 策略的忠实执行者。
最后,从分布式系统角度看,数据归档过程涉及到数据在不同存储系统间的迁移,这必然引入一致性模型的考量。当我们将数据从 MySQL 迁移到对象存储 S3 时,我们无法实现跨系统的原子事务。在数据拷贝完成但源数据尚未删除的中间状态,数据会短暂地存在于两个地方。查询系统必须能处理这种最终一致性 (Eventual Consistency)。我们追求的是在归档操作完成后,系统能达到一致的状态,而对过程中的短暂不一致有明确的预期和处理机制。
系统架构总览
基于以上原理,我们设计一个三层(热、温、冷)存储架构。这里我们不用架构图,而是通过文字描述其核心组件与数据流,这有助于你构建更深刻的心理模型。
整个系统的数据流向像一条由热到冷的河流:
- 热数据层 (Hot Tier): 这是交易系统的核心。通常是一个分库分表的 MySQL 或 PostgreSQL 集群,部署在高性能物理机和 NVMe SSD 之上。所有在线交易请求,如订单创建、撮合、状态更新,都直接与该层交互。该层的数据规模被严格控制,例如,只保留最近 30 天的数据。
- 数据总线 (Data Bus): 这是连接热数据层与其他层的动脉。我们绝不能通过批量任务直接扫描线上生产库来进行数据同步,这会对其造成巨大冲击。最佳实践是采用变更数据捕获 (Change Data Capture, CDC) 技术。利用 MySQL 的 binlog,我们可以将所有数据变更(INSERT, UPDATE, DELETE)准实时地、低侵入地捕获,并推送到一个高吞吐的消息中间件,如 Apache Kafka。Kafka 在这里扮演了数据总线的角色,实现了上下游系统的完全解耦。
- 温数据层 (Warm Tier): 一个或多个数据处理服务(例如用 Go 或 Java 编写的消费者应用,或 Flink/Spark Streaming 作业)订阅 Kafka 中的数据变更消息,经过适当的清洗和转换后,写入温数据存储。温存储的选型侧重于优秀的分析查询性能,例如 ClickHouse、Apache Druid 或 Elasticsearch。这些系统擅长处理大规模数据的聚合和复杂查询,并且可以部署在成本更低的普通 SSD 或 HDD 上。
- 冷数据层 (Cold Tier): 这是数据的最终归宿。我们通过定期的归档任务(例如,一个每晚执行的 Kubernetes CronJob),将温数据层中超过特定时间阈值(如 90 天)的数据,导出为标准化的、压缩友好的列式存储格式(如 Apache Parquet 或 ORC),并存入廉价、高耐久的对象存储中,如 AWS S3、Google Cloud Storage 或自建的 MinIO 集群。
- 统一查询层 (Unified Query Layer): 对用户来说,不应该关心数据到底存储在哪里。我们需要提供一个统一的查询入口。像 PrestoDB 或 Trino 这样的分布式 SQL 查询引擎是这个角色的完美选择。它可以配置多个数据源连接器(Connector),分别连接到热层的 MySQL、温层的 ClickHouse 和冷层的 S3(通过 Hive Connector)。当用户提交一个跨越时间范围的 SQL 查询时,Presto/Trino 的查询优化器会自动将查询分解,分别下推到合适的存储层执行,最后将结果聚合后返回给用户。
核心模块设计与实现
(极客工程师声音)
理论听起来很完美,但魔鬼全在细节里。我们来聊聊几个关键模块的实现坑点。
模块一:基于 Binlog 的 CDC 数据捕获
别用定时任务跑 `SELECT * FROM … WHERE updated_at > ?` 这种烂招数。它不仅给源库带来周期性压力,还处理不了 DELETE 操作。正确的做法是拥抱 Binlog。像 Debezium 或 Maxwell’s demon 这样的开源工具,把自己伪装成一个 MySQL Slave,订阅 Master 的 binlog,然后把每一行变更解析成 JSON 或 Avro 格式的消息,扔进 Kafka。
这样做的好处是:
- 准实时: 延迟通常在毫秒级。
- 低侵入: 对源库的性能影响极小,只是多了一个网络连接。
- 数据完整: 能捕获 INSERT、UPDATE、DELETE 的全部信息,包括变更前和变更后的数据镜像(取决于你的 binlog 格式配置,建议使用 `ROW` 格式)。
一个 Debezium 发到 Kafka 的典型消息 payload 看起来像这样,信息量非常丰富,下游可以做各种处理。
{
"schema": { ... },
"payload": {
"before": { /* a full row image before the change */ },
"after": {
"order_id": 12345,
"user_id": 6789,
"symbol": "BTC_USDT",
"price": 50000.00,
"quantity": 1.5,
"status": "FILLED",
"created_at": 1678886400000,
"updated_at": 1678886401000
},
"source": { ... },
"op": "u", // 'c' for create, 'u' for update, 'd' for delete
"ts_ms": 1678886401500
}
}
模块二:从温到冷的归档任务
这是整个架构中最需要精细设计、也最容易出问题的环节。一个健壮的归档任务必须是可重入、幂等、且对源系统友好的。
假设我们用 Go 语言编写一个运行在 K8s CronJob 里的归档程序,它负责将 ClickHouse 里 90 天前的数据归档到 S3 并删除。
核心逻辑如下:
package main
import (
"context"
"fmt"
"time"
// Assume we have clients for ClickHouse and S3
"my_app/ch_client"
"my_app/s3_client"
"my_app/parquet_writer"
)
func archiveTrades(ctx context.Context, cutoffDate time.Time) error {
// 1. 定义归档批次,例如按天归档
targetDate := cutoffDate.AddDate(0, 0, -1) // e.g., archive data of 91 days ago
dateStr := targetDate.Format("2006-01-02")
// 2. 从 ClickHouse 中查询并流式读取数据
// **关键点**: 不要一次性 `SELECT *`,会撑爆内存。使用游标或分页。
rows, err := ch_client.QueryTradesByDate(ctx, dateStr)
if err != nil {
return fmt.Errorf("querying clickhouse failed: %w", err)
}
defer rows.Close()
// 3. 将数据写入本地 Parquet 文件
// **关键点**: 写入本地临时文件,而不是直接流式上传,这样便于校验和重试。
localFilePath := fmt.Sprintf("/tmp/trades_%s.parquet", dateStr)
pqWriter, err := parquet_writer.New(localFilePath)
if err != nil {
return fmt.Errorf("creating parquet writer failed: %w", err)
}
var rowCount int64
for rows.Next() {
var trade ch_client.Trade
if err := rows.Scan(&trade); err != nil {
// ... handle scan error ...
}
pqWriter.Write(trade)
rowCount++
}
pqWriter.Close()
// 4. 上传 Parquet 文件到 S3
// **关键点**: S3 key 设计要利于分区查询。
s3Key := fmt.Sprintf("trades/year=%d/month=%02d/day=%02d/data.parquet",
targetDate.Year(), targetDate.Month(), targetDate.Day())
err = s3_client.Upload(ctx, localFilePath, s3Key)
if err != nil {
return fmt.Errorf("uploading to S3 failed: %w", err)
}
// 5. **最危险的一步**: 验证后删除 ClickHouse 数据
// **关键点**: 必须先确认 S3 上传成功。可以检查 S3 object 的 metadata 或下载校验。
// 确认无误后,再执行删除。删除操作必须幂等。
// `ALTER TABLE trades DELETE` 是一个重量级操作,要小心使用。
// 确保有监控,失败时告警,绝不能让它失控。
err = ch_client.DeleteTradesByDate(ctx, dateStr)
if err != nil {
// 如果删除失败,必须告警让人工介入。因为数据已经备份,
// 最坏情况是数据在温、冷层有短暂重复,但不会丢数据。
return fmt.Errorf("CRITICAL: deleting from clickhouse failed after successful archival: %w", err)
}
fmt.Printf("Successfully archived %d trades for date %s", rowCount, dateStr)
return nil
}
工程坑点:
- 删除操作的冲击: 对 ClickHouse 这种 MergeTree 引擎的表执行 `ALTER TABLE … DELETE` 会产生所谓的 mutation,这是一个异步的、消耗资源的后台操作。频繁或大规模的删除可能导致 ClickHouse 性能抖动。因此,归档任务的执行窗口最好放在业务低峰期。对于 MySQL,大批量 `DELETE` 会导致表锁、MVCC 版本链过长、产生大量碎片。Percona 的 `pt-archiver` 工具在这方面做得很好,它会以小批量的方式、通过复杂的锁机制来降低对线上业务的影响。
- 幂等性保证: 任务可能因为网络问题、Pod 重启等原因中断并重跑。整个流程必须设计成幂等的。例如,上传 S3 时使用相同的 key 会覆盖,这是天然幂等的。删除操作 `DELETE WHERE date = ‘…’` 也是幂等的。关键是要保证流程的原子性,或者至少是“向前滚动”的,即使重跑也不会造成数据损坏。
- 数据格式选择: 为什么是 Parquet?因为它列式存储的特性。对于冷数据的分析查询,通常只关心少数几个字段(例如,查询某个币对一年的总交易额),列式存储只需读取相关的列,极大地减少了 I/O。同时它支持高效的压缩算法(如 Snappy, ZSTD),能进一步节约存储成本。
性能优化与高可用设计
在整个分级存储架构中,性能瓶颈和可用性风险点分布在不同的层次。
热数据层优化: 优化的核心思想是让它尽可能小、尽可能纯粹。严格执行数据归档和清理,是保证热数据层高性能的首要手段。一张只存 30 天数据的订单表,和一张存了 5 年数据的表,其查询性能、索引维护成本、备份恢复时间完全是天壤之别。
温数据层优化: 以 ClickHouse 为例,分区和排序键的设计至关重要。例如,交易表可以按 `(toYYYYMM(trade_time), symbol)` 进行分区,并按 `(symbol, trade_time)` 排序。这样,针对特定币对和时间范围的查询会非常快。
冷数据层优化: 冷数据层的查询性能几乎完全取决于两件事:
- 数据分区 (Partitioning): 在对象存储上,分区是通过目录结构模拟的。就像上面代码示例中的 `year=…/month=…/day=…`。当 Presto/Trino 查询时,如果 WHERE 条件中包含分区键(如 `WHERE trade_date > ‘2022-01-01’`),它会利用分区剪枝(Partition Pruning),只去扫描相关的目录,避免全量数据扫描。这是冷数据查询性能的决定性因素。
- 文件大小: 对象存储对于大文件的吞吐性能远好于大量小文件。如果归档任务每天只产生几 KB 的小文件,会造成“小文件灾难”,拖慢查询速度。需要在归档流程中加入一个合并步骤,定期将小文件合并成数百 MB 到 1GB 的大文件。
高可用设计:
- 数据总线: Kafka 本身通过分区和副本机制提供了高可用性。
- 处理/归档服务: 设计为无状态应用,部署在 Kubernetes 等容器编排平台上,利用其自愈和弹性伸缩能力。
- 存储层: 各存储系统(MySQL, ClickHouse, S3)都有各自成熟的高可用方案(如主从复制、集群、多区域复制),需要根据业务的 RTO/RPO 指标来配置。
- 统一查询层: Presto/Trino 也可以部署为高可用的集群模式,Coordinator 节点可以有主备,Worker 节点可以动态增删。
架构演进与落地路径
一口气吃不成胖子。一个完善的分级存储架构不是一蹴而就的,它可以分阶段演进。
第一阶段:手工归档与冷热分离
在系统初期,数据量不大时,最简单的方案是在同一个 MySQL 实例中创建归档表(例如 `orders_archive`)。由 DBA 在深夜手动或通过脚本执行 `INSERT INTO orders_archive SELECT * FROM orders WHERE created_at < ...; DELETE FROM orders WHERE created_at < ...;`。应用代码需要改造,根据查询的时间范围决定是查 `orders` 表还是 `orders_archive` 表。
优点: 实现简单,无需引入新组件。
缺点: 风险高,对主库性能有冲击,查询逻辑耦合,且没有真正降低存储成本。
第二阶段:引入 CDC 与温数据层
当数据量增长,第一阶段的方案难以为继时,引入 CDC 和 Kafka,将数据实时同步到一个专门用于分析的温数据层(如另一个独立的、使用 HDD 的 MySQL 或 ClickHouse 实例)。此时,在线应用只访问热数据层,分析和报表类查询全部打到温数据层。热数据层的清理任务可以更安全地执行,因为它只需要 `DELETE` 数据,而不需要 `INSERT … SELECT`。
优点: 实现了读写分离和在线/分析负载隔离,主库压力显著降低。
缺点: 引入了新的技术栈,运维复杂度增加。历史数据查询仍然不够方便。
第三阶段:构建完整的冷数据层与统一查询
这是我们本文描述的最终形态。在第二阶段的基础上,增加从温数据层到对象存储的归档任务,并部署 Presto/Trino 作为统一查询入口。这个阶段完成了成本、性能和易用性的最终平衡。所有用户,无论是交易员、分析师还是合规官,都可以通过一个统一的 SQL 入口,无差别地查询从实时到数年前的全部数据。
优点: 存储成本最优,架构清晰,可扩展性强,用户体验好。
缺点: 架构最复杂,对技术团队的驾驭能力要求最高。
总而言之,交易系统的数据归档与分级存储是一个典型的工程权衡问题。它要求架构师不仅要理解业务对数据的不同需求,还要深刻掌握从存储介质、数据库引擎到分布式查询系统的多层次技术原理。通过分阶段的演进,逐步构建一个自动化、高性价比的数据生命周期管理体系,是保障交易系统长期、稳定、高效运行的关键所在。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。