博客

Cluster Key 最佳实践:列怎么选、顺序怎么排与粒度设计

avatarzhyass8月 25, 2026
Cluster Key 最佳实践:列怎么选、顺序怎么排与粒度设计

Cluster Key 专题介绍

Cluster Key 系列希望帮助数据工程师从“会写

CLUSTER BY
”走向“能够解释、设计并验证数据布局”。

原理篇:从传统分区到微分区,Snowflake 与 Databend 如何减少数据扫描?

最佳实践篇(本文):从真实 Workload 出发,判断表是否值得聚类,并选择列、顺序和粒度。

第一篇回答“Cluster Key 为什么能减少扫描”;本文继续回答更容易出错的问题:面对一张真实大表,应该把哪些表达式放进 Cluster Key,又如何证明这个选择有效。

实验背景:查询只覆盖两天,为什么仍要扫描 62.9% 的 Blocks?

本文使用

test.hits
作为实验表。它是一张典型的 Web Analytics 宽表:

  • 1 亿行事件数据;

  • 105 个字段;

  • 压缩后约 10.6 GiB;

  • 覆盖 2013 年 7 月的 17 个日期;

  • Baseline 没有显式 Cluster Key;

  • 共包含 9,044 个 Blocks 和 10 个 Segments。

实验条件: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

这组数字应该这样读:

  1. Segment Range Pruning 没有排除任何 Segment;

  2. Block Range Pruning 将 9,044 个 Blocks 缩小到 5,693 个;

  3. 查询仍需扫描 62.9% 的 Blocks;

  4. 读取行数约为实际命中行数的 4.7 倍。

问题不在 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

这里出现了两类互补的裁剪:

  • 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
表的 Baseline 在两天查询上仍扫描 62.9% 的 Blocks,且主要查询具有稳定的时间与业务维度,值得继续形成候选方案。

第二步:从真实 Workload 选候选列,不要从表结构猜

宽表里有很多业务上重要的字段,但业务重要不等于具有裁剪价值。候选列应该从真实查询历史中提取,而不是浏览一次表结构后凭经验决定。

更可靠的顺序是:

  1. 找到执行频率高、累计扫描量大的参数化查询族;

  2. 提取这些查询长期共享的过滤条件;

  3. 判断每个条件的选择性和访问方式;

  4. 形成少量候选表达式;

  5. 使用受控实验验证 Pruning 与实际扫描量。

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)
。如果截取中间或末尾字符,通常会破坏原有前导顺序,很难形成连续的范围。

第四步:用受控实验决定复合 Key 的列顺序

确定

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 PruningBlock Range PruningRange 后占比Read Rows
Baseline10 → 109,044 → 5,69362.9%63,526,217
(eventdate, counterid)
8 → 22,234 → 1,26013.6%13,608,944
(counterid, eventdate)
9 → 88,245 → 1,81819.6%19,873,404

日期优先布局在纯日期查询上保留的 Blocks 更少,符合前导列预期。

查询二:只按 Counter 过滤

SELECT count(*)
FROM hits
WHERE counterid = 7525;
数据布局Segment Range PruningBlock Range PruningBloom Pruning最终 Block 占比Read Rows
Baseline10 → 109,044 → 8,9318,931 → 4595.1%5,269,162
(eventdate, counterid)
8 → 78,144 → 210210 → 840.9%948,139
(counterid, eventdate)
9 → 11,013 → 7979 → 750.8%843,147

Counter 优先布局的 Range Pruning 更强。但 Bloom Pruning 继续工作后,两种候选最终保留 75 和 84 个 Blocks,差距已经明显缩小。

查询三:同时按 Counter 和日期过滤

数据布局Segment Range PruningBlock Range PruningBloom Pruning最终 Block 占比Read RowsRead Size
Baseline10 → 109,044 → 5,6675,667 → 2432.7%2,862,79330.80 MiB
(eventdate, counterid)
8 → 11,117 → 6464 → 380.4%426,9142.66 MiB
(counterid, eventdate)
9 → 11,013 → 4848 → 440.5%490,5852.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
约有 1,797 万个不同值。为了观察第三列的作用,实验表采用:

CLUSTER BY (counterid, eventdate, userid)

CounterID = 62
EventDate = '2013-07-15'
的局部范围内,仍有约 6.3 万个 UserID。

查询条件Segment Range PruningBlock Range PruningBloom Pruning最终 Blocks最终 Block 占比
CounterID + EventDate
9 → 11,014 → 7272 → 70700.8%
CounterID + EventDate + UserID
9 → 11,014 → 1919 → 11<0.1%
UserID
9 → 99,212 → 2,7982,798 → 16160.2%

