Cluster Key 专题介绍
Cluster Key 系列希望帮助数据工程师从“会写
CLUSTER BY
原理篇:从传统分区到微分区,Snowflake 与 Databend 如何减少数据扫描?
最佳实践篇(本文):从真实 Workload 出发,判断表是否值得聚类,并选择列、顺序和粒度。
第一篇回答“Cluster Key 为什么能减少扫描”;本文继续回答更容易出错的问题:面对一张真实大表,应该把哪些表达式放进 Cluster Key,又如何证明这个选择有效。
实验背景:查询只覆盖两天,为什么仍要扫描 62.9% 的 Blocks?
本文使用
test.hits
-
1 亿行事件数据;
-
105 个字段;
-
压缩后约 10.6 GiB;
-
覆盖 2013 年 7 月的 17 个日期;
-
Baseline 没有显式 Cluster Key;
-
共包含 9,044 个 Blocks 和 10 个 Segments。
实验条件:Baseline 显式设置了
,低于默认的 100 万行目标。这样做是为了在当前数据规模下形成更多 Blocks,便于观察不同物理布局的 Pruning 差异。后续候选表沿用相同设置。ROW_PER_BLOCK = 10000
先执行一条两天范围查询:
SELECT count(*)
FROM hits
WHERE eventdate >= '2013-07-02'
AND eventdate < '2013-07-04';
查询实际命中约 1,354 万行,但
EXPLAIN ANALYZE
segments: <range pruning: 10 to 10>
blocks: <range pruning: 9044 to 5693>
read rows: 63,526,217
这组数字应该这样读:
-
Segment Range Pruning 没有排除任何 Segment;
-
Block Range Pruning 将 9,044 个 Blocks 缩小到 5,693 个;
-
查询仍需扫描 62.9% 的 Blocks;
-
读取行数约为实际命中行数的 4.7 倍。
问题不在 SQL 谓词,而在数据布局。大量 Blocks 的
EventDate
再看一条更接近实际分析的热门 URL 查询:
SELECT
url,
count(*) AS page_views
FROM hits
WHERE counterid = 7525
AND eventdate >= '2013-07-02'
AND eventdate < '2013-07-04'
AND dontcounthits = 0
AND isrefresh = 0
AND url <> ''
GROUP BY url
ORDER BY page_views DESC
LIMIT 10;
Baseline 的裁剪过程为:
segments: <range pruning: 10 to 10>
blocks: <range pruning: 9044 to 5667,
bloom pruning: 5667 to 243>
read rows: 2,862,793
read size: 30.80 MiB
这里出现了两类互补的裁剪:
-
Range Pruning 根据 Block Min/Max 排除不可能命中的值域;
-
Bloom Pruning 针对等值谓词,继续排除一定不包含目标值的 Blocks。
Cluster Key 最直接改善的是 Range Pruning。它通过重新组织数据收紧 Block 的 Min/Max,让更多 Blocks 在读取前就被排除。
但“当前扫描比例很高”只说明存在优化空间,还不能证明设置 Cluster Key 一定划算。下一步要先过一遍投入产出判断。
第一步:先判断这张表是否值得设置 Cluster Key
行数多不等于一定要聚类。Cluster Key 会带来初始数据重组和后续维护成本,只有查询侧的累计收益足够大时才值得投入。
一张表更可能从 Cluster Key 中受益,通常需要同时满足以下条件:
-
表已经足够大,包含大量可被裁剪的 Blocks;
-
高频查询通常只需要读取全表的一小部分数据;
-
经过现有 Range Pruning 和 Bloom Pruning 后,实际扫描量仍然较大;
-
扫描成本最高的查询长期共享一到两个过滤维度;
-
这些查询执行足够频繁,单次节省可以累积成显著收益;
-
数据写入顺序和变更模式不会让维护成本失控。
反过来,以下场景的增量收益通常有限:
-
表规模较小,只有少量 Blocks;
-
高频查询本来就需要读取大部分数据;
-
数据已经按主要过滤维度自然有序;
-
高成本查询没有稳定、共享的访问路径;
-
能够受益的查询很少执行;
-
历史回补、乱序写入或高频 DML 会持续破坏布局。
需要特别注意:
没有显式 Cluster Key,不等于数据天然无序。
如果数据按时间追加写入,而主要查询也按时间过滤,当前 Block 范围可能已经足够紧凑。此时显式聚类只能带来很小的增量收益,却仍然需要维护成本。
因此,决策单位不应该是“一次查询快了多少”,而应该是一个观察周期内的总账:
查询执行频率 × 单次扫描节省
是否大于
初始数据重组 + 持续聚类维护成本
hits
第二步:从真实 Workload 选候选列,不要从表结构猜
宽表里有很多业务上重要的字段,但业务重要不等于具有裁剪价值。候选列应该从真实查询历史中提取,而不是浏览一次表结构后凭经验决定。
更可靠的顺序是:
-
找到执行频率高、累计扫描量大的参数化查询族;
-
提取这些查询长期共享的过滤条件;
-
判断每个条件的选择性和访问方式;
-
形成少量候选表达式;
-
使用受控实验验证 Pruning 与实际扫描量。
hits
| 访问模式 | 示例字段 | 需要回答的问题 |
|---|---|---|
| 范围过滤 | | 粒度是否匹配典型查询窗口? |
| 业务范围 | | 是否覆盖主要高成本查询? |
| 高基数等值过滤 | | 点查是否足够频繁,前导条件是否完整? |
| 低基数维度 | | 某个常见值能否排除足够多数据? |
本文主要查询都包含
CounterID
CounterID
EventDate
EventTime
低基数不等于适合聚类
IsRefresh = 0
WHERE
所以判断候选列时,真正需要问的是:
查询一个常见值或范围时,这个表达式能否帮助 Databend 排除大部分 Blocks?
字段出现频率、业务重要性和基数只能帮助形成候选,不能替代实际选择性和扫描验证。
第三步:让时间粒度匹配典型查询窗口
时间是最常见的 Cluster Key 候选,也是最容易凭感觉选择的字段。原始 Timestamp、小时、天、周和月分别代表不同的物理粒度,不能机械地认为越细越好,或越粗越省成本。
可以先用近似去重了解候选表达式的数量级:
SELECT
min(eventdate) AS min_date,
max(eventdate) AS max_date,
approx_count_distinct(eventdate) AS approx_days,
approx_count_distinct(to_start_of_hour(eventtime)) AS approx_hours,
approx_count_distinct(counterid) AS approx_counters,
approx_count_distinct(userid) AS approx_users
FROM test.hits;
本次数据分布为:
日期范围:2013-07-02 至 2013-07-31
日期数:17
小时数:约 406
CounterID:约 6,477
UserID:约 1,797 万
整张表只覆盖一个月,因此下面的表达式在本实验中接近常量:
CLUSTER BY (to_start_of_month(eventtime))
它几乎没有进一步划分数据的能力。更合理的候选包括:
CLUSTER BY (eventtime)
CLUSTER BY (to_start_of_hour(eventtime))
CLUSTER BY (eventdate)
三者各有偏好:
-
原始
保留最细时间顺序,也具有最高基数;EventTime -
更贴近分钟到小时级分析;to_start_of_hour(eventtime)
-
更适合一天到数天的范围查询。EventDate
本文的主要查询窗口为一天到数天,因此实验选择
EventDate
选择时间粒度时,至少要同时考虑:
典型查询窗口
+ 每个时间 Bucket 会覆盖多少 Blocks
+ 数据是否按时间自然到达
+ 是否经常回补历史范围
表达式必须保留你想优化的顺序
to_start_of_hour
to_date
to_start_of_month
字符串也遵循相同原则。如果有意义的顺序位于前缀,可以使用
substr(column, 1, N)
第四步:用受控实验决定复合 Key 的列顺序
确定
EventDate
CounterID
多列 Cluster Key 按字典序组织。下面两个定义使用完全相同的列,却偏向不同访问路径:
-- 先形成全局日期范围,再在日期内按 Counter 细分
CLUSTER BY (eventdate, counterid)
-- 先形成全局 Counter 范围,再在 Counter 内按日期细分
CLUSTER BY (counterid, eventdate)
实验设计
使用同一份 1 亿行数据构造三种布局:
| 数据布局 | Cluster Key |
|---|---|
| Baseline | 无显式 Cluster Key |
| 日期优先 | |
| Counter 优先 | |
所有实验表使用相同的
ROW_PER_BLOCK = 10000
v1.2.932-nightly
本实验重点比较物理布局带来的 Pruning 和扫描变化。单次执行时间容易受到硬件、并发、缓存和后台任务影响,因此不作为 Cluster Key 选型依据。
完成写入与数据重组后,三种布局分别包含 9,044、9,261 和 9,258 个 Blocks,处于同一数量级。由于重组会改变 Block 与 Segment 边界,下面同时比较各层 Pruning、最终 Block 占比和实际读取量,而不是只看绝对 Block 数。
查询一:只按日期过滤
| 数据布局 | Segment Range Pruning | Block Range Pruning | Range 后占比 | Read Rows |
|---|---|---|---|---|
| Baseline | 10 → 10 | 9,044 → 5,693 | 62.9% | 63,526,217 |
| 8 → 2 | 2,234 → 1,260 | 13.6% | 13,608,944 |
| 9 → 8 | 8,245 → 1,818 | 19.6% | 19,873,404 |
日期优先布局在纯日期查询上保留的 Blocks 更少,符合前导列预期。
查询二:只按 Counter 过滤
SELECT count(*)
FROM hits
WHERE counterid = 7525;
| 数据布局 | Segment Range Pruning | Block Range Pruning | Bloom Pruning | 最终 Block 占比 | Read Rows |
|---|---|---|---|---|---|
| Baseline | 10 → 10 | 9,044 → 8,931 | 8,931 → 459 | 5.1% | 5,269,162 |
| 8 → 7 | 8,144 → 210 | 210 → 84 | 0.9% | 948,139 |
| 9 → 1 | 1,013 → 79 | 79 → 75 | 0.8% | 843,147 |
Counter 优先布局的 Range Pruning 更强。但 Bloom Pruning 继续工作后,两种候选最终保留 75 和 84 个 Blocks,差距已经明显缩小。
查询三:同时按 Counter 和日期过滤
| 数据布局 | Segment Range Pruning | Block Range Pruning | Bloom Pruning | 最终 Block 占比 | Read Rows | Read Size |
|---|---|---|---|---|---|---|
| Baseline | 10 → 10 | 9,044 → 5,667 | 5,667 → 243 | 2.7% | 2,862,793 | 30.80 MiB |
| 8 → 1 | 1,117 → 64 | 64 → 38 | 0.4% | 426,914 | 2.66 MiB |
| 9 → 1 | 1,013 → 48 | 48 → 44 | 0.5% | 490,585 | 2.79 MiB |
同时提供两个条件时,日期优先与 Counter 优先最终保留 38 和 44 个 Blocks,实际扫描量接近。
为什么本实验选择日期优先
三组结果体现了复合 Key 的前导列效应:
-
日期优先明显改善纯日期查询;
-
Counter 优先在纯 Counter 查询的 Range Pruning 阶段更强;
-
Bloom Pruning 缩小了两种布局在等值查询上的最终差距;
-
混合查询中,两种布局的最终扫描量接近。
综合当前 Workload 的三类查询权重,本文选择:
CLUSTER BY (eventdate, counterid)
这不是因为“时间列应该永远放在第一位”,而是因为日期优先兼顾了当前查询组合:纯日期查询改善更多,纯 Counter 查询仍可由 Bloom Pruning 补充,混合查询的最终扫描量也略低。
更一般地说,在候选列都具备裁剪价值的前提下,可以先评估“常用范围列在前,高基数业务列在后”的布局。但只要 Workload 以高基数精确过滤为主,就仍然应该测试反向顺序,而不是套用模板。
第五步:谨慎处理高基数 ID 与长字符串
高基数不是一条简单的否决规则。真正影响维护成本的,是值如何生成、数据如何到达,以及查询是否通常提供完整的前导条件。
单调 ID 与随机 ID 的维护成本不同
-
单调增长或时间有序的 ID 通常追加到已有值域末端,较容易保持局部顺序;
-
随机 UUID、随机 Trace ID 和随机 Hash 会持续落入历史值域,使新旧 Blocks 的范围交叉,增加后续 Recluster 工作量。
因此,不要仅仅因为等值过滤频繁,就把随机高基数列放在 Cluster Key 前部。只有当全历史点查是核心高频 Workload,并且 Range Pruning 在 Bloom Pruning 之外仍有足够增量收益时,才值得进一步测试。
高基数列放在第三位时,前导条件决定价值
hits.UserID
CLUSTER BY (counterid, eventdate, userid)
在
CounterID = 62
EventDate = '2013-07-15'
| 查询条件 | Segment Range Pruning | Block Range Pruning | Bloom Pruning | 最终 Blocks | 最终 Block 占比 |
|---|---|---|---|---|---|
| 9 → 1 | 1,014 → 72 | 72 → 70 | 70 | 0.8% |
| 9 → 1 | 1,014 → 19 | 19 → 1 | 1 | <0.1% |
仅 | 9 → 9 | 9,212 → 2,798 | 2,798 → 16 | 16 | 0.2% |
查询提供前两列时,Databend 先定位到连续的 Counter 与日期范围;补上
UserID
只提供
UserID
这再次证明:
多列 Cluster Key 不是多条独立索引。高基数后续列是否有价值,很大程度上取决于查询是否同时提供前导字段。
字符串需要区分 Cluster Key 与普通列统计信息
对于
URL
Referer
| 对象 | Databend 默认行为 | 显式调整方式 |
|---|---|---|
| 字符串 Cluster Key | Cluster Statistics 使用字符串前 8 Bytes | |
| 普通字符串 Column Statistics | 以 16 个字符为基础,可根据公共前缀自适应到 32 | |
hits.URL
| 表达式 | 近似基数 |
|---|---|
| 66 |
| 58,332 |
| 2,115,680 |
完整 | 18,388,647 |
直接使用
CLUSTER BY (url)
如果 Workload 的过滤语义适合前缀表达式,可以测试:
CLUSTER BY (substr(url, 1, N))
本例中前 32 个字符的近似基数达到 2,115,680,但
N = 32
同时需要明确:调整
STATS_TRUNCATE_LEN
CLUSTER BY
substr
第六步:同时验证数据布局、查询扫描和维护成本
Cluster Key 的验证至少要分为两层。只看查询耗时或只看
average_depth
第一层:用 clustering_information
观察数据组织
clustering_information
SELECT
cluster_key,
info:total_block_count,
info:constant_block_count,
info:average_overlaps,
info:average_depth,
info:p95_depth,
info:p99_depth
FROM clustering_information(
'database_name',
'table_name'
);
这些指标可以帮助回答:
-
当前有多少 Blocks;
-
有多少 Blocks 已经达到 Constant 状态;
-
不同 Block 范围平均重叠多少;
-
同一个 Key Value 平均落入多少层重叠范围;
-
高 Depth 是来自无序数据,还是热点值形成的多个 Constant Blocks。
本次实验结果如下:
| 布局 | Blocks | Constant Blocks | Constant 占比 | Average Overlaps | Average Depth | P95 / P99 Depth |
|---|---|---|---|---|---|---|
Baseline,按 | 9,044 | 7 | 0.1% | 9,042.0484 | 9,022.5597 | 9,023 / 9,023 |
| 9,261 | 7,087 | 76.5% | 168.1164 | 164.8168 | 565 / 565 |
| 9,258 | 7,127 | 77.0% | 168.7695 | 164.0238 | 566 / 566 |
| 9,212 | 1 | <0.1% | 21.4021 | 11.9238 | 12 / 12 |
结论一:高 Depth 可能代表可优化的无序布局
Baseline 按
(eventdate, counterid)
average_depth
两个双列候选按各自 Cluster Key 评估后,
average_depth
结论二:剩余 Depth 也可能来自不可再拆的热点值
两个双列布局中,约 76.5% 至 77.0% 的 Blocks 已经是 Constant Blocks,也就是完整 Cluster Key 的 Min 与 Max 相同。
如果一个热点 Key 真实占据多个达到大小阈值的 Blocks,这些相同点范围仍然会被计入 Overlap 和 Depth,但它们已经是 Recluster 的终态。继续重写无法消除由真实热点分布造成的这部分 Depth。
结论三:不同 Cluster Key 的 Depth 不能直接横向排名
三列布局的
average_depth
UserID
因此,不能仅凭更低的 Depth 判断三列方案更优。第五步的查询实测显示,
UserID
clustering_information
第二层:用 EXPLAIN ANALYZE
验证真实查询
EXPLAIN ANALYZE
建议按下面顺序阅读结果:
Segment Range Pruning 前后的 Segment 数
↓
Block Range Pruning 前后的 Block 数
↓
Bloom Pruning 前后的 Block 数(等值谓词适用时)
↓
Read Rows 与 Read Size
这个顺序可以帮助判断收益来自哪一层,也能避免把 Bloom Pruning 的效果错误归因于 Cluster Key。
定义或修改 Cluster Key 不会自动重排所有历史 Blocks。验证历史数据效果前,需要根据部署方式和维护策略完成数据重组:
ALTER TABLE hits
CLUSTER BY (counterid, eventdate);
如果使用显式 Recluster,可以优先限制到受影响的数据范围:
ALTER TABLE hits RECLUSTER
WHERE eventdate >= '2013-07-14'
AND eventdate < '2013-07-16';
RECLUSTER FINAL
第三层:把维护成本放回同一张账单
数据重组会消耗计算、I/O 和临时存储资源。乱序写入、历史回补以及频繁的
UPDATE
DELETE
MERGE
最终评估应覆盖同一个业务周期:
-
主要查询累计减少了多少 Read Rows / Read Size;
-
查询执行频率是否足以放大这部分节省;
-
自动或显式聚类消耗了多少计算资源;
-
新写入是否持续破坏现有布局;
-
是否可以只维护受影响的时间范围。
目标不是机械地周期性重写全表,而是在布局退化到影响关键查询时,以可控成本恢复足够好的 Pruning。
结语:最佳 Cluster Key 来自 Workload,而不是模板
Cluster Key 没有固定的“最佳列顺序”。真正可复用的是一套验证路径:
定位累计扫描成本最高的查询族
↓
提取长期共享且有选择性的过滤条件
↓
根据典型窗口选择时间粒度
↓
根据前导条件和写入顺序形成候选布局
↓
在相同物理参数下构造受控实验
↓
用 clustering_information 判断数据组织
↓
用 EXPLAIN ANALYZE 验证真实扫描
↓
确认长期查询收益覆盖数据重组与维护成本
这套方法背后有五条原则:
-
高频过滤列不一定具有裁剪价值;
-
低基数和高基数都不是单独的选择标准;
-
复合 Key 的前导列决定主要物理范围;
-
更低的 Depth 不等于更高的查询收益;
-
更多 Cluster Key 列不等于更好的数据布局。
最终目标不是让
average_depth
实验运行于 Databend
。本文同时参考了 2026 年 8 月可用的官方文档。聚类、字符串统计信息和v1.2.932-nightly输出可能随版本变化,生产使用前请以当前版本文档和实际测试为准。EXPLAIN ANALYZE
订阅我们的新闻简报
及时了解功能发布、产品规划、支持服务和云服务的最新信息!






