对于任何一个高频、海量的交易系统,无论是股票、外汇还是加密货币,数据处理都面临着一个永恒的矛盾:既要保证在线交易的极致性能,又要满足合规审计对历史数据的长期存储与可查询需求。将所有数据,无论新旧,都堆积在高性能的在线数据库中,不仅成本高昂到无法接受,更会拖垮核心交易链路的性能。本文旨在剖析交易场景下数据分级存储与归档的底层原理、架构设计与工程实践,为面临同样挑战的架构师与技术负责人提供一个清晰的演进路线图。
现象与问题背景
一个典型的中大型交易所在高速运转时,每日产生的数据量是惊人的。我们来量化一下这个“惊人”:
- 订单数据 (Orders): 包括委托单的创建、修改、撤销等所有状态变更,每日可达数亿条。
- 成交数据 (Trades/Executions): 每笔撮合成功的交易记录,每日千万至上亿条。
- 行情数据 (Market Data): Level 2 深度快照、逐笔委托和逐笔成交,数据量最为庞大,原始数据每日可达数 TB。
- 操作流水与日志: 用户操作、系统日志、风控事件等,同样是海量。
这些数据在生命周期的不同阶段,其访问频率呈现出极端的差异。近 3 个月的数据(热数据)可能会被频繁用于清结算、用户查询、风险分析等场景。而 3 个月前的数据(冷数据)访问频率骤降,可能一年也只会被监管审计或内部复盘查询几次。当所有数据都存放在以 MySQL 或 PostgreSQL 为代表的在线关系型数据库(OLTP DB)中时,灾难便开始了:
- 性能雪崩: 核心交易表(如 `orders`, `trades`)的行数达到百亿甚至千亿级别。即使有索引,B+树的高度增加,每次查询的磁盘 I/O 次数变多,查询延迟显著增加。更致命的是,写入和更新操作(UPDATE/INSERT)因为要维护庞大的索引,性能也会急剧下降,直接影响交易主链路。数据库的 `VACUUM` 或碎片整理操作也变得异常耗时。
- 成本失控: 为了支撑在线交易,OLTP DB 通常部署在最昂贵的硬件上:高频 CPU、大内存、NVMe SSD。将TB、PB级的冷数据存储在这样的介质上,无异于用金子做仓库的地板,成本极高。
- 运维噩梦: 对一个几十TB的单表进行备份、恢复、或者执行一次 DDL(如加字段),都是一场高风险、耗时极长的操作。数据库的可用性面临巨大威胁。
因此,建立一套完善的数据归档与分级存储架构,将数据根据其“温度”自动地、平滑地从昂贵的高速存储迁移到廉价的低速存储,同时保证数据的完整性、一致性和在需要时的可查询性,就成为了交易系统架构设计的必然选择。
关键原理拆解
在设计架构之前,我们必须回归计算机科学的基础原理。分级存储并非一个新概念,它的思想根植于计算机体系结构的每一个角落,从 CPU 的 L1/L2/L3 Cache 到内存,再到磁盘。其核心理论基石是访问的局部性原理(Principle of Locality)。
大学教授的声音:
局部性原理分为两个维度:
- 时间局部性 (Temporal Locality): 如果一个数据项被访问,那么在不久的将来它很可能再次被访问。在交易系统中,一笔刚刚创建的委托单,其状态会很快被更新(部分成交、完全成交、撤销),相关的资金和持仓也会被频繁操作。这就是典型的时间局部性。
- 空间局部性 (Spatial Locality): 如果一个数据项被访问,那么与它物理地址相近的数据项也可能很快被访问。例如,查询某用户今日的所有订单,这些订单记录在物理上可能存储在一起,一次磁盘 I/O 读取一个 Page/Block 可以命中多条。
整个计算机存储体系(Cache-Memory-Disk)就是基于局部性原理构建的金字塔结构。越靠近CPU,存储介质速度越快、容量越小、单位成本越高。CPU Cache 缓存了主存中最常被访问的数据,主存则缓存了磁盘中最活跃的数据。我们的数据归档架构,本质上是在这个金字塔的底座上,做了进一步的宏观延伸:
- 热数据层 (Hot Tier): 对应 CPU 的 Cache/Memory。存放于高性能 OLTP 数据库(如 MySQL on NVMe SSD),服务于在线交易,要求毫秒级响应。
- 温数据层 (Warm Tier): 对应磁盘。存放于成本较低的存储(如 TiDB, ClickHouse on HDD/SATA SSD),服务于近期的分析和查询,允许秒级响应。
- 冷数据层 (Cold Tier): 对应网络存储或磁带库。存放于对象存储(如 AWS S3, MinIO),服务于长期归档和低频的审计查询,响应时间可达分钟级。
数据从热到冷的过程,就是一个不断下沉(Sink)的过程,这个过程必须是自动化的,并且对在线业务无感。这个过程的核心,是实现一个宏观尺度上的“写回策略(Write-Back)”或“写穿策略(Write-Through)”的数据同步与清理机制。
系统架构总览
一个成熟的、可扩展的数据归档系统,绝不是一个简单的定时 `DELETE` 脚本。它应该是一个解耦的、基于事件驱动的流式处理架构。下面我们用文字描述这幅架构图:
整个系统分为在线平面和归档平面,通过一个可靠的消息队列(如 Kafka)进行解耦。
- 在线平面 (Online Plane):
- 交易核心 (Trading Core): 负责处理交易请求。
- OLTP 数据库集群 (MySQL Cluster): 存储热数据,例如过去 90 天的订单和成交记录。
- CDC (Change Data Capture) 组件: 使用 Debezium 或 Maxwell 等工具,以非侵入的方式捕获 OLTP 数据库的 `binlog`,将所有数据变更(INSERT, UPDATE, DELETE)实时转化为事件流,并推送到 Kafka。
- 数据总线 (Data Bus):
- Apache Kafka 集群: 作为解耦在线平面和归档平面的数据总线。所有数据变更事件都持久化在 Kafka 中,为下游消费提供了缓冲和高可用保障。
- 归档平面 (Archive Plane):
- 归档服务 (Archiving Service): 核心的无状态处理单元。它消费 Kafka 中的数据变更事件。其内部逻辑判断数据是否达到归档条件(例如,订单状态是否为终态,且创建时间早于 90 天)。
- 冷存储 (Cold Storage): 通常是对象存储,如 AWS S3 或自建的 MinIO。归档服务将符合条件的数据转换为某种优化的格式(如 Parquet),并按照特定目录结构(如 `s3://bucket/trades/year=2023/month=10/day=26/`)写入。
- 数据目录/元数据存储 (Data Catalog): 一个至关重要的组件,用于记录已归档数据的元信息,例如每个 Parquet 文件存储了哪个表、哪个时间范围的数据、在 S3 上的具体路径等。可以使用 Elasticsearch 或一个独立的 RDBMS 实现。
- 清理服务 (Purge Service): 在确认数据已安全写入冷存储并更新元数据后,该服务负责向 OLTP 数据库发起 `DELETE` 操作,清理已归档的数据。为了安全,通常采用软删除或延迟删除策略。
- 统一查询平面 (Unified Query Plane):
- 查询网关 (Query Gateway): 对用户或分析师提供一个统一的查询入口。
- 查询引擎 (Query Engine): 使用 Presto (Trino) 或 Spark SQL。该引擎能够联邦查询多个数据源。当查询请求的时间范围跨越热数据和冷数据时,它能智能地同时查询 OLTP 数据库和对象存储中的 Parquet 文件,并将结果合并返回。
这种架构的核心优势在于解耦。归档操作的压力完全从 OLTP 数据库转移到了归档平面,在线交易的性能不会受到任何归档任务的影响。CDC 的使用保证了数据捕获的实时性和低侵入性。
核心模块设计与实现
极客工程师的声音:
理论很丰满,但魔鬼全在细节里。我们来扒一扒几个关键模块的实现和坑点。
模块一:可靠的数据捕获与识别
千万别用应用层双写(即交易服务在写 MySQL 的同时也写 Kafka),这会引入分布式事务的复杂性,得不偿失。CDC 是目前最优雅的方案。
但是,CDC 捕获的是 `binlog`,是物理变更。归档服务需要的是有业务含义的数据。例如,一个订单从 `PENDING` -> `PARTIALLY_FILLED` -> `FILLED`,会产生多条 `UPDATE` 事件。我们只关心它进入终态(`FILLED`, `CANCELED`, `REJECTED`)的那一刻。因此,归档服务内部需要一个简单的状态机来跟踪每个订单的生命周期,或者在消费时判断 `status` 字段是否为终态。
// 伪代码: 归档服务消费逻辑
func processOrderEvent(event cdc.Event) {
order := parseOrder(event.Payload)
// 我们只归档处于终态且时间超过阈值的订单
isFinalState := order.Status == "FILLED" || order.Status == "CANCELED"
isOldEnough := order.UpdatedAt.Before(time.Now().Add(-90 * 24 * time.Hour))
if isFinalState && isOldEnough {
// 将该订单ID放入待归档批次中
batchArchiver.Add(order.ID)
}
}
另一个方案是,在 OLTP 表中增加一个 `archive_eligible_at` 字段。当订单进入终态时,应用层更新这个时间戳。归档服务的逻辑就可以简化为轮询这个时间戳,但这又对业务代码产生了侵入,是一种权衡。
模块二:数据格式转换与写入
直接把数据库的行数据以 JSON 格式存到 S3 是最低效的做法。正确的选择是使用列式存储格式,如 Apache Parquet 或 Apache ORC。
为什么是 Parquet?
- 列式存储: 分析查询通常只关心少数几列(例如,查询某段时间的总成交额,只需要 `price` 和 `quantity` 列)。Parquet 只需读取这两列的数据,I/O 开销远小于读取整行数据的 JSON 或 CSV。
- 高压缩率: 同一列的数据类型相同,具有相似的模式,因此压缩效果极好(如 Snappy, Gzip)。能为你省下大笔存储费用。
- 谓词下推 (Predicate Pushdown): Parquet 文件内部存储了每列的统计信息(最大/最小值等)。查询引擎(如 Presto)在读取文件前,可以先检查元数据。如果查询条件是 `WHERE price > 50000`,而某个 Parquet 文件的元数据表明其 `price` 列的最大值只有 48000,那么整个文件都会被跳过,极大地提升了查询效率。
写入 S3 时,目录结构的设计至关重要,它直接决定了查询性能。一个好的实践是按日期分区:
`s3://my-exchange-archive/trades/dt=2023-10-26/hour=15/part-00001-uuid.parquet`
当查询 `SELECT * FROM trades WHERE dt = ‘2023-10-26’` 时,Presto 只会去扫描 `dt=2023-10-26` 这个“目录”下的文件,这就是分区裁剪(Partition Pruning)。
模块三:在线数据的安全清理
这是最危险的一步。如果在数据还没成功写入 S3 时就删除了在线数据,那就意味着数据永久丢失。绝对不能用“先删除再归档”的逻辑。
一个健壮的流程应该是:
- 归档服务: 从 OLTP 查询出一批待归档数据。比如,使用 `SELECT id FROM orders WHERE status IN (‘FILLED’, ‘CANCELED’) AND created_at < '...' LIMIT 1000`。
- 写入冷存: 将这批数据写入 Parquet 文件并上传到 S3。
- 获取回执: 确认 S3 返回成功,并最好能校验文件的完整性(如 ETag/MD5)。
- 更新元数据: 在数据目录中记录这批数据的归档信息。
- 提交清理任务: 将这批数据的 ID (`[id1, id2, …]`) 作为一个消息发送到另一个专用的 Kafka topic(`purge-queue`)。
- 清理服务: 消费 `purge-queue`,执行批量删除操作。
为了防止清理服务的 `DELETE` 语句锁住整张表或造成主从延迟,必须小批量、分批次执行,并且使用带有超时和限流的 `DELETE` 语句。
-- 小心执行! 一定要在非高峰期,并且有严格的限流和监控
DELETE FROM orders WHERE id IN (...) LIMIT 500;
这个流程实现了最终一致性。在第 2 步和第 5 步之间如果发生故障,最坏的情况是数据在 OLTP 和 S3 中同时存在,但绝不会丢失。归档服务需要具备幂等性,能够处理重复的数据归档请求。
性能优化与高可用设计
对抗层 (Trade-off 分析):
- 归档时机:流式 vs. 批量:
- 流式 (Streaming): 基于 CDC,数据一旦满足条件就立即被归档。优点是准实时,对 OLTP 几乎无读压力。缺点是会产生大量小文件,对 HDFS/S3 和查询引擎不友好。需要一个额外的“文件合并(Compaction)”服务来定期合并小文件。
- 批量 (Batch): 每天凌晨定时启动一个任务,从 OLTP 读取前一天的数据进行归档。优点是实现简单,产生的文件大小可控。缺点是在执行期间会对 OLTP 数据库造成巨大的读压力,可能影响到正常的夜间清算等任务。
- 混合模式 (推荐): 使用 CDC 将数据变更实时写入数据湖的着陆区(Landing Zone),再由一个定时的 Spark/Flink 任务,每小时或每几小时对这些增量数据进行一次批量处理、转换格式、合并文件,并写入最终的归档位置。这是当前业界主流的最佳实践。
- 数据一致性: 归档过程不是一个原子操作,而是跨多个系统的最终一致过程。在归档和清理的短暂时间窗口内,一份数据可能同时存在于热存储和冷存储。查询层需要能够处理这种情况(例如,通过 `UNION ALL` 后去重)。业务上必须能接受这种短暂的不一致状态。对于需要强一致性的财务对账等场景,应以 OLTP 数据为准,直到清理完成。
- 高可用:
- 归档服务和清理服务本身必须是无状态的,可以水平扩展,并部署在 Kubernetes 等容器编排平台上,实现故障自愈。
- 数据管道 Kafka 和冷存储 S3 本身就是高可用、高持久性的组件,这是我们选择它们的重要原因。
- 元数据存储也需要高可用方案,例如 Elasticsearch 集群或主从复制的 RDS。
架构演进与落地路径
一口气吃不成胖子,如此复杂的架构不可能一蹴而就。一个务实的演进路径如下:
第一阶段:工具化脚本(救火阶段)
当线上数据库性能告急时,最快的方式是使用 `mysqldump` 配合查询条件导出冷数据,或者使用 Percona Toolkit 中的 `pt-archiver` 工具。这是一个“战术性”解决方案,能快速缓解问题,但它对数据库有侵入性,且过程不透明、难管理、易出错。此阶段的目标是先生存下来。
第二阶段:独立的批处理归档服务
开发一个独立的微服务,替代 `pt-archiver`。该服务在深夜低峰期运行,连接到只读副本(Read Replica)以减少对主库的影响。它查询、转换数据并写入对象存储,然后通过 API 调用或消息队列触发对主库的删除操作。这实现了逻辑的中心化管理和初步的解耦,但仍然是批处理模式,对数据库的周期性压力依然存在。
第三阶段:引入CDC与流处理架构
实施本文所述的完整架构。引入 Debezium 和 Kafka,将数据捕获方式从“拉(Pull)”模式转变为“推(Push)”模式。归档服务变为一个 7×24 小时运行的流处理应用。这个阶段的改造成本最高,但能从根本上解决归档对在线系统的性能影响,并为未来的实时数据应用打下基础。
第四阶段:构建统一数据湖/湖仓一体
当冷数据在对象存储中以 Parquet 格式积累到一定规模后,它就不再仅仅是“归档数据”,而是一个宝贵的“数据金矿”。可以引入 Delta Lake 或 Apache Iceberg 这样的事务性数据湖格式,提供 ACID 事务、时间旅行(Time Travel)等能力。在此之上,通过 Spark、Flink、Presto/Trino 等计算引擎,可以进行复杂的批量分析、机器学习模型训练、Ad-hoc 查询等,真正实现了数据的价值最大化,完成了从一个简单的归档系统到企业级数据湖仓的演进。
总结而言,交易系统的数据归档不仅是一个技术问题,更是一个成本、性能和业务需求之间不断权衡的架构艺术。从简单的脚本到复杂的湖仓一体,每一步演进都旨在更优雅地解决那个根本性的矛盾,让数据在它的整个生命周期中,都能被安放在最合适的位置。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。