数据库分层架构实战——热数据、温数据、冷数据的存储分离方案
数据库分层架构实战——热数据、温数据、冷数据的存储分离方案
适读人群:关注存储成本和查询性能的后端工程师 | 阅读时长:约16分钟 | 核心价值:数据分层不只是省钱,它是解决"数据量增长导致查询变慢"这个普遍问题的系统性方案
从一个"越来越慢"的问题开始
我们有一个订单系统,运行了 3 年,订单表里有 2 亿条数据。
最开始,订单查询很快,1 秒内响应。但随着数据增长,同样的查询,到后来要 5-10 秒,偶尔超时。
加了索引,做了读写分离,上了缓存,都只是临时缓解,治标不治本。
根本原因在于:2 亿条数据里,绝大多数都是 2 年前的历史订单,但它们和最新的订单存在同一张表里,占用着同样的索引空间,每次查询都带着这些历史数据的包袱。
这个问题的本质解法,是数据分层——把不同"温度"的数据,存到不同的存储介质里。
什么是数据温度
热数据:最近一段时间(比如最近 3 个月)频繁读写的数据。需要最快的访问速度,通常在内存或高性能 SSD 上。
温数据:不那么频繁访问,但偶尔还需要查(比如半年到 2 年的数据)。可以接受稍慢的访问速度,用普通 SSD 存储。
冷数据:很少访问,主要用于归档合规(比如 2 年以上的数据)。可以接受更慢的访问速度,用 HDD 或对象存储(如 S3)存储,成本极低。
各个层的特征:
| 层次 | 数据时效 | 访问频率 | 延迟要求 | 存储方案 | 存储成本 |
|---|---|---|---|---|---|
| 热层 | 最近 3 个月 | 高频 | <100ms | MySQL SSD | 高 |
| 温层 | 3 个月~2 年 | 低频 | <1s | ClickHouse/TiDB | 中 |
| 冷层 | 2 年以上 | 极少 | <10s | OSS/S3 | 极低 |
数据分层架构设计
整体架构
查询路由层
关键是:用户查询时,如何判断数据在哪一层?
最简单的方式:按时间判断。查询参数里有开始时间和结束时间,根据时间范围路由到对应的存储层。
@Service
public class OrderQueryRouter {
private static final LocalDate HOT_BOUNDARY = LocalDate.now().minusMonths(3);
private static final LocalDate WARM_BOUNDARY = LocalDate.now().minusYears(2);
public List<OrderVO> queryOrders(OrderQueryCondition condition) {
LocalDate queryStart = condition.getStartDate();
LocalDate queryEnd = condition.getEndDate();
// 判断时间范围,路由到对应存储
if (queryEnd.isAfter(HOT_BOUNDARY)) {
if (queryStart.isAfter(HOT_BOUNDARY)) {
// 全在热层
return hotOrderService.query(condition);
} else if (queryStart.isAfter(WARM_BOUNDARY)) {
// 跨越热层和温层,需要合并结果
List<OrderVO> hot = hotOrderService.query(condition.withStartDate(HOT_BOUNDARY));
List<OrderVO> warm = warmOrderService.query(condition.withEndDate(HOT_BOUNDARY));
return merge(hot, warm);
}
} else if (queryStart.isAfter(WARM_BOUNDARY)) {
// 全在温层
return warmOrderService.query(condition);
} else {
// 涉及冷层...
return queryWithColdData(condition);
}
return Collections.emptyList();
}
}热层:MySQL 优化
热层的核心是:小而快。
策略一:表分区
按月创建分区,每月的数据在独立的物理分区里,查询时只扫描需要的分区:
CREATE TABLE order_hot (
id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2),
status TINYINT,
create_time DATETIME NOT NULL,
-- ...
PRIMARY KEY (id, create_time) -- 分区键必须在主键里
)
PARTITION BY RANGE (TO_DAYS(create_time)) (
PARTITION p202401 VALUES LESS THAN (TO_DAYS('2024-02-01')),
PARTITION p202402 VALUES LESS THAN (TO_DAYS('2024-03-01')),
PARTITION p202403 VALUES LESS THAN (TO_DAYS('2024-04-01')),
PARTITION p_future VALUES LESS THAN MAXVALUE
);每月末,删除最老的分区(自动清理 3 个月以上的数据):
ALTER TABLE order_hot DROP PARTITION p202312;这比 DELETE WHERE create_time < xxx 快几十倍,因为分区删除是直接释放文件,不做逐行删除。
温层:ClickHouse 的合理使用
ClickHouse 是列式存储数据库,非常适合温层的典型查询场景:时间范围查询、聚合统计、大范围扫描。
-- ClickHouse 创建订单归档表
CREATE TABLE order_warm (
id UInt64,
user_id UInt64,
amount Decimal(10,2),
status UInt8,
create_time DateTime
)
ENGINE = MergeTree()
ORDER BY (create_time, user_id)
PARTITION BY toYYYYMM(create_time);ClickHouse 适合的查询(温层):
-- 统计某用户在某年度的消费总额
SELECT sum(amount), count()
FROM order_warm
WHERE user_id = 123 AND create_time BETWEEN '2023-01-01' AND '2023-12-31';ClickHouse 不适合的查询(不要用温层做这种查询):
-- 按订单ID精确查询单条订单的详情(更适合热层或走冷层归档文件)
SELECT * FROM order_warm WHERE id = 999888777;冷层:对象存储归档
2 年以上的数据,迁移到 S3 或阿里云 OSS,以 Parquet 格式存储(压缩比高,支持按列读取):
@Scheduled(cron = "0 2 1 * * ?") // 每月1号凌晨2点执行归档
public void archiveColdData() {
LocalDate archiveBefore = LocalDate.now().minusYears(2);
// 从温层查出要归档的数据
List<Order> toArchive = warmOrderService.findBefore(archiveBefore);
// 按年月分组,写成 Parquet 文件
Map<String, List<Order>> grouped = toArchive.stream()
.collect(Collectors.groupingBy(
o -> o.getCreateTime().format(DateTimeFormatter.ofPattern("yyyy/MM"))
));
grouped.forEach((yearMonth, orders) -> {
String path = "orders/archive/" + yearMonth + "/data.parquet";
parquetWriter.write(path, orders);
ossClient.upload(bucket, path);
});
// 归档成功后,从温层删除
warmOrderService.deleteBefore(archiveBefore);
}从冷层查询数据(很少发生):
public List<OrderVO> queryColdData(String yearMonth, Long userId) {
// 从 OSS 下载对应月份的 Parquet 文件
String path = "orders/archive/" + yearMonth + "/data.parquet";
byte[] parquetData = ossClient.download(bucket, path);
// 读取并过滤
return parquetReader.read(parquetData)
.filter(o -> o.getUserId().equals(userId))
.collect(Collectors.toList());
}踩坑记录
踩坑一:迁移任务影响了线上查询
第一次运行数据迁移任务(热层 → 温层),把几百万条记录从 MySQL 迁移到 ClickHouse,MySQL 的 SELECT 把从库打满了,线上查询超时。
修复: 迁移任务限速,每次批量查询 1000 条,每批次之间 sleep 100ms,同时迁移任务读从库,不读主库。
public void migrateToWarm() {
long lastId = 0;
LocalDate boundary = LocalDate.now().minusMonths(3);
while (true) {
List<Order> batch = orderMapper.findForMigration(
lastId, boundary, 1000
);
if (batch.isEmpty()) break;
warmOrderService.batchInsert(batch);
orderMapper.deleteMigrated(batch.stream().map(Order::getId).collect(Collectors.toList()));
lastId = batch.get(batch.size() - 1).getId();
// 限速,防止打满数据库
TimeUnit.MILLISECONDS.sleep(100);
}
}踩坑二:跨层查询结果合并时的排序问题
用户查询"3 个月前 + 最近 1 个月"的数据,热层和温层各返回一部分,合并时需要按 createTime 重新排序。
但两边的数量可能很大,直接放内存合并 OOM 了。
修复: 设置跨层查询的最大时间范围,超过范围(比如跨层查询最多 6 个月内)直接拒绝,让用户缩小查询范围。并告知这是已知限制。
踩坑三:冷层数据的对账缺失
一年后发现,冷层里有的月份数据少于 MySQL 迁移记录,但没有人知道是迁移时漏了还是从没有。
修复: 迁移后必须做对账——记录迁移的数量,对比温层插入的数量,不一致时告警并暂停迁移。
总结
数据分层不是一蹴而就的,通常是这样的演进路径:
- 数据量小时:只有 MySQL,不需要分层
- 数据量增长,查询变慢:加分区,清理历史数据
- 历史数据有查询需求,不能删:引入 ClickHouse 做温层,MySQL 只保留热数据
- 合规要求长期保留数据:引入 OSS 冷层归档
每一步都是应对真实问题的,不要提前过度设计。
