先说结论
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,最终影响写入、查询和恢复速度。