Skip to content
Charles Shao
Go back

数据冷热分离:边界划分、迁移与查询路由

views

不是所有数据都值得占据最快、最贵的存储。按访问频率和业务重要性把数据拆成冷热,热的放高性能介质、冷的放低成本介质——这就是数据冷热分离

本篇讲清冷热怎么划、迁移怎么做、冷库怎么选,以及查询怎么路由;用一个电商订单表把成本与延迟收益算到量级。全篇附冷热路由、Canal/binlog、xxl-job 定时归档、ClickHouse/对象存储写入等可运行代码。

TL;DR

Table of contents

Open Table of contents

1. 冷热分离要解决什么

数据冷热分离是指根据数据的访问频率和业务重要性,将数据区分为冷数据和热数据,冷数据存储在低成本、低性能的介质中,热数据存储在高性能介质中。

1.1 冷数据与热数据

热数据:访问频繁、读写活跃,对响应延迟敏感,需要存放在高性能介质(如 SSD、内存数据库)以保障用户体验。

冷数据:访问频率低,对当前业务的直接价值较低,但通常需要长期保存(如审计、对账、历史回溯),可以接受较高访问延迟,适合放在低成本介质(如 HDD、对象存储)。

冷热数据的划分维度:

需要注意的是,时间旧不等于冷——例如某些优质文章发布多年仍有大量读者访问,而大量新发布的内容却鲜有人浏览。实际项目中,通常将两种维度结合使用以提高划分准确度。

冷热数据对比示意:热数据高频访问放 SSD/高性能库,冷数据低频长期保存放 HDD/低成本介质

热数据上高性能介质,冷数据上低成本介质。

1.2 冷热边界怎么划

划定冷热边界,实践中常见以下四类场景:

业务场景冷热边界示例推荐划分依据
订单系统1 年以内为热;超 1 年为冷(对账、审计仍需)时间维度(创建时间)
日志/审计近 3 个月为热(实时检索);更早为冷(归档合规)时间维度(生成时间)
用户画像近 30 天活跃用户为热;长期不活跃为冷访问频率(最后活跃时间)
内容 Feed近 6 个月内发布且有阅读记录为热;其余为冷时间 + 访问频率结合

决策建议

  1. 时间维度优先:最简单可靠,运维成本低,适合大多数业务。迁移脚本里直接写 WHERE created_at < '2024-01-01' 即可,不需要额外的访问统计基础设施。
  2. 访问频率维度代价高:需要持续记录访问时间戳并定期打标,且「最近未被访问」≠「以后不会被访问」,有误判风险。
  3. 两者结合精度最高:先按时间粗筛,再对剩余数据按访问频率精筛;但实现复杂,适合数据量真正大且已积累完整访问日志的系统。
  4. 预留挽救窗口:冷数据刚迁移后不应立即删除热库原始数据,建议保留 1–4 周再做清理,便于快速回查和回滚。

1.3 按价值分配存储资源

冷热分离的核心是按价值与频率分配存储资源,在成本与性能之间取得更优的平衡。这一思想广泛应用于各类系统:

1.4 收益与代价

优点

缺点

一句话:冷热分离换的是热路径体验与存储成本,付的是复杂度与跨库统计

1.5 何时不该做冷热分离

以下场景分离收益往往抵不上引入的复杂度:

1.6 与其他扩展手段的关系

冷热分离常与读写分离、分库分表一起出现在数据库扩展的讨论中(参见 数据库性能与扩展),三者解决的问题并不相同,实践中通常组合使用而非互相替代:

手段解决的问题触发条件
冷热分离按访问价值分层存储,压缩热路径数据量单表数据量大,且冷热访问频率差异明显
读写分离分摊读压力,写与读走不同实例读远多于写,单实例读吞吐已成瓶颈
分库分表突破单机存储与连接数上限单表/单库数据量或并发已逼近物理极限

三者的顺序通常是:先做读写分离缓解读压力,若数据量持续增长再引入冷热分离压缩热表体量,仍不够时才考虑分库分表——分库分表的迁移与运维成本最高,应作为最后手段而非首选。

2. 冷数据迁移方案

