先说结论

PARTITION BY 决定数据片段的分区目录和生命周期管理;ORDER BY 决定片段内的物理排序,是查询裁剪和压缩的核心;PRIMARY KEY 定义稀疏主索引,默认通常与排序键相同或是其前缀,但不保证唯一。

大多数场景先把高频过滤、基数与数据局部性用于设计排序键,再按删除、归档和分区裁剪需求选择粒度适中的分区键。

设计原则

排序键前部放常用且选择性有效的过滤维度,但最高基数字段不一定总应最前。时间序列常组合租户、业务维度与时间;实际顺序需要用代表性查询和数据分布验证。

三个键分别解决什么问题

ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (tenant_id, event_type, event_time, event_id)
PRIMARY KEY (tenant_id, event_time)
  • 分区键按月把数据组织成独立生命周期单元,便于删除历史、迁移和分区裁剪。
  • 排序键决定每个 part 内数据的物理排序、压缩局部性和主键索引关联的 granule 范围。
  • 主键是稀疏索引表达式,可以是排序键前缀,用于过滤条件下跳过 granule,不提供唯一性检查。

在 ClickHouse 中,“主键”不是关系数据库的约束概念。插入重复 event_id 不会报错;数据模型需要独立保证幂等或选择合适引擎。

排序键怎么从查询反推

收集 Top 查询的 WHERE、GROUP BY、ORDER BY、时间范围和返回量。优先让最常见的高选择性等值过滤形成排序前缀,再考虑时间范围和局部排序。例如多数请求都是 tenant_id = ? AND event_time BETWEEN ...,tenant_id 放前通常比只按时间排序能裁剪更多数据。

但把极高基数且随机的 UUID 放在最前,可能损失时间局部性和压缩;把低基数布尔值放最前又可能几乎不能裁剪。没有固定口诀,必须用真实分布和查询日志验证。

分区不要过细

每个活跃分区会产生 parts、元数据和后台合并任务。按小时甚至分钟分区可能让小批写入产生海量小 part;按年分区又让删除月度历史、冷热迁移和分区裁剪不够灵活。选择按天、周、月通常取决于日写入量、保留周期和运维动作颗粒度。

低写入量、保留多年:按月通常足够
高写入量、按天过期:按天可能合理
持续小批流式写入:先解决批次与 parts,再细分分区

index_granularity 的影响

稀疏索引不是每行一个索引项,而是每隔一组行记录 mark。更小粒度可能提升裁剪精度,却增加 mark 文件、内存与判断开销;更大粒度降低索引成本,却让范围查询读到更多无关行。默认值通常是合理起点,只有在确认查询模式和数据行宽后才应调整。

建模变更策略

ORDER BY 一旦确定,历史数据物理组织不会自动改变。要调整排序或分区,常见流程是新建表、回填历史、双写或切换写入、验证查询、原子切换别名并保留回退。不要在高峰期对大表直接做重 mutation 期待“在线改排序键”。

分区边界

分区过细会产生大量活跃分区和数据片段,加重元数据与 Merge 压力。按天、月或业务域划分应结合写入量、保留周期和查询范围,而不是简单追求更细裁剪。

容易踩坑的地方

把 MySQL 主键概念直接套用会误以为能防重复。排序键也不是建表后轻易改变的普通索引,它决定数据物理组织,调整通常需要新表迁移。

常见问题

追问:PRIMARY KEY 可以与 ORDER BY 不同吗?

可以,但通常要求主键表达式与排序键兼容,常见是排序键前缀。分开设计用于在索引大小和排序局部性之间权衡。

追问:按时间分区是否意味着所有时间查询都快?

只有谓词能被分区表达式识别并裁剪时才会少读分区;跨很长时间范围仍会扫描很多分区,且 part 内是否高效还取决于排序键。

追问:为什么 ORDER BY 不等于查询 SQL 的 ORDER BY?

前者是物理存储排序,帮助读取局部有序数据;后者是每次查询的结果排序。若 SQL 排序与物理顺序不兼容,仍可能需要额外排序。

追问:小分区造成的主要危害是什么?

parts 和文件数量增加、后台 merge 分散、元数据膨胀、查询需要打开更多 part,最终影响写入、查询和恢复速度。