从热到冷:构建高可用、低成本的交易系统数据分级存储架构

对于任何一个高频交易系统,例如股票、外汇或数字货币交易所,数据是其命脉,也是其负担。每日产生的订单、成交、行情快照等数据量可达 TB 级别。将所有数据无限期地存储在高性能的在线 OLTP 数据库中,不仅成本高昂到无法接受,更会严重拖累核心交易链路的性能。本文将从首席架构师的视角,深入剖析交易系统数据分级存储的必要性、底层原理、架构设计与实现细节,为你提供一套从理论到实践的完整解决方案。

现象与问题背景

一个典型的交易系统,其核心数据可以分为几类:

  • 订单与成交数据 (Order & Trade): 这是系统的核心,记录了用户的每一笔委托和最终的成交结果。
  • 行情数据 (Market Data): 包括 Tick、K-Line、深度图等,数据量极大。

    账户与流水数据 (Account & Journal): 用户的资产变动、出入金记录等。

    清结算数据 (Clearing & Settlement): 每日日终结算后的轧差结果和最终交割记录。

这些数据具有显著不同的生命周期和访问模式。新产生的数据(例如 T+0 或 T+1 的订单)属于 热数据,需要被交易核心、风控系统、用户前端高频访问,要求毫秒级的读写延迟。随着时间的推移,一周前的数据变为 温数据,主要用于生成报表、客服查询、内部对账,访问频率降低,对延迟的容忍度也提升到秒级。而数月乃至数年前的数据则成为 冷数据归档数据,几乎不会被在线业务访问,仅为满足监管审计、数据分析或司法调查等低频需求而存在,其访问延迟可以是分钟级甚至小时级。

当所有数据都堆积在主库(通常是 MySQL 或 PostgreSQL)时,会引发一系列灾难性的工程问题:

  1. 性能雪崩: 单表数据量达到数十亿行,B+Tree 索引层级变深,随机 I/O 成本剧增。任何一个简单的查询都可能导致慢 SQL,耗尽数据库连接池,最终拖垮整个在线业务。在线业务的 DML 操作(INSERT, UPDATE)也因索引维护成本的增加而性能下降。
  2. 成本失控: 为了支撑在线业务,主库通常部署在高性能 NVMe SSD 上,并配置高规格 CPU 和内存。将海量冷数据存储在如此昂贵的介质上,是巨大的资源浪费。
  3. 运维黑洞: 对一个几 TB 大的表执行 DDL 操作(如加字段、加索引)是一场噩梦,可能导致长时间的锁表。数据库的备份和恢复时间也变得无法接受,严重影响系统的 RTO(恢复时间目标)。

因此,设计一套合理的数据分级存储与归档架构,将数据根据其“温度”迁移到不同成本和性能的存储介质上,是交易系统架构成熟的必经之路。

关键原理拆解

在设计方案之前,我们必须回归计算机科学的基础原理。数据分级存储并非一个全新的概念,它本质上是计算机存储体系(Memory Hierarchy)思想在分布式系统领域的宏观应用。

学术派视角:

  • 局部性原理 (Principle of Locality): 这是整个存储体系的理论基石。程序在访问数据时,倾向于访问最近刚访问过的数据(时间局部性)或其附近的数据(空间局部性)。在交易数据场景下,时间局部性表现得尤为明显:用户最关心的是“刚刚”发生了什么。这为我们将数据划分为“热”和“冷”提供了理论依据。
  • 存储金字塔模型: 从上到下依次是 CPU 寄存器、L1/L2/L3 Cache、主存(DRAM)、SSD、HDD、磁带/云对象存储。越往上,速度越快,每比特成本越高,容量越小。数据分级存储架构就是将这个模型从单机扩展到了分布式集群:
    • 热数据层 -> 主存/SSD: 对应高性能 OLTP 数据库,服务在线请求。
    • 温数据层 -> 普通 SSD/HDD: 对应数据仓库或分析型数据库,服务 BI 和复杂查询。
    • 冷数据层 -> 对象存储/HDFS: 对应云存储(如 AWS S3, Google Cloud Storage)或自建 HDFS,实现成本最低的长期归档。
  • 数据结构与 I/O 模型: 为什么在线数据库不适合存储海量历史数据?核心在于其存储引擎(如 InnoDB)采用的 B+Tree 索引结构。B+Tree 善于处理小范围的、高并发的随机读写(Point Queries & Range Scans),这正是 OLTP 场景所需要的。但当数据量 N 巨大时,树的高度 `log_b(N)` 增加,每次查询需要更多的磁盘 I/O。而归档和分析场景下,我们通常需要的是对大量数据的全量扫描和聚合(Full Table Scan),此时 B+Tree 反而不是最优解。列式存储(如 Parquet, ORC)在这种场景下通过减少 I/O 总量和提高压缩比,展现出巨大优势。