冷数据迁移的三种主要方式:

  1. 业务层代码分流:在写入时判断数据冷热,分别写入不同存储。侵入业务代码、冷热边界不易维护,且对性能有影响,一般不推荐。

  2. 定时任务扫描:利用 xxl-job 等分布式任务调度平台定期扫描热库,找出满足冷数据条件的记录,批量复制到冷库并从热库删除。代码改动量极小,适合按时间维度划分冷热的场景。

  3. 监听数据库 binlog:从 binlog 中提取满足冷数据条件的变更记录,异步复制到冷库并清理热库。几乎不改动业务代码,但实时性依赖 binlog 延迟。binlog 本质是增量变更流,更擅长「随业务状态变更实时触发」的冷热判定;对纯粹按固定时间点归档的场景,它并非最顺手的选择——存量历史冷数据无法从 binlog 回放,仍需一次性迁移脚本兜底。换言之,它更适合按条件/事件维度而非按时间维度划分(但时间维度也并非完全不能用 binlog,只是要额外配合存量脚本,不如定时任务直接)。

冷数据迁移方案示意:业务层分流、任务调度定时扫描、监听 binlog 三种方式对比

迁移三条路:业务分流、定时任务、binlog 监听。

若团队有 DBA 支持,也可先由 DBA 执行一次性的历史冷数据迁移,再搭配上述方案持续维护后续新增冷数据。

2.1 定时任务 vs 监听 binlog:怎么选

方案 2(定时任务扫描)与方案 3(监听 binlog)是工程中最常用的两种自动化迁移方式,但适用条件明显不同:

