面试考察点
- 是否理解分区、排序与稀疏索引的不同职责。
- 能否根据查询模式设计 ORDER BY 前缀。
- 是否知道 ClickHouse 主键不提供唯一约束。
核心答案
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,最终影响写入、查询和恢复速度。
总结
分区管理数据片段,排序键组织数据,主键建立稀疏索引;建模应以真实查询和生命周期为依据。
机制全景图
下面把「ClickHouse 分区键、排序键和主键有什么区别?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。
flowchart LR
A["按 PARTITION BY 路由数据"]
A --> B["按 ORDER BY 建立物理顺序"]
B --> C["PRIMARY KEY 生成稀疏标记"]
C --> D["查询逐级裁剪"]
D --> E["TTL/删除按分区管理"]
完整链路:从输入到结果
沿着「按 PARTITION BY 路由数据 → 按 ORDER BY 建立物理顺序 → PRIMARY KEY 生成稀疏标记 → 查询逐级裁剪 → TTL/删除按分区管理」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。
1. 按 PARTITION BY 路由数据
分区用于数据生命周期和粗粒度裁剪,通常按月/日等低基数时间窗口设计。
2. 按 ORDER BY 建立物理顺序
ORDER BY 决定 Part 内物理排序、压缩局部性和主键裁剪能力,是最重要的数据布局。
3. PRIMARY KEY 生成稀疏标记
PRIMARY KEY 默认等于 ORDER BY,也可取其前缀,只生成稀疏索引而不提供唯一约束。
4. 查询逐级裁剪
查询依次利用分区条件、主键范围和数据跳数索引,过滤顺序与列相关性决定读取量。
5. TTL/删除按分区管理
DROP PARTITION 和 TTL 可快速管理整段数据,粒度过细则让 Part 与 ZooKeeper/元数据成本上升。
源码与实现定位
| 入口 | 阅读重点 |
|---|---|
| system.parts | 分区与 Part 物理分布 |
| EXPLAIN indexes=1 | 分区/主键裁剪 |
源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。
参数配置与可复现实验
EXPLAIN indexes=1 SELECT count() FROM events WHERE tenant_id=7 AND event_time>=now()-INTERVAL 1 DAY;
以三种 ORDER BY 回放 Top 查询,比较读取 granule 和压缩比。
验证步骤与预期结果
1. 固定输入和基线
先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「read_rows/result_rows」为主基线,记录值应满足「记录稳态基线」;同时保存 分区数量、每分区 active parts,使后续变化能够回到同一时间轴比较。
2. 从实现入口确认路径
在「system.parts」确认请求确实进入「分区与 Part 物理分布」对应的实现,再沿「EXPLAIN indexes=1」观察「分区/主键裁剪」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。
3. 注入本文特有的失败模式
优先复现「高基数业务字段做分区」,并把单一变量逐级放大,直到「read_rows/result_rows」越过「>100」。随后再分别验证「ORDER BY 首列与主要过滤无关」和「把 PRIMARY KEY 当唯一约束」,三类故障分开执行,避免多个变量同时变化而无法归因。
4. 执行止损和根因修复
第一轮只应用「低基数时间分区」,确认它能控制影响范围;第二轮应用「ORDER BY 前缀匹配查询」,验证核心链路恢复;最后落实「禁止把主键当唯一约束」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。
5. 通过退出条件
实验只有同时满足三项才算通过:「read_rows/result_rows」回到「记录稳态基线」、「read_rows/result_rows P99」回到「同负载可复现」、「结果核对」回到「差异为 0」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。
量化基线
| 指标 | 样例基线/口径 | 风险线 | 结论 |
|---|---|---|---|
| read_rows/result_rows | 记录稳态基线 | >100 | 排序键失配 |
| read_rows/result_rows P99 | 同负载可复现 | 超过基线 2 倍 | 排序键失配 |
| 结果核对 | 差异为 0 | 任意非零 | 停止切换并修复 |
这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。
事故复盘:按 user_id 分区导致集群无法写入
设计者希望查询单用户更快,把高基数 user_id 作为分区,结果每天产生海量分区与小 Part。改为按月分区、ORDER BY (tenant_id,user_id,event_time) 后,生命周期管理与用户过滤都能兼顾。
| 失败模式 | 首要证据 | 第一处置动作 |
|---|---|---|
| 高基数业务字段做分区 | 分区数量 | 低基数时间分区 |
| ORDER BY 首列与主要过滤无关 | 每分区 active parts | ORDER BY 前缀匹配查询 |
| 把 PRIMARY KEY 当唯一约束 | 主键裁剪率 | 禁止把主键当唯一约束 |
发布与回滚检查点
- 发布前:确认「system.parts」对应实现和上述配置在目标版本仍然有效,并保存「read_rows/result_rows」基线。
- 灰度中:同时观察 分区数量、每分区 active parts、主键裁剪率;任一指标越过表中风险线,就停止继续扩量。
- 回滚时:先执行「低基数时间分区」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
- 发布后:至少覆盖一个完整峰值周期,确认「高基数业务字段做分区」没有再次出现,才关闭变更观察窗口。
方案对比与选型
| 方案 | 更适合的场景 | 主要收益 | 代价与边界 |
|---|---|---|---|
| 按月分区 | 长期留存与常规时间查询 | 分区数量稳定、管理方便 | 删除粒度为月 |
| 按日分区 | 日级删除或单日数据巨大 | 裁剪与运维粒度细 | 分区数量和小 Part 更多 |
| 不分区/单分区 | 小表或无需周期删除 | 结构最简单 | 长期大表维护和删除成本高 |
选型至少带上 日增量、分区规模、查询并发、扫描行数和压缩比,并用上面的量化基线验证;未知数据应明确为待测假设。
设计边界与工程取舍
分区不是越细查询越快;先用少量分区管理生命周期,再用 ORDER BY 和主键前缀解决高频过滤。
工程落地遵循:以数据布局减少扫描,以批量写入减少小 Part,避免照搬行存思路。回答时直接引用「system.parts」、配置实验和事故数据,比复述固定模板更有说服力。