4. 数据模型设计
数据模型设计,说白了就是决定数据怎么存、怎么取、怎么淘汰。很多刚接触 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 天后删除。这个机制我特别喜欢,运维成本几乎为零。
now(),要用具体的列。因为 now() 是查询时计算的,会导致每次合并都重新计算 TTL,性能很差。用 timestamp + INTERVAL 30 DAY 才是正确的写法。
4.5 知识体系总览
下面这张图把本章的核心逻辑串起来了。你可以看到,从 Schema 设计到分区、排序、TTL,是一条完整的链路。
4.6 实战建议:从查询反推模型
说了这么多原则,落地时怎么操作?我一般按这个步骤来:
- 列出所有查询:把业务方常用的 SQL 收集起来,标注出 WHERE 条件里的字段。
- 确定排序键:把 WHERE 条件里出现频率最高的字段,按顺序排好。
- 确定分区键:看查询是否有时间范围。如果有,按天分区;如果没有,考虑按月或按业务维度。
- 设计 TTL:跟业务确认数据保留周期,设置行级或移动 TTL。
- 建表测试:用真实数据量压测,观察查询延迟和合并速度。
数据模型设计没有银弹,但遵循「宽表 + 精确类型 + 合理分区 + 匹配排序键 + 自动 TTL」这条链路,至少能保证 80% 的场景不出大问题。剩下的 20%,靠监控和持续调优来解决。