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

Cluster Key 专题介绍

Cluster Key 系列希望帮助数据工程师从"会写 CLUSTER BY"走向"能够解释、设计并验证数据布局"。

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

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

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


导读 :不要把所有高频过滤列依次写进 CLUSTER BY。先找到累计扫描成本最高的一组查询,再选择它们长期共享、能够排除大量 Blocks 的谓词;根据典型查询窗口确定时间粒度,根据访问路径决定复合 Key 顺序;最后同时使用 clustering_informationEXPLAIN ANALYZE 验证数据布局与实际扫描收益。本文面向熟悉 SQL、过滤谓词和基本 OLAP 查询分析的数据工程师。文中的实验来自特定版本和受控数据布局,重点是展示选择方法,不是给出一组可以直接外推到所有 Workloads 的性能数字。


实验背景:查询只覆盖两天,为什么仍要扫描 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 差异。后续候选表沿用相同设置。

先执行一条两天范围查询:

SQL 复制代码
SELECT count(*)
FROM hits
WHERE eventdate >= '2013-07-02'
  AND eventdate <  '2013-07-04';

查询实际命中约 1,354 万行,但 EXPLAIN ANALYZE 显示:

Plaintext 复制代码
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 查询:

SQL 复制代码
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 的裁剪过程为:

Plaintext 复制代码
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 范围可能已经足够紧凑。此时显式聚类只能带来很小的增量收益,却仍然需要维护成本。

因此,决策单位不应该是"一次查询快了多少",而应该是一个观察周期内的总账:

Plaintext 复制代码
查询执行频率 × 单次扫描节省
            是否大于
初始数据重组 + 持续聚类维护成本

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


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

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

更可靠的顺序是:

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

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

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

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

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

hits 表的候选字段可以先按访问模式分类:

访问模式 示例字段 需要回答的问题
范围过滤 EventDateEventTime 粒度是否匹配典型查询窗口?
业务范围 CounterIDRegionID 是否覆盖主要高成本查询?
高基数等值过滤 UserIDWatchIDURLHash 点查是否足够频繁,前导条件是否完整?
低基数维度 IsRefreshDontCountHits 某个常见值能否排除足够多数据?

本文主要查询都包含 CounterID 和时间范围,因此先将 CounterIDEventDateEventTime 列为候选。

低基数不等于适合聚类

IsRefresh = 0 在这份数据中覆盖约 9,333 万行,占全表 93.3%。即使它频繁出现在 WHERE 中,也无法为这个常见值排除大部分数据。

所以判断候选列时,真正需要问的是:

查询一个常见值或范围时,这个表达式能否帮助 Databend 排除大部分 Blocks?

字段出现频率、业务重要性和基数只能帮助形成候选,不能替代实际选择性和扫描验证。


第三步:让时间粒度匹配典型查询窗口

时间是最常见的 Cluster Key 候选,也是最容易凭感觉选择的字段。原始 Timestamp、小时、天、周和月分别代表不同的物理粒度,不能机械地认为越细越好,或越粗越省成本。

可以先用近似去重了解候选表达式的数量级:

SQL 复制代码
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;

本次数据分布为:

Plaintext 复制代码
日期范围:2013-07-02 至 2013-07-31
日期数:17
小时数:约 406
CounterID:约 6,477
UserID:约 1,797 万

整张表只覆盖一个月,因此下面的表达式在本实验中接近常量:

SQL 复制代码
CLUSTER BY (to_start_of_month(eventtime))

它几乎没有进一步划分数据的能力。更合理的候选包括:

SQL 复制代码
CLUSTER BY (eventtime)
CLUSTER BY (to_start_of_hour(eventtime))
CLUSTER BY (eventdate)

三者各有偏好:

  • 原始 EventTime 保留最细时间顺序,也具有最高基数;

  • to_start_of_hour(eventtime) 更贴近分钟到小时级分析;

  • EventDate 更适合一天到数天的范围查询。

本文的主要查询窗口为一天到数天,因此实验选择 EventDate。这不是通用推荐,只是当前 Workload 的匹配结果。

选择时间粒度时,至少要同时考虑:

Plaintext 复制代码
典型查询窗口
+ 每个时间 Bucket 会覆盖多少 Blocks
+ 数据是否按时间自然到达
+ 是否经常回补历史范围

表达式必须保留你想优化的顺序

to_start_of_hourto_dateto_start_of_month 都保留了时间的先后关系,适合 Range Organization。