系统架构总览

一个成熟的数据分级存储系统,其架构通常包含以下几个核心部分。我们可以通过文字来描绘这幅架构图:

系统的左侧是 数据源,即在线交易系统,它持续不断地向 热数据层(Hot Tier)写入数据。热数据层是一个高性能的关系型数据库集群,例如 MySQL Sharding Cluster 或 CockroachDB。

系统的核心是一个称为 数据迁移服务 (Data Mover Service) 的 ETL 引擎。它周期性地或基于事件流地从热数据层拉取“变冷”的数据。这个过程必须对在线业务无感知或影响极小。

数据迁移服务会将拉取到的数据处理后,兵分两路:

  1. 一部分写入 温数据层 (Warm Tier)。温数据层通常是一个 OLAP 数据库,如 ClickHouse 或 Elasticsearch。它为运营、客服和数据分析师提供近期的历史数据查询能力,支持复杂的聚合和分析,但对实时性要求不高。
  2. 另一部分,也是绝大部分,被格式化为标准列存格式(如 Parquet),然后存入 冷数据层 (Cold Tier)。冷数据层是整个系统的成本中心和最终归宿,通常选用 AWS S3、阿里云 OSS 等公有云对象存储,或自建的 MinIO/Ceph 集群。

在所有数据层的顶端,可以构建一个 统一查询层 (Unified Query Layer),采用 PrestoDB 或 Trino 这类联邦查询引擎。它能让用户通过单一的 SQL 入口,透明地查询分布在 MySQL、ClickHouse 和 S3 上的数据,屏蔽了底层存储的异构性。

最后,整个系统需要完善的 元数据管理 (Metadata Management)生命周期管理 (Lifecycle Management) 模块,用于记录每个数据片段的位置、格式、归档时间,并根据预设策略(例如,S3 中的数据超过 5 年后自动转入 Glacier Deep Archive)进一步降低存储成本。

核心模块设计与实现

空谈架构毫无意义,我们必须深入到代码和工程细节中去。数据迁移服务是整个系统的“心脏”,其设计的优劣直接决定了系统的成败。

数据迁移服务 (Data Mover Service)

极客工程师视角:

这个服务的核心任务是:安全、高效、低侵入 地把数据从热库搬走。别扯什么花哨的架构,早期最直接的办法就是用一个脚本。比如 Percona Toolkit 里的 `pt-archiver`。


# 一个简单粗暴但有效的开始
pt-archiver \
  --source h=online-db,D=trading,t=orders,u=archiver,p=... \
  --where "created_at < NOW() - INTERVAL 30 DAY" \
  --dest h=archive-db,D=trading_archive,t=orders_2023_q1 \
  --limit 1000 \
  --txn-size 1000 \
  --progress 5000 \
  --statistics \
  --execute

这种方法的优点是简单,能快速解决问题。但缺点也显而易见:它直接在主库上执行 `SELECT` 和 `DELETE`,即使分批处理,依然可能对主库造成压力,引发锁竞争。尤其是在繁忙的交易时段,DBA 看到这种操作会提刀来见你。更优雅的方案是基于变更数据捕获(CDC)的流式架构。

基于 CDC 的流式归档

这个方案的核心思想是解耦。在线数据库只管产生数据和变更日志(Binlog),归档系统作为下游消费者去异步处理。我们可以使用 Debezium 或 Canal 等工具捕获 MySQL 的 Binlog,将其转换为 Avro/JSON 格式的消息,然后推送到 Kafka。