查询提供前两列时,Databend 先定位到连续的 Counter 与日期范围;补上

UserID
后,第三列继续缩小候选范围,Bloom Pruning 最终只保留 1 个 Block。

只提供

UserID
时,Segment Range Pruning 无法排除任何 Segment,后续主要依靠普通 Block Min/Max 与 Bloom Pruning。

这再次证明:

多列 Cluster Key 不是多条独立索引。高基数后续列是否有价值,很大程度上取决于查询是否同时提供前导字段。

字符串需要区分 Cluster Key 与普通列统计信息

对于

URL
Referer
等长字符串,还要区分两套独立机制:

对象Databend 默认行为显式调整方式
字符串 Cluster KeyCluster 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;

  • 有多少 Blocks 已经达到 Constant 状态;

  • 不同 Block 范围平均重叠多少;

  • 同一个 Key Value 平均落入多少层重叠范围;

  • 高 Depth 是来自无序数据,还是热点值形成的多个 Constant Blocks。

本次实验结果如下:

布局BlocksConstant BlocksConstant 占比Average OverlapsAverage DepthP95 / P99 Depth
Baseline,按
(eventdate, counterid)
评估
9,04470.1%9,042.04849,022.55979,023 / 9,023
(eventdate, counterid)
,Recluster 后
9,2617,08776.5%168.1164164.8168565 / 565
(counterid, eventdate)
,Recluster 后
9,2587,12777.0%168.7695164.0238566 / 566
(counterid, eventdate, userid)
,Recluster 后
9,2121<0.1%21.402111.923812 / 12

结论一:高 Depth 可能代表可优化的无序布局

Baseline 按

(eventdate, counterid)
评估时只有 7 个 Constant Blocks,但
average_depth
接近总 Block 数。这意味着大量 Block 的 Key 范围宽且互相交叉。

两个双列候选按各自 Cluster Key 评估后,

average_depth
降到约 164 至 165,说明范围重叠显著收敛。

结论二:剩余 Depth 也可能来自不可再拆的热点值

两个双列布局中,约 76.5% 至 77.0% 的 Blocks 已经是 Constant Blocks,也就是完整 Cluster Key 的 Min 与 Max 相同。

如果一个热点 Key 真实占据多个达到大小阈值的 Blocks,这些相同点范围仍然会被计入 Overlap 和 Depth,但它们已经是 Recluster 的终态。继续重写无法消除由真实热点分布造成的这部分 Depth。

结论三:不同 Cluster Key 的 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
也会重新增加范围重叠。

最终评估应覆盖同一个业务周期:

  • 主要查询累计减少了多少 Read Rows / Read Size;

  • 查询执行频率是否足以放大这部分节省;

  • 自动或显式聚类消耗了多少计算资源;

  • 新写入是否持续破坏现有布局;

  • 是否可以只维护受影响的时间范围。

目标不是机械地周期性重写全表,而是在布局退化到影响关键查询时,以可控成本恢复足够好的 Pruning。

结语:最佳 Cluster Key 来自 Workload,而不是模板

Cluster Key 没有固定的“最佳列顺序”。真正可复用的是一套验证路径:

定位累计扫描成本最高的查询族

提取长期共享且有选择性的过滤条件

根据典型窗口选择时间粒度

根据前导条件和写入顺序形成候选布局

在相同物理参数下构造受控实验

用 clustering_information 判断数据组织

用 EXPLAIN ANALYZE 验证真实扫描

确认长期查询收益覆盖数据重组与维护成本

这套方法背后有五条原则:

  1. 高频过滤列不一定具有裁剪价值;

  2. 低基数和高基数都不是单独的选择标准;

  3. 复合 Key 的前导列决定主要物理范围;

  4. 更低的 Depth 不等于更高的查询收益;

  5. 更多 Cluster Key 列不等于更好的数据布局。

最终目标不是让

average_depth
变成一个漂亮数字,也不是让 Cluster Key 覆盖所有查询字段,而是让最重要的查询在可控维护成本下,长期只读取真正可能命中的 Blocks。

实验运行于 Databend

v1.2.932-nightly
。本文同时参考了 2026 年 8 月可用的官方文档。聚类、字符串统计信息和
EXPLAIN ANALYZE
输出可能随版本变化,生产使用前请以当前版本文档和实际测试为准。

分享本篇文章

订阅我们的新闻简报

及时了解功能发布、产品规划、支持服务和云服务的最新信息!