4. 数据模型设计:Schema设计原则、分区键与排序键选择、TTL与数据生命周期

数据模型设计,说白了就是决定数据怎么存、怎么取、怎么淘汰。很多刚接触 ClickHouse 的朋友,上来就按 MySQL 那套建表,结果查询慢、磁盘爆、合并卡死。我早期踩过不少这种坑,今天把核心原则和实战经验摊开讲。

4.1 Schema 设计原则:宽表优先,字段精简

ClickHouse 是列存数据库,它的强项是扫描少量列、处理大量行。所以设计 Schema 时,我建议你记住三个字:宽、平、少。

  • 宽表优先:尽量把维度字段拉平到事实表中,避免 Join。Join 在 ClickHouse 里性能一般,能省则省。
  • 字段类型精打细算:能用 Int32 就别用 Int64,能用 LowCardinality(String) 就别用普通 String。类型越小,扫描越快。
  • Nullable 慎用:Nullable 列会额外生成一个标记文件,查询时多一次判断。我习惯用默认值代替 Null,比如空字符串或 0。
我的习惯:建表前先列出所有查询模式,然后反推需要的列。不需要的列,坚决不加。字段越少,合并越快。

4.2 分区键选择:粒度与查询的平衡

分区键决定了数据在物理上怎么切分。选得好,查询可以跳过大量无关数据;选得不好,分区过多反而拖慢合并。

我见过有人按小时分区,结果一天 24 个分区,一个月 720 个。ClickHouse 官方建议分区数控制在 1000 以内,超过这个数,后台合并线程会忙不过来。

分区粒度 适用场景 分区数估算(月) 风险
按天 日志、监控、时序数据 30 低
按周 低频查询、归档数据 4-5 低
按月 历史报表、聚合查询 1 低
按小时 实时流、高频写入 720 高(分区爆炸)

我的建议:大部分场景按天分区就够了。如果数据量特别大(每天几亿行),可以按 toYYYYMMDD(event_time) 分区,再配合排序键做细粒度过滤。

避坑指南:我曾经接手过一个集群,分区键用了 toString(event_type),结果 event_type 有 2000 多种,一个月产生了 6 万个分区。合并队列堵死,写入延迟飙升。后来改成按天分区,问题解决。

4.3 排序键选择:查询加速的核心

排序键(ORDER BY)决定了数据在分区内的物理顺序。ClickHouse 的稀疏索引就是基于排序键构建的。选对了,点查可以秒级返回;选错了,全表扫描跑不掉。

排序键的设计原则很简单:高频过滤字段放前面,基数低的放前面。

  • 第一字段:通常是时间字段,因为大部分查询都有时间范围。
  • 第二字段:业务维度,比如用户 ID、城市 ID。
  • 第三字段及以后:其他过滤条件,按查询频率降序排列。
-- 推荐:时间 + 高频维度
CREATE TABLE events (
    event_time DateTime,
    user_id UInt32,
    event_type String,
    ... 
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(event_time)
ORDER BY (event_time, user_id, event_type);

-- 不推荐:把低基数字段放前面
-- ORDER BY (event_type, event_time, user_id)  -- event_type 基数低,但查询很少用它过滤

你想想看,如果查询条件里没有 event_type,排序键的第一个字段就浪费了。索引跳不过去,还得扫全分区。

核心要点:排序键不是索引,但它决定了索引的构建方式。把最常用的过滤条件放在最左边,查询性能提升最明显。

4.4 TTL 与数据生命周期:自动淘汰,省心省力

数据不能无限堆积。ClickHouse 提供了 TTL(Time To Live)机制,可以自动删除或移动过期数据。我习惯把 TTL 分为两级:热数据和冷数据。

4.4.1 行级 TTL:按行过期删除

最常用的方式。指定一个时间列,超过一定天数后自动删除。

CREATE TABLE logs (
    timestamp DateTime,
    message String,
    level String
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (timestamp, level)
TTL timestamp + INTERVAL 30 DAY DELETE;

这样 30 天前的数据会自动清理,不用写定时任务。嗯,这里要注意:TTL 不是实时触发的,合并时才会执行。所以过期数据可能还会存在几个小时。

4.4.2 列级 TTL:部分字段提前过期

有些字段只在近期有用,比如原始请求体。过了一周就可以置空,节省空间。

CREATE TABLE api_logs (
    request_time DateTime,
    url String,
    request_body String TTL request_time + INTERVAL 7 DAY,
    response_body String TTL request_time + INTERVAL 30 DAY
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(request_time)
ORDER BY request_time;

request_body 7 天后自动置空,response_body 30 天后置空。这样既保留了核心字段,又控制了存储成本。

4.4.3 移动 TTL:冷热分层

如果集群有冷热存储(比如 SSD + HDD),可以用 TTL 把冷数据自动迁移到廉价存储。

CREATE TABLE metrics (
    ts DateTime,
    metric_name String,
    value Float64
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(ts)
ORDER BY (ts, metric_name)
TTL ts + INTERVAL 7 DAY TO VOLUME 'cold',
    ts + INTERVAL 30 DAY DELETE;

7 天后数据从 SSD 移到 HDD,30 天后删除。这个机制我特别喜欢,运维成本几乎为零。

我的经验:TTL 表达式里不要用 now(),要用具体的列。因为 now() 是查询时计算的,会导致每次合并都重新计算 TTL,性能很差。用 timestamp + INTERVAL 30 DAY 才是正确的写法。

4.5 知识体系总览

下面这张图把本章的核心逻辑串起来了。你可以看到,从 Schema 设计到分区、排序、TTL,是一条完整的链路。

数据模型设计核心链路 Schema 设计:宽表 + 精确类型 分区键:按天 / 按月,控制分区数 排序键:高频字段放左,低基数优先 TTL:行级 / 列级 / 移动,自动淘汰 常见误区 • 分区过多导致合并阻塞 • 排序键与查询不匹配 • TTL 用 now() 导致性能差 总结:Schema 定结构 → 分区键定物理切分 → 排序键定索引顺序 → TTL 定生命周期 四步联动,才能构建高性能、低运维的 ClickHouse 数据模型

4.6 实战建议:从查询反推模型

说了这么多原则,落地时怎么操作?我一般按这个步骤来:

  1. 列出所有查询:把业务方常用的 SQL 收集起来,标注出 WHERE 条件里的字段。
  2. 确定排序键:把 WHERE 条件里出现频率最高的字段,按顺序排好。
  3. 确定分区键:看查询是否有时间范围。如果有,按天分区;如果没有,考虑按月或按业务维度。
  4. 设计 TTL:跟业务确认数据保留周期,设置行级或移动 TTL。
  5. 建表测试:用真实数据量压测,观察查询延迟和合并速度。
我曾经踩过的坑:有一次我按业务方的「理想查询」设计了排序键,结果上线后发现实际查询根本不走那个字段。排序键完全浪费,查询全表扫描。后来我改成先分析慢查询日志,再反推排序键,效果好了很多。

数据模型设计没有银弹,但遵循「宽表 + 精确类型 + 合理分区 + 匹配排序键 + 自动 TTL」这条链路,至少能保证 80% 的场景不出大问题。剩下的 20%,靠监控和持续调优来解决。