数据迁移服务就是一个 Kafka Consumer Group,它消费这些变更事件,然后在内存中重构出完整的记录,再写入冷存储。这个过程需要处理事务边界,例如,一个订单从创建到最终状态可能会有多条 Binlog 事件,服务需要将它们聚合后再进行归档。

下面是一个 Go 语言实现的归档任务核心逻辑伪代码,展示了如何处理从数据库中捞取数据并写入 S3,最后安全删除的完整流程。


package archiver

import (
    "context"
    "database/sql"
    "fmt"
    "github.com/aws/aws-sdk-go-v2/service/s3"
    "log"
)

// ArchiveBatch 演示了归档一个批次数据的核心逻辑
// 这里的核心是事务性和幂等性
func ArchiveBatch(db *sql.DB, s3Client *s3.Client, bucketName string) error {
    // 1. 开启一个事务,从热库中查询并锁定需要归档的行
    // 使用 FOR UPDATE SKIP LOCKED 可以避免与其它归档进程或在线业务争抢锁
    tx, err := db.Begin()
    if err != nil {
        return fmt.Errorf("failed to begin transaction: %w", err)
    }
    defer tx.Rollback() // 保证异常时回滚

    rows, err := tx.Query("SELECT id, order_data FROM orders WHERE created_at < NOW() - INTERVAL 30 DAY LIMIT 1000 FOR UPDATE SKIP LOCKED")
    if err != nil {
        return fmt.Errorf("failed to query orders for archiving: %w", err)
    }
    defer rows.Close()

    var idsToDelete []int64
    var recordsToArchive []interface{} // 实际应为结构化数据

    for rows.Next() {
        var id int64
        var orderData string // 简化为 string,实际是复杂结构
        if err := rows.Scan(&id, &orderData); err != nil {
            log.Printf("failed to scan row: %v", err)
            continue
        }
        idsToDelete = append(idsToDelete, id)
        recordsToArchive = append(recordsToArchive, orderData)
    }

    if len(recordsToArchive) == 0 {
        log.Println("No records to archive in this batch.")
        return nil // 正常结束
    }

    // 2. 将数据写入冷存储(例如 S3 Parquet 文件)
    // 这一步必须是幂等的。文件名可以包含批次ID或内容哈希,防止重复写入。
    s3ObjectKey := fmt.Sprintf("orders/archive-%d.parquet", time.Now().UnixNano())
    err = writeToS3AsParquet(s3Client, bucketName, s3ObjectKey, recordsToArchive)
    if err != nil {
        // 写入S3失败,直接回滚事务,数据库中的行不会被删除,下次重试即可
        return fmt.Errorf("failed to write data to S3: %w", err)
    }

    // 3. 在同一个事务中,删除已经成功归档的数据
    // 因为数据已安全落盘到S3,我们现在可以放心地删除主库的数据了
    // 使用 IN 子句进行批量删除,效率较高
    deleteStmt := "DELETE FROM orders WHERE id IN (?)" // 使用占位符防止SQL注入
    // 此处需要将 idsToDelete 转换为可变参数或拼接SQL,具体看驱动支持
    _, err = tx.Exec(deleteStmt, buildInClause(idsToDelete))
    if err != nil {
        // 删除失败,这是一个棘手的情况。S3上已经有数据了。
        // 此时必须回滚事务。数据会留在主库,造成“已归档但未删除”的状态。
        // 需要有补偿机制或告警来处理这种情况。
        return fmt.Errorf("failed to delete archived records: %w", err)
    }

    // 4. 提交事务
    if err := tx.Commit(); err != nil {
        return fmt.Errorf("failed to commit transaction: %w", err)
    }

    log.Printf("Successfully archived and deleted %d records.", len(idsToDelete))
    return nil
}

// 辅助函数,实际生产中需要更健壮的实现
func writeToS3AsParquet(client *s3.Client, bucket, key string, data []interface{}) error { /* ... 实现细节 ... */ return nil }
func buildInClause(ids []int64) string { /* ... 实现细节 ... */ return "1,2,3" }

这段代码展示了工程上的关键考量:

  • 事务完整性: 查询、写入S3、删除这几个步骤的原子性至关重要。虽然 S3 操作无法纳入数据库事务,但我们将删除操作放在 S3 写入成功之后,并与查询锁定放在同一个数据库事务中,最大限度保证了数据不丢失。
  • 幂等性: 如果服务在 `tx.Commit()` 后崩溃,下次任务启动时可能会处理同一批数据。`SKIP LOCKED` 可以避免重复处理,而向 S3 写入时使用确定性的文件名或在元数据中记录已完成的批次,可以避免数据重复。
  • 容错与恢复: 必须详细记录每一步的日志。当删除失败时,需要发出严重告警,并有手动或自动的工具来清理这些“应该被删除但还活着”的数据。

性能优化与高可用设计

一个生产级别的归档系统,还需要考虑以下对抗性问题:

对抗主库性能抖动:

  • 读写分离: 归档任务的 `SELECT` 操作应该尽可能在只读副本(Read Replica)上执行。这能将归档查询的 I/O 和 CPU 压力从主库剥离。但 `DELETE` 操作必须在主库上执行。
  • 智能限流: 归档服务必须具备动态的速率控制能力。它可以监控主库的延迟、CPU 使用率等指标,当主库压力过大时,自动降低归档批次的大小或增加处理间隔。
  • 时间窗口: 归档任务应配置在交易低峰期执行,例如凌晨。

对抗数据一致性风险:

  • 两阶段删除: 一种更安全的模式是“软删除”。第一阶段,将归档成功的记录在主库标记为 `archived = true`。第二阶段,一个独立的、低优先级的任务再来清理这些已标记的记录。这给了我们一个缓冲期来验证归档数据的完整性。
  • 数据校验: 归档到冷存储后,必须有校验任务。可以是简单的行数对比,也可以是关键字段的哈希值校验。对于金融数据,这种偏执是必要的。数据完整性永远是第一位的。

系统自身的高可用:

  • 无状态服务: 数据迁移服务本身应设计为无状态的,可以水平扩展部署多个实例。通过数据库的 `SKIP LOCKED` 机制或分布式锁(如 ZooKeeper/Etcd)来协调,确保同一批数据不会被多个实例同时处理。
  • 任务调度与监控: 使用成熟的调度系统(如 K8s CronJob, Airflow)来管理归档任务,并接入完善的监控告警体系(Prometheus, Grafana),对归档延迟、失败率、处理速率等关键指标进行实时监控。

架构演进与落地路径

对于大多数团队来说,一口气建成上述的完美系统是不现实的。一个务实的演进路径可能如下:

第一阶段:救火期 (脚本小子)

当主库性能问题已经出现时,最快的方式就是引入 `pt-archiver` 或编写类似的 SQL 脚本,配合 `cron` 定期执行。目标非常明确:尽快把最冷的数据移走,哪怕是移动到同一个实例的另一个库或表中,先缓解主表的压力。此阶段重点是验证归档逻辑的正确性,并积累运维经验。

第二阶段:工程化 (工具人)

将脚本进化为一个独立的、健壮的后台服务。引入配置管理、日志、监控、告警。实现更精细的批处理和限流逻辑。将归档目标从本地数据库升级为专用的、成本更低的温数据存储(如另一组 MySQL 集群或 ClickHouse)。开始在只读副本上执行数据读取,减轻主库压力。

第三阶段:平台化 (架构师)

引入 CDC 和消息队列,实现准实时的、流式归档架构。归档服务与在线业务彻底解耦。将冷数据层标准化为对象存储(S3/OSS),并统一数据格式为 Parquet。这个阶段,归档系统开始从一个单纯的后台运维工具,转变为数据平台的基础设施。数据一旦进入冷存储,就可以被 Spark、Presto 等大数据工具直接消费,为数据分析和机器学习赋能。

第四阶段:服务化 (布道师)

构建统一查询层,为全公司提供透明的数据访问服务。业务方不再需要关心数据到底在哪一层,只需通过一个统一的 SQL Gateway 即可查询。同时,建立完善的数据生命周期管理策略,自动化地将 S3 中的数据根据存放时间进一步降级到更便宜的存储类别(如 S3 Glacier),实现成本的极致优化。此时,数据分级存储架构才算真正完成了它的使命:不仅保障了在线系统的稳定,还盘活了历史数据,使其从成本中心转变为价值中心。

延伸阅读与相关资源

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