Cluster Key 系列希望帮助数据工程师从“会写
CLUSTER BY”走向“能够解释、设计并验证数据布局”。
第一篇回答“Cluster Key 为什么能减少扫描”;本文继续回答更容易出错的问题:面对一张真实大表,应该把哪些表达式放进 Cluster Key,又如何证明这个选择有效。
导读:不要把所有高频过滤列依次写进
CLUSTER BY。先找到累计扫描成本最高的一组查询,再选择它们长期共享、能够排除大量 Blocks 的谓词;根据典型查询窗口确定时间粒度,根据访问路径决定复合 Key 顺序;最后同时使用clustering_information和EXPLAIN ANALYZE验证数据布局与实际扫描收益。本文面向熟悉 SQL、过滤谓词和基本 OLAP 查询分析的数据工程师。文中的实验来自特定版本和受控数据布局,重点是展示选择方法,不是给出一组可以直接外推到所有 Workloads 的性能数字。
本文使用
test.hits 作为实验表。它是一张典型的 Web Analytics 宽表:
实验条件:Baseline 显式设置了
ROW_PER_BLOCK = 10000,低于默认的 100 万行目标。这样做是为了在当前数据规模下形成更多 Blocks,便于观察不同物理布局的 Pruning 差异。后续候选表沿用相同设置。
先执行一条两天范围查询:
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
这组数字应该这样读:
问题不在 SQL 谓词,而在数据布局。大量 Blocks 的
EventDate Min/Max 都覆盖了这个两天窗口,Databend 无法证明它们一定不命中。
再看一条更接近实际分析的热门 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
这里出现了两类互补的裁剪:
Cluster Key 最直接改善的是 Range Pruning。它通过重新组织数据收紧 Block 的 Min/Max,让更多 Blocks 在读取前就被排除。
但“当前扫描比例很高”只说明存在优化空间,还不能证明设置 Cluster Key 一定划算。下一步要先过一遍投入产出判断。
行数多不等于一定要聚类。Cluster Key 会带来初始数据重组和后续维护成本,只有查询侧的累计收益足够大时才值得投入。
一张表更可能从 Cluster Key 中受益,通常需要同时满足以下条件:
反过来,以下场景的增量收益通常有限:
需要特别注意:
没有显式 Cluster Key,不等于数据天然无序。
如果数据按时间追加写入,而主要查询也按时间过滤,当前 Block 范围可能已经足够紧凑。此时显式聚类只能带来很小的增量收益,却仍然需要维护成本。
因此,决策单位不应该是“一次查询快了多少”,而应该是一个观察周期内的总账:
查询执行频率 × 单次扫描节省
是否大于
初始数据重组 + 持续聚类维护成本
hits 表的 Baseline 在两天查询上仍扫描 62.9% 的 Blocks,且主要查询具有稳定的时间与业务维度,值得继续形成候选方案。
宽表里有很多业务上重要的字段,但业务重要不等于具有裁剪价值。候选列应该从真实查询历史中提取,而不是浏览一次表结构后凭经验决定。
更可靠的顺序是:
hits 表的候选字段可以先按访问模式分类:
| 访问模式 | 示例字段 | 需要回答的问题 |
|---|---|---|
| 范围过滤 |
EventDate、
EventTime |
粒度是否匹配典型查询窗口? |
| 业务范围 |
CounterID、
RegionID |
是否覆盖主要高成本查询? |
| 高基数等值过滤 |
UserID、
WatchID、
URLHash |
点查是否足够频繁,前导条件是否完整? |
| 低基数维度 |
IsRefresh、
DontCountHits |
某个常见值能否排除足够多数据? |
本文主要查询都包含
CounterID 和时间范围,因此先将
CounterID、
EventDate 与
EventTime 列为候选。
IsRefresh = 0 在这份数据中覆盖约 9,333 万行,占全表 93.3%。即使它频繁出现在
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。这不是通用推荐,只是当前 Workload 的匹配结果。
选择时间粒度时,至少要同时考虑:
典型查询窗口
+ 每个时间 Bucket 会覆盖多少 Blocks
+ 数据是否按时间自然到达
+ 是否经常回补历史范围
to_start_of_hour、
to_date 和
to_start_of_month 都保留了时间的先后关系,适合 Range Organization。
字符串也遵循相同原则。如果有意义的顺序位于前缀,可以使用
substr(column, 1, N)。如果截取中间或末尾字符,通常会破坏原有前导顺序,很难形成连续的范围。
确定
EventDate 与
CounterID 都具有候选价值后,还需要决定谁放在前面。
多列 Cluster Key 按字典序组织。下面两个定义使用完全相同的列,却偏向不同访问路径:
-- 先形成全局日期范围,再在日期内按 Counter 细分
CLUSTER
BY (eventdate, counterid)
-- 先形成全局 Counter 范围,再在 Counter 内按日期细分
CLUSTER
BY (counterid, eventdate)
使用同一份 1 亿行数据构造三种布局:
| 数据布局 | Cluster Key |
|---|---|
| Baseline | 无显式 Cluster Key |
| 日期优先 |
(eventdate, counterid) |
| Counter 优先 |
(counterid, eventdate) |
所有实验表使用相同的
ROW_PER_BLOCK = 10000。测试环境为 Databend
v1.2.932-nightly,使用本地文件系统,未启用 Databend Data Cache。
本实验重点比较物理布局带来的 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 |
(eventdate, counterid) |
8 → 2 | 2,234 → 1,260 | 13.6% | 13,608,944 |
(counterid, eventdate) |
9 → 8 | 8,245 → 1,818 | 19.6% | 19,873,404 |
日期优先布局在纯日期查询上保留的 Blocks 更少,符合前导列预期。
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 |
(eventdate, counterid) |
8 → 7 | 8,144 → 210 | 210 → 84 | 0.9% | 948,139 |
(counterid, eventdate) |
9 → 1 | 1,013 → 79 | 79 → 75 | 0.8% | 843,147 |
Counter 优先布局的 Range Pruning 更强。但 Bloom Pruning 继续工作后,两种候选最终保留 75 和 84 个 Blocks,差距已经明显缩小。
| 数据布局 | 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 |
(eventdate, counterid) |
8 → 1 | 1,117 → 64 | 64 → 38 | 0.4% | 426,914 | 2.66 MiB |
(counterid, eventdate) |
9 → 1 | 1,013 → 48 | 48 → 44 | 0.5% | 490,585 | 2.79 MiB |
同时提供两个条件时,日期优先与 Counter 优先最终保留 38 和 44 个 Blocks,实际扫描量接近。
三组结果体现了复合 Key 的前导列效应:
综合当前 Workload 的三类查询权重,本文选择:
CLUSTER BY (eventdate, counterid)
这不是因为“时间列应该永远放在第一位”,而是因为日期优先兼顾了当前查询组合:纯日期查询改善更多,纯 Counter 查询仍可由 Bloom Pruning 补充,混合查询的最终扫描量也略低。
更一般地说,在候选列都具备裁剪价值的前提下,可以先评估“常用范围列在前,高基数业务列在后”的布局。但只要 Workload 以高基数精确过滤为主,就仍然应该测试反向顺序,而不是套用模板。
高基数不是一条简单的否决规则。真正影响维护成本的,是值如何生成、数据如何到达,以及查询是否通常提供完整的前导条件。
因此,不要仅仅因为等值过滤频繁,就把随机高基数列放在 Cluster Key 前部。只有当全历史点查是核心高频 Workload,并且 Range Pruning 在 Bloom Pruning 之外仍有足够增量收益时,才值得进一步测试。
hits.UserID 约有 1,797 万个不同值。为了观察第三列的作用,实验表采用:
CLUSTER BY (counterid, eventdate, userid)
在
CounterID = 62、
EventDate = '2013-07-15' 的局部范围内,仍有约 6.3 万个 UserID。
| 查询条件 | Segment Range Pruning | Block Range Pruning | Bloom Pruning | 最终 Blocks | 最终 Block 占比 |
|---|---|---|---|---|---|
CounterID + EventDate |
9 → 1 | 1,014 → 72 | 72 → 70 | 70 | 0.8% |
CounterID + EventDate + UserID |
9 → 1 | 1,014 → 19 | 19 → 1 | 1 | <0.1% |
仅
UserID |
9 → 9 | 9,212 → 2,798 | 2,798 → 16 | 16 | 0.2% |
查询提供前两列时,Databend 先定位到连续的 Counter 与日期范围;补上
UserID 后,第三列继续缩小候选范围,Bloom Pruning 最终只保留 1 个 Block。
只提供
UserID 时,Segment Range Pruning 无法排除任何 Segment,后续主要依靠普通 Block Min/Max 与 Bloom Pruning。
这再次证明:
多列 Cluster Key 不是多条独立索引。高基数后续列是否有价值,很大程度上取决于查询是否同时提供前导字段。
对于
URL、
Referer 等长字符串,还要区分两套独立机制:
| 对象 | Databend 默认行为 | 显式调整方式 |
|---|---|---|
| 字符串 Cluster Key | Cluster Statistics 使用字符串前 8 Bytes |
CLUSTER BY (substr(column, 1, N)) |
| 普通字符串 Column Statistics | 以 16 个字符为基础,可根据公共前缀自适应到 32 |
VARCHAR STATS_TRUNCATE_LEN N |
hits.URL 的前缀近似基数为:
| 表达式 | 近似基数 |
|---|---|
substr(url, 1, 8) |
66 |
substr(url, 1, 16) |
58,332 |
substr(url, 1, 32) |
2,115,680 |
完整
url |
18,388,647 |
直接使用
CLUSTER BY (url) 在语法上没有问题,但在当前 Databend 行为下,Cluster Statistics 默认只使用字符串前 8 Bytes。这个数据集中,前 8 个字符的近似基数只有 66,大量完整 URL 因而落在相同的 Cluster Key 范围内。
如果 Workload 的过滤语义适合前缀表达式,可以测试:
CLUSTER BY (substr(url, 1, N))
本例中前 32 个字符的近似基数达到 2,115,680,但
N = 32 只是一个实验候选,不是固定推荐值。仍然要通过实际查询验证 Pruning 与维护成本。
同时需要明确:调整
STATS_TRUNCATE_LEN 不会改变字符串 Cluster Key 默认使用的前 8 Bytes。要改变 Cluster Key 的字符串表达式,必须在
CLUSTER BY 中显式使用
substr。
Cluster Key 的验证至少要分为两层。只看查询耗时或只看
average_depth,都容易得出错误结论。
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 | Constant Blocks | Constant 占比 | Average Overlaps | Average Depth | P95 / P99 Depth |
|---|---|---|---|---|---|---|
Baseline,按
(eventdate, counterid) 评估 |
9,044 | 7 | 0.1% | 9,042.0484 | 9,022.5597 | 9,023 / 9,023 |
(eventdate, counterid),Recluster 后 |
9,261 | 7,087 | 76.5% | 168.1164 | 164.8168 | 565 / 565 |
(counterid, eventdate),Recluster 后 |
9,258 | 7,127 | 77.0% | 168.7695 | 164.0238 | 566 / 566 |
(counterid, eventdate, userid),Recluster 后 |
9,212 | 1 | <0.1% | 21.4021 | 11.9238 | 12 / 12 |
Baseline 按
(eventdate, counterid) 评估时只有 7 个 Constant Blocks,但
average_depth 接近总 Block 数。这意味着大量 Block 的 Key 范围宽且互相交叉。
两个双列候选按各自 Cluster Key 评估后,
average_depth 降到约 164 至 165,说明范围重叠显著收敛。
两个双列布局中,约 76.5% 至 77.0% 的 Blocks 已经是 Constant Blocks,也就是完整 Cluster Key 的 Min 与 Max 相同。
如果一个热点 Key 真实占据多个达到大小阈值的 Blocks,这些相同点范围仍然会被计入 Overlap 和 Depth,但它们已经是 Recluster 的终态。继续重写无法消除由真实热点分布造成的这部分 Depth。
三列布局的
average_depth 只有 11.9238,看起来远低于双列布局。但加入高基数
UserID 后,原来的热点点范围被进一步拆分,计算指标的维度空间已经发生变化。
因此,不能仅凭更低的 Depth 判断三列方案更优。第五步的查询实测显示,
UserID 只有在前导条件完整时才能稳定增强 Range Pruning。
clustering_information 回答的是“数据范围如何组织”,不是“某条查询一定扫描多少”。
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 也会重新增加范围重叠。
最终评估应覆盖同一个业务周期:
目标不是机械地周期性重写全表,而是在布局退化到影响关键查询时,以可控成本恢复足够好的 Pruning。
Cluster Key 没有固定的“最佳列顺序”。真正可复用的是一套验证路径:
定位累计扫描成本最高的查询族
↓
提取长期共享且有选择性的过滤条件
↓
根据典型窗口选择时间粒度
↓
根据前导条件和写入顺序形成候选布局
↓
在相同物理参数下构造受控实验
↓
用 clustering_information 判断数据组织
↓
用 EXPLAIN ANALYZE 验证真实扫描
↓
确认长期查询收益覆盖数据重组与维护成本
这套方法背后有五条原则:
最终目标不是让
average_depth 变成一个漂亮数字,也不是让 Cluster Key 覆盖所有查询字段,而是让最重要的查询在可控维护成本下,长期只读取真正可能命中的 Blocks。
实验运行于 Databend
v1.2.932-nightly。本文同时参考了 2026 年 8 月可用的官方文档。聚类、字符串统计信息和EXPLAIN ANALYZE输出可能随版本变化,生产使用前请以当前版本文档和实际测试为准。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70042558/viewspace-3180670/ ,如需转载,请注明出处,否则将追究法律责任。