维度定时任务扫描监听 binlog
适用冷热维度时间维度(如 created_at < 阈值条件/事件维度(随业务字段变更触发)
实时性分钟级到小时级,取决于调度周期秒级到分钟级,取决于消费延迟
历史存量数据可直接批量扫描迁移无法处理——binlog 只携带增量变更,历史数据需配合一次性迁移脚本
业务代码改动无(但需接入 Canal / Debezium / Maxwell 等中间件)
基础设施依赖仅需分布式任务调度平台(xxl-job 等)需部署并维护 binlog 订阅链路,运维成本更高
对热库压力批量扫描可能扫大范围数据,需限速/分批执行增量消费几乎不增加额外读压力
典型场景订单、日志等按固定时间点归档按业务状态变更触发冷热判定(如「已关闭工单」「已完结售后」)

决策建议:按时间维度划分冷热时,定时任务扫描已经足够简单可靠,不必引入 binlog 链路;只有当冷热边界依赖业务状态变更(而非固定时间点)时,才值得为此投入 binlog 订阅的基础设施成本。

2.2 迁移安全保障

无论选用哪种迁移方案,以下安全措施都应纳入上线计划:

双写窗口(Dual-Write Window)

在割接前先以「只写冷库、不删热库」的方式运行一段时间(通常 1–4 周),验证冷库数据完整性后再统一清理热库对应记录。双写窗口期间热库存储成本会临时增加,属正常代价。

迁移后校验

迁移完成后,通过以下步骤核实数据完整性:

  1. 对比冷热库中目标时间段内的行数是否一致;
  2. 对关键字段做 checksum 比对(如 MD5(id || amount || status)),或对样本行逐字段对比;
  3. 对 10%–20% 的迁移记录做随机抽查,验证字段值未发生截断或类型转换错误。

回滚通道

2.3 迁移实现示例

定时任务扫描(xxl-job):核心是分批 + 游标 + 限速 + 幂等——按主键游标翻页避免深分页,写冷库用唯一键幂等(任务中断重跑安全),删热库只做逻辑标记以保留回滚窗口,每批之间限速避免扫描压垮热库。

@XxlJob("archiveColdOrders")
public void archiveColdOrders() {
    LocalDate boundary = LocalDate.now().minusYears(1);
    long lastId = 0;
    int batch = 1000;
    while (!Thread.currentThread().isInterrupted()) {
        List<Order> rows = hotOrderRepo.findColdBatch(boundary, lastId, batch);
        if (rows.isEmpty()) break;
        coldOrderRepo.batchUpsert(rows);
        hotOrderRepo.markArchived(rows.stream().map(Order::getId).toList());
        lastId = rows.get(rows.size() - 1).getId();
        XxlJobHelper.log("archived {} rows, lastId={}", rows.size(), lastId);
        sleepForRateLimit(200);
    }
}

监听 binlog(Canal):当冷热判定依赖业务状态变更(如订单「已关闭」、工单「已完结」)时,用 Canal 订阅热库 binlog,过滤目标表后投递到 MQ,由归档消费者按状态判定并写冷库。配置只关注目标库表,避免全量订阅浪费:

# Canal instance:订阅订单库 binlog,仅关注 orders 表
canal.instance.master.address=10.0.0.11:3306
canal.instance.dbUsername=canal
canal.instance.dbPassword=***
canal.instance.filter.regex=ad_db\\.orders
# 投递到 MQ,由归档消费者按业务状态(如 status=CLOSED)判定冷热
canal.mq.topic=cold-archive
canal.mq.partitionsNum=3

Canal 只能捕获接入之后的增量变更,接入前的存量历史冷数据必须先用一次性迁移脚本回填一遍,二者配合才完整——这也是 2 节末尾「先由 DBA 做一次性历史迁移,再挂持续管道」的由来。

3. 冷数据存储选型

冷数据存储的核心要求:大容量、低成本、高可靠,访问速度可适当牺牲。

规模推荐方案
中小厂直接使用 MySQL / PostgreSQL,新增冷表或独立冷库(涉及跨库查询,需评估复杂度)
大厂HBase(常用,列式存储,适合稀疏大表)、RocksDB、Doris(适合 OLAP 分析)、Cassandra
一体化方案TiDB 6.0+ 支持数据冷热存储分离(Data Placement),同一集群内热数据写 SSD、冷数据写 HDD,降低运维与跨库复杂度

冷热分离存储架构示意:应用分别读写热库与冷库,定时迁移冷数据

应用分读写热库与冷库,冷数据按策略迁出热库。

3.1 存储介质成本

不同存储介质的成本差异显著,选型时结合访问频率与预算综合权衡:

介质典型冷热分离用途相对成本说明
SSD热库(高性能 DB、Redis)I/O 延迟最低(< 1ms),适合 OLTP 热路径
HDD暖/冷库(MySQL 历史库、HBase)顺序读写吞吐尚可,随机 I/O 较慢(约 SSD 的 1/10)
对象存储归档冷库(S3/OSS/COS)成本约为 SSD 的 1/10–1/30;读延迟高(100ms+),适合按需偶发访问

实践建议:冷数据量越大,下沉到对象存储的收益越显著。但若冷数据仍有每天数百次以上的查询,直接对接对象存储会带来明显延迟——此时先下沉到 HDD 冷库,仅对极少访问的归档数据再上对象存储,形成三级分层。

3.2 冷层写入:批量落 ClickHouse / 对象存储

冷库写入与热库有一个根本差异:冷库为吞吐而非低延迟优化,因此要攒批写、避免逐行 INSERT。以列存冷库(ClickHouse)为例,单次写入应聚合到万行量级,让列存压缩与后台合并发挥效率:

// 冷层写入 ClickHouse:攒批 + 大 batch,避免逐行 INSERT(列存对小批写极不友好)
public void flushToClickHouse(List<Order> batch) {
    String sql = "INSERT INTO cold.orders "
        + "(id, user_id, amount, status, created_at) VALUES (?, ?, ?, ?, ?)";
    try (PreparedStatement ps = clickHouseConn.prepareStatement(sql)) {
        for (Order o : batch) {              // 单批建议 5k~50k 行
            ps.setLong(1, o.getId());
            ps.setLong(2, o.getUserId());
            ps.setBigDecimal(3, o.getAmount());
            ps.setString(4, o.getStatus());
            ps.setTimestamp(5, Timestamp.valueOf(o.getCreatedAt()));
            ps.addBatch();
        }
        ps.executeBatch();                   // 表引擎选 ReplacingMergeTree,按 id 去重保证幂等重放
    }
}

若冷数据几乎不再随机访问、只用于合规留存与离线回溯,可直接归档为对象存储上的 Parquet 分区(按月/按 campaign 分目录),交给数仓引擎按需扫描:

// 归档为对象存储 Parquet:按时间分区落 OSS/S3,交由 Athena/Spark/ClickHouse(S3 引擎) 按需查询
public void archiveToObjectStore(YearMonth month, List<Order> rows) {
    String key = String.format("orders/dt=%d-%02d/part-%s.parquet",
        month.getYear(), month.getMonthValue(), UUID.randomUUID());
    try (ParquetWriter<Order> writer = ParquetWriter.builder(ossPath(key))
            .withCompressionCodec(CompressionCodecName.ZSTD)   // 列存 + ZSTD,冷数据压缩比优先
            .withWriteMode(Mode.OVERWRITE)                     // 分区级覆盖写,重跑幂等
            .build()) {
        for (Order o : rows) writer.write(o);
    }
    // 写完注册分区到元数据(Hive Metastore / Glue),下游按 dt 分区裁剪扫描
    catalog.addPartition("cold_orders", month, key);
}

冷层写入的两条铁律:批量(列存/对象存储对小批写代价极高)与幂等(迁移任务可能中断重跑,用去重表引擎或分区级覆盖写保证重放安全)。

4. 真实案例:订单表容量测算

用一个虚构但具有代表性的电商订单表说明冷热分离的收益量级(数字为便于说明的估算,实际收益因业务而异)。

背景:订单表总量 2 亿行,年新增约 6000 万行,单行含二级索引平均占用约 800B,全表(含索引)总占用约 160GB,全部存放在 SSD 云盘上。按创建时间 1 年为界划分冷热:近 1 年订单约 6000 万行(占 30%),历史订单约 1.4 亿行(占 70%)。

阶段数据量存储介质表大小说明
迁移前2 亿行(全量)SSD约 160GB索引随全表膨胀,Buffer Pool 命中率下降
迁移后 · 热库6000 万行(近 1 年)SSD约 48GB索引基本可驻留内存,P99 明显下降
迁移后 · 冷库1.4 亿行(历史)对象存储归档约 112GB单价约为 SSD 的 1/20,仅按需访问

存储成本(以 SSD 等效容量估算,对象存储单价按 SSD 的 1/20 计):

通用公式:综合成本 ≈ 热数据量 × 热介质单价 + 冷数据量 × 冷介质单价。冷数据占比越高、冷热介质单价差越大,冷热分离的成本收益就越显著;若冷数据占比不到 20%,收益往往不足以覆盖迁移与维护的复杂度成本(对照 1.5 节)。

查询性能:线上高频查询(订单详情、售后、催付提醒)几乎全部落在近 1 年范围内。热库表体量缩小 70% 后,索引更容易被 Buffer Pool 完整缓存,P99 查询延迟从迁移前的约 35ms 降至约 10ms 量级;剩余约 1% 的历史订单查询(年度对账、审计取证)改走独立存档 API(见 5.2),可接受数百毫秒到秒级的延迟。

写路径的隐性收益:上述测算只讨论了读,实际上写路径同样受益——订单表的写入(创建、状态更新)几乎全部发生在近 1 年范围内,迁移后写入始终落在体量缩小 70% 的热表上,INSERT/UPDATE 需要维护的索引更小,二级索引的随机写放大也随之下降,高峰期写延迟的抖动通常比读延迟收敛得更明显。

以上数字只是示意性估算,实际收益取决于索引结构、访问模式与存储选型报价,但方向具有代表性:冷热分离对存储成本的收益通常是数量级的,对热路径延迟的收益通常是倍数级的——这也是它常被列为数据量突破千万行门槛后的高优先级优化项的原因(对照 1.5 节的门槛判断)。

5. 查询路由模式

业务代码需要知道去哪里取数据:热库还是冷库,或是两者都要?常见三种路由模式:

模式业务侵入性典型延迟适用场景
业务层双读高——代码需感知冷热边界低(直连库)冷热边界固定、逻辑简单的小系统
独立存档 API中——新增一套接口中~高(可接受秒级)后台管理、对账、客服历史查询等内部场景
透明代理低——业务基本无感知低~中(多一层代理)老系统改造、对业务侵入零容忍的场景

5.1 业务层双读

在 Service 或 Repository 层显式判断:若请求的数据在热库时间范围内,查热库;否则查冷库;若结果为空且不确定冷热分布,则再查另一侧兜底。

public Order getOrder(Long orderId, LocalDate orderDate) {
    boolean isHot = orderDate.isAfter(LocalDate.now().minusYears(1));
    Order order = isHot ? hotOrderRepo.findById(orderId)
                        : coldOrderRepo.findById(orderId);
    // 边界附近或调用方传入日期不可信时,回查另一侧兜底
    if (order == null) {
        order = isHot ? coldOrderRepo.findById(orderId)
                      : hotOrderRepo.findById(orderId);
    }
    return order;
}

适用:逻辑简单、冷热边界固定的场景;缺点:业务代码需感知冷热分布,侵入性高,边界调整时需改动代码,且兜底回查可能放大冷库压力。

5.2 独立存档 API

专门为冷数据暴露独立的查询接口(如 /api/orders/archive/{id}),调用方明确知道在查历史数据,且可接受较高延迟(如 5–30s)。

适用:后台管理系统、对账服务、客服历史查询等内部场景;优点:热路径不受影响,服务端可在存档 API 中单独加索引、加限流,对延迟不敏感。

5.3 透明代理

在数据访问层(中间件或代理层,如 ShardingSphere-Proxy、自研 DB Proxy)自动识别请求,按规则路由到热库或冷库,业务代码无感知。

适用:对业务侵入零容忍、改造量大的系统;缺点:代理层本身成为新的维护点,Schema 变更需同步更新路由规则,运维复杂度增加。

选型建议:新项目优先用「独立存档 API」隔离冷热访问路径;改造老系统时若修改成本高可考虑透明代理;业务层双读仅适合冷热边界极简单、访问逻辑明确的场景。

5.4 按时间范围路由:跨冷热的区间查询

点查(按主键 + 时间)好办,麻烦的是跨冷热边界的区间查询(如「查我近 18 个月的订单」)。此时按查询时间窗口与冷热边界的关系拆分:完全落在热区走热库,完全落在冷区走冷库,跨界则拆成两段分别查询再合并——关键是给冷段查询套上时间跨度上限与限流,防止一个大区间把冷库(乃至误路由回热库)打爆:

public List<Order> queryByRange(Long userId, LocalDate from, LocalDate to) {
    LocalDate boundary = LocalDate.now().minusYears(1);
    if (!from.isBefore(boundary)) {                 // 整段在热区
        return hotOrderRepo.findByUserAndRange(userId, from, to);
    }
    if (to.isBefore(boundary)) {                    // 整段在冷区
        guardColdSpan(from, to);                     // 冷段跨度/频率护栏,超限降级为异步任务
        return coldOrderRepo.findByUserAndRange(userId, from, to);
    }
    // 跨界:拆两段,冷段走冷库、热段走热库,再按时间归并
    guardColdSpan(from, boundary);
    List<Order> cold = coldOrderRepo.findByUserAndRange(userId, from, boundary);
    List<Order> hot  = hotOrderRepo.findByUserAndRange(userId, boundary, to);
    return Stream.concat(cold.stream(), hot.stream())
        .sorted(Comparator.comparing(Order::getCreatedAt))
        .toList();
}

跨界查询是冷热分离最容易「阴沟翻船」的地方:边界附近的数据可能正处于双写窗口(冷热都有),归并时要按业务主键去重;冷段务必有跨度上限,把「拉全部历史」这类请求挡在护栏外。

6. 常见陷阱

6.1 热数据被误归档

场景:按「创建时间 > 1 年」迁移,但某些热门商品详情页每天仍有数千次访问——这些数据进冷库后,每次查询都触发高延迟冷读,用户体验骤降。

应对:迁移脚本执行前,先关联访问日志过滤最近 30 天仍有访问记录的数据,将其豁免于本次迁移批次;或在冷库前加一层 TTL 较短的缓存(如 Redis,TTL 5 分钟)来吸收偶发热读,参见 缓存机制

6.2 冷查询打穿 OLTP 热库

场景:后台报表写了 SELECT * FROM orders WHERE created_at BETWEEN '2022-01-01' AND '2022-12-31',若此时冷数据尚未迁走,该全量扫描会直接压垮 OLTP 实例,影响线上用户。

应对:为冷热分离设立「只读冷库副本」或独立只读实例,专门承接历史数据查询,与 OLTP 主库完全隔离;同时在查询入口对大范围历史查询做频控或异步化(先提交任务,结果异步推送)。

6.3 冷热 Schema 漂移

场景:热库表结构随业务迭代持续变更(加字段、改类型),但冷库长期未同步,导致对账或数据回溯时字段对不上、类型转换报错,严重时需要人工修数据。

应对:将冷库 Schema 变更纳入 DDL 上线流程,与热库同步评审;或冷库使用 Schema-less 存储(HBase、对象存储 Parquet)降低 Schema 约束,用转换层在读时做字段映射。

6.4 双写窗口期成本被低估

场景:为了验证冷库数据完整性(2.2 节),团队在双写窗口期同时保留热库全量数据与冷库副本,但预算和容量规划只按迁移后的最终态估算,导致双写期间热库存储成本临时超支,甚至触发磁盘容量告警。

应对:在迁移排期中把双写窗口的存储成本显式纳入预算,而不是只算迁移后的终态;窗口期结束、数据校验通过后要及时清理热库中已确认迁移成功的记录,避免「双写变成常态」。

延伸阅读


views
Share this post on:

Previous Post
数据库性能与扩展:索引、读写分离与分库分表
Next Post
负载均衡:四层与七层、算法选型与生产实践