字符串也遵循相同原则。如果有意义的顺序位于前缀,可以使用 substr(column, 1, N)。如果截取中间或末尾字符,通常会破坏原有前导顺序,很难形成连续的范围。


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

确定 EventDateCounterID 都具有候选价值后,还需要决定谁放在前面。

多列 Cluster Key 按字典序组织。下面两个定义使用完全相同的列,却偏向不同访问路径:

SQL 复制代码
-- 先形成全局日期范围,再在日期内按 Counter 细分
CLUSTER BY (eventdate, counterid)
SQL 复制代码
-- 先形成全局 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 更少,符合前导列预期。

查询二:只按 Counter 过滤

SQL 复制代码
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,差距已经明显缩小。

查询三:同时按 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
(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 的前导列效应:

  • 日期优先明显改善纯日期查询;

  • Counter 优先在纯 Counter 查询的 Range Pruning 阶段更强;

  • Bloom Pruning 缩小了两种布局在等值查询上的最终差距;

  • 混合查询中,两种布局的最终扫描量接近。

综合当前 Workload 的三类查询权重,本文选择:

SQL 复制代码
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 万个不同值。为了观察第三列的作用,实验表采用:

SQL 复制代码
CLUSTER BY (counterid, eventdate, userid)

CounterID = 62EventDate = '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 不是多条独立索引。高基数后续列是否有价值,很大程度上取决于查询是否同时提供前导字段。

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

对于 URLReferer 等长字符串,还要区分两套独立机制:

对象 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 的过滤语义适合前缀表达式,可以测试:

SQL 复制代码
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 观察数据组织

SQL 复制代码
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,按 (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

结论一:高 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 验证真实查询

建议按下面顺序阅读结果:

Plaintext 复制代码
Segment Range Pruning 前后的 Segment 数
        ↓
Block Range Pruning 前后的 Block 数
        ↓
Bloom Pruning 前后的 Block 数(等值谓词适用时)
        ↓
Read Rows 与 Read Size

这个顺序可以帮助判断收益来自哪一层,也能避免把 Bloom Pruning 的效果错误归因于 Cluster Key。

定义或修改 Cluster Key 不会自动重排所有历史 Blocks。验证历史数据效果前,需要根据部署方式和维护策略完成数据重组:

SQL 复制代码
ALTER TABLE hits
CLUSTER BY (counterid, eventdate);

如果使用显式 Recluster,可以优先限制到受影响的数据范围:

SQL 复制代码
ALTER TABLE hits RECLUSTER
WHERE eventdate >= '2013-07-14'
  AND eventdate <  '2013-07-16';

RECLUSTER FINAL 可能运行更久并重写更多数据,适合受控实验,不应成为生产大表的无条件默认动作。

第三层:把维护成本放回同一张账单

数据重组会消耗计算、I/O 和临时存储资源。乱序写入、历史回补以及频繁的 UPDATEDELETEMERGE 也会重新增加范围重叠。

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

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

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

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

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

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

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


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

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

Plaintext 复制代码
定位累计扫描成本最高的查询族
        ↓
提取长期共享且有选择性的过滤条件
        ↓
根据典型窗口选择时间粒度
        ↓
根据前导条件和写入顺序形成候选布局
        ↓
在相同物理参数下构造受控实验
        ↓
用 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 输出可能随版本变化,生产使用前请以当前版本文档和实际测试为准。

相关推荐
招财小梗1 小时前
沈阳AI企业服务真能降本增效?
大数据·人工智能·python
三8441 小时前
WordPress REST API 参数校验机制剖析:为什么 author__not_in 无法直接盲注?
服务器·前端·数据库
kobe_OKOK_1 小时前
ubuntu server 打包成一个压缩文件,并且在另一台电脑中解压缩
服务器·数据库·ubuntu
又是重名了1 小时前
集团项目信创改造3 mysql to dm 插入获取主键
数据库·mysql·dameng
zengjuan10051 小时前
[特殊字符]️ 松鼠备份 | 数据库备份方案即将重磅上线!三大模式组合拳,精准守护SQL Server每一笔数据
数据库·sqlserver·数据库备份·全量备份·增量备份·差异备份·日志和事务备份
github_czy1 小时前
tf-idf讲解
大数据·人工智能·microsoft
whcyhhh1 小时前
头歌实践教学平台:数据科学与大数据技术导论(九下)
大数据·python
auspicious航1 小时前
PostgreSQL统计信息:SQL调优的“眼睛”与基石
数据库·sql·postgresql
字节跳动数据平台2 小时前
iDA:从 ChatBI 到专业数据分析助手的演进之路
大数据