从全表排序到概率统计:Databend 如何用 KLL、Top-N 与 CMS 改进基数估计

导读: 查询优化器既要了解数据的整体分布,也要识别少量高频值,否则就可能在 JOIN 顺序、扫描策略和算子选择上做出错误判断。本文结合 Databend 的实现与 Benchmark,介绍 KLL、Top-N 和 Count-Min Sketch(CMS)如何分工协作:KLL 以较低内存开销构建近似等深直方图,Top-N 精确记录最常见值,CMS 则补足未被 Top-N 保留的热点。适合数据库内核、查询优化器、数据平台及性能工程师阅读,预计阅读时间 12 分钟。

为什么统计信息同时关乎性能与成本

SQL 查询进入数据库后,优化器需要估算不同执行计划的成本,再决定 JOIN 顺序、扫描方式和算子组合。基数估计是这一过程的基础:如果优化器错误判断某个过滤条件会返回多少行,即使执行引擎本身足够快,也可能选出代价很高的计划。

直方图(Histogram)是描述列值分布的常用统计结构。相比等宽直方图(Equi-width Histogram),等深直方图(Equi-depth Histogram)让每个桶包含大致相同数量的行,因此在存在数据热点和倾斜时,通常能提供更稳定的估计。

但准确的统计信息并不是免费的。传统方式通常需要扫描、排序并聚合整列数据。在 Databend 这类面向大规模分析、采用存算分离架构的云数据仓库中,ANALYZE 不仅消耗内存和计算资源,也可能带来大量对象存储读取。问题因此变成:

如何在不承担全局排序成本的前提下,获得足以支撑优化器决策的统计信息?

Databend 使用 KLL、Top-N 和 CMS 处理这个问题。三者并非互相替代,而是分别描述数据分布的不同部分。

直方图如何支持基数估计

先看一个简化的范围查询。

SQL 复制代码
CREATE TABLE employee (
    id INT,
    salary INT
);

假设 salary 列的等深直方图如下:

Bucket Salary Range Rows
B1 [1000, 2000) 250
B2 [2000, 4000) 250
B3 [4000, 7000) 250
B4 7000, 10000 250

对于查询:

SQL 复制代码
SELECT *
FROM employee
WHERE salary < 5000;

B1 和 B2 被完全覆盖,B3 只覆盖了部分范围。若假设桶内数据均匀分布,则估算结果为:

Plaintext 复制代码
Estimated Rows
= B1 + B2 + B3 × Coverage
= 250 + 250 + 250 × (5000 - 4000) / (7000 - 4000)
≈ 583

直方图通过桶边界近似描述全局分布。但要得到完全准确的等深桶边界,传统实现往往需要完成一次全表扫描、全局排序和聚合:

Plaintext 复制代码
Column Values
      │
      ▼
+--------------------+
|  Full Table Scan   |
+--------------------+
      │
      ▼
+--------------------+
|    Global Sort     |
+--------------------+
      │
      ▼
+--------------------+
| Global Aggregation |
| Compute Quantiles  |
+--------------------+
      │
      ▼
+--------------------+
|  Build Histogram   |
+--------------------+

随着数据量增长,全局排序会成为 ANALYZE 的主要时间和内存开销。KLL 的作用,就是在可接受的误差范围内移除这一瓶颈。

KLL:用一次扫描近似数据分位点

KLL(Karnin-Lang-Liberty Sketch)是一种近似计算分位数(Quantile)的流式摘要算法。它只需要顺序读取数据,并将有限数量的样本压缩为可合并的 Sketch。

KLL 的可合并性非常适合 Databend 的分布式执行模型:不同计算节点可以并行构建局部 KLL,再将结果合并为全局 Sketch,最后查询各个分位点并生成等深桶。

Plaintext 复制代码
Input Values
      │
      ▼
+-------------------+
| One-pass KLL Scan |
+-------------------+
      │
      ▼
+-------------------+
| Merge KLL Sketch  |  Distributed
+-------------------+
      │
      ▼
+-------------------+
|  Quantile Query   |
| q0, q1, ..., qN   |
+-------------------+
      │
      ▼
+-------------------+
|  Build Histogram  |
+-------------------+

这条路径将"全表扫描 + 全局排序 + 聚合"简化为"一次扫描 + Sketch 合并"。对于需要频繁刷新统计信息的大表,它可以显著降低内存占用,并减少统计收集对业务查询的资源干扰。

KLL 还需要 NDV

KLL 能计算分位点和桶边界,但不能直接给出每个桶的 NDV(Number of Distinct Values,不同值数量)。而 NDV 又直接影响等值谓词的估计精度。

Databend 在第一次 ANALYZE 扫描中使用 HLL 估算整列 NDV:

Rust 复制代码
let column_ndv = self
    .ndv_states
    .get(&column_id)
    .map(|hll| hll.count() as f64);

KLL 根据 rank 推算每个等深桶的行数 bucket_values,再按行数比例估算桶内 NDV:

Plaintext 复制代码
bucket_ndv = column_ndv × bucket_values / total_values

结果被限制在以下范围:

Plaintext 复制代码
1 ≤ bucket_ndv ≤ bucket_values

例如:

Plaintext 复制代码
总行数       = 10,000
全列 HLL NDV = 1,000
桶数         = 100
每桶行数     ≈ 100

这种估算开销低,但隐含了"不同值在各个桶中按行数比例分布"的假设。数据倾斜越明显,等值查询的估计误差就越可能增大。

kll_fast 与 kll_full :在速度和精度之间选择

Databend 提供两种基于 KLL 的 ANALYZE 方式:

  • kll_fast:执行一次扫描,使用整列 HLL NDV 按比例估算各桶 NDV。

  • kll_full:先用 KLL 确定桶边界,再执行第二次扫描,统计每个桶的实际行数与 HLL NDV。

kll_full 的处理过程如下。

第一遍扫描:

Plaintext 复制代码
数据 → KLL Sketch → 近似分位点 → 固定桶边界

第二遍扫描:

Plaintext 复制代码
数据 → 根据桶边界分桶 → 每桶单独统计 Count 和 HLL NDV

每个桶维护以下状态:

Rust 复制代码
struct KllBucketStats {
    routing_upper_bound: Datum,
    observed_lower_bound: Option<Datum>,
    observed_upper_bound: Option<Datum>,
    count: u64,
    ndv: MetaHLL,
}

第二遍扫描每读到一个值,就寻找第一个满足以下条件的桶:

Plaintext 复制代码
value <= bucket.routing_upper_bound

然后更新桶的行数和 NDV:

Rust 复制代码
self.count += 1;
self.ndv.add_object(original_value);

与此同时,Databend 还会更新该桶实际观察到的最小值和最大值。最终桶 NDV 来自:

Rust 复制代码
bucket.ndv.count()

kll_full 用额外一次扫描换取更准确的桶级统计,但由于扫描和分桶本身仍有成本,它的执行时间不一定比传统 window 方案短。

为什么桶级 NDV 会影响等值查询

继续使用 salary 示例,并为每个桶加入 NDV:

Bucket Salary Range Rows NDV
B1 [1000, 2000) 250 50
B2 [2000, 4000) 250 100
B3 [4000, 7000) 250 150
B4 7000, 10000 250 80

对于等值谓词:

SQL 复制代码
SELECT *
FROM employee
WHERE salary = 4500;

4500 位于 B3。B3 包含 250 行和 150 个不同值。假设桶内数据均匀分布:

Plaintext 复制代码
Estimated Rows = Bucket Rows / Bucket NDV
               = 250 / 150
               ≈ 1.67

再看另一个查询:

SQL 复制代码
SELECT *
FROM employee
WHERE salary = 1500;

1500 位于 B1:

Plaintext 复制代码
Estimated Rows = 250 / 50
               = 5

虽然 B1 和 B3 的行数相同,但不同的 NDV 会产生不同的平均频率:

Bucket Rows NDV Avg Frequency
B1 250 50 5.00
B3 250 150 1.67

因此,范围谓词主要依靠桶边界和覆盖比例;等值谓词则更依赖桶内 NDV。KLL 很适合描述整体值域和分位点,但仅靠 KLL 与 NDV 仍无法识别桶内的热点值。

Benchmark:KLL 节省了什么,又牺牲了什么

下面比较传统 windowkll_fastkll_full 三种方案。q-error 越接近 1,说明估计结果越接近真实值。

Rows Algorithm Analyze elapsed RSS delta Peak RSS Overall q-error Eq q-error Range q-error
1M window 3.203s 333,016 KB (~325 MB) 2,059,548 KB geo 1.000, p90 1.000, max 1.000 geo 1.000, max 1.000 geo 1.000, max 1.000
1M kll_fast 0.657s 130,300 KB (~127 MB) 1,826,592 KB geo 2.412, p90 39.713, max 227.380 geo 5.236, max 227.380 geo 1.111, max 1.318
1M kll_full 4.266s 113,688 KB (~111 MB) 1,862,936 KB geo 1.251, p90 1.166, max 9.532 geo 1.564, max 9.532 geo 1.000, max 1.003
5M window 20.103s 1,093,400 KB (~1068 MB) 2,818,556 KB geo 1.000, p90 1.000, max 1.000 geo 1.000, max 1.000 geo 1.000, max 1.000
5M kll_fast 3.373s 442,104 KB (~432 MB) 2,167,888 KB geo 2.785, p90 198.086, max 224.573 geo 6.833, max 224.573 geo 1.135, max 1.799
5M kll_full 22.210s 466,308 KB (~455 MB) 2,191,748 KB geo 1.401, p90 1.157, max 46.612 geo 1.962, max 46.612 geo 1.000, max 1.002

结果体现出三个清晰的取舍:

  1. kll_fast显著缩短了 ANALYZE 时间。 在 1M 和 5M 数据集上,它分别约为 window 耗时的 20.5% 和 16.8%,同时明显降低内存增量。

  2. kll_fast 对范围谓词较稳定,但不适合单独承担等值估计。 两组测试中的 Range q-error geomean 分别为 1.111 和 1.135,而 Eq q-error geomean 上升到 5.236 和 6.833。

  3. kll_full 改善了整体和等值估计精度,但时间优势并不成立。 它的内存增量低于 window,范围估计几乎保持准确;不过第二次扫描使其耗时略高于 window,极端等值谓词仍可能存在明显误差。

这说明 KLL 解决的是直方图构建的资源问题,而不是所有数据倾斜问题。要准确处理高频值,还需要 Top-N 和 CMS。

Top-N:精确记录最重要的热点值

Top-N 用于保存单列中出现频率最高的一组值及其对应行数。与在全表层面一次性计算不同,Databend 采用分层采集和合并:

Plaintext 复制代码
Block 局部 Top-N
        │
        ▼ Merge
Segment / Source 局部 Top-N
        │
        ▼ Merge
Analyze Sink
        │
        ▼
表快照级、按列全局 Top-N

Block 级 Top-N 会保存在 Segment Statistics 中,可在增量 ANALYZE 时复用,避免每次重新扫描所有历史数据。

当等值谓词命中 Top-N 时,优化器可以直接使用记录的频率,避开"桶内均匀分布"这一假设。它对少量、稳定且非常突出的热点尤其有效。

Top-N 的边界也很明确:可保存的值数量有限。如果热点值很多、频率分布较宽,一些实际高频值可能被截断在 Top-N 之外。

CMS:以固定空间覆盖更宽的热点集合

Count-Min Sketch(CMS)是一种估计元素频率的概率数据结构。它使用多个 Hash 函数将输入值映射到二维计数器数组:

Plaintext 复制代码
hash(value, row0) → counter++
hash(value, row1) → counter++
...
hash(value, row4) → counter++

查询某个值的频率时,CMS 取多个计数器中的最小值:

Plaintext 复制代码
estimate(value) = min(counter[hash_i(value)])

因为 Hash 冲突只会让计数增大,所以 CMS 给出的是带上界偏差的近似频率。它使用固定空间,且和 KLL 一样支持分布式合并:

Plaintext 复制代码
Block CMS
    │
    ▼ Counter-wise Add
Segment / Source CMS
    │
    ▼ Counter-wise Add
Table Snapshot CMS

相比 Top-N,CMS 能覆盖更广的值集合,尤其适合"热点数量超过 Top-N 容量"的场景;代价是结果不再完全精确,并且需要控制 Hash 冲突产生的噪声。

Databend 如何组合 Top-N、CMS 与普通估计

Databend 对等值谓词采用分层的估计策略:

Plaintext 复制代码
Top-N 精确命中
        │ 未命中
        ▼
CMS 频率估算
        │ 不适用
        ▼
Top-N 带误差估算
        │
        ▼
普通 NDV / 直方图估算

CMS 不会无条件覆盖普通估计。优化器首先计算:

Plaintext 复制代码
lower_count  = cms_estimate - cms_error_bound
average_count = non_null_rows / NDV

只有满足以下条件时:

Plaintext 复制代码
lower_count > average_count

优化器才认为该值确实比平均频率高,并采用 CMS 结果。对于普通低频值,仍然使用常规 NDV 或直方图估算,从而避免 CMS 的碰撞噪声放大误差。

这一组合背后的思路是:

  • KLL 描述整体值域与分位点;

  • Top-N 精确保存最突出的热点;

  • CMS 覆盖 Top-N 容量之外的宽热点;

  • NDV 和直方图继续处理普通值。

Benchmark:CMS 如何修正 Top-N 漏掉的热点

测试数据包含 100 万行、10 万 NDV,Top-N 大小为 50,CMS 错误率为 0.001。wide_hot_key 的一半数据分布在 200 个热点值上,因此 Top-N 无法保存所有热点,而 CMS 仍可以估算它们的频率。

以下结果为三次重复实验的中位数和范围。

ANALYZE 开销

Variant Elapsed RSS delta
none 0.201s (0.193-0.279) 29.7 MiB (21.2-29.8)
topn 0.548s (0.517-0.931) 89.4 MiB (76.8-92.6)
cms 0.486s (0.429-0.502) 74.2 MiB (59.2-87.5)
topn_cms 0.642s (0.613-0.857) 79.0 MiB (75.5-92.6)

启用 Top-N 或 CMS 会增加统计收集时间与内存,但整体开销仍处于亚秒级。是否值得承担这部分成本,需要结合等值过滤在真实工作负载中的重要程度判断。

全部等值谓词的 q-error

Variant Geomean P50 P90 Max
none 25.354 24.189 2006.822 18190.086
topn 4.921 1.333 250.000 250.000
cms 1.355 1.036 2.500 10.000
topn_cms 1.355 1.036 2.500 10.000

仅使用普通统计信息时,等值谓词 q-error 的几何平均值为 25.354;启用 CMS 后下降到 1.355,P90 从 2006.822 降至 2.5。Top-N 能明显改善最突出的热点,但面对更宽的热点集合,CMS 的覆盖更完整。

wide_hot_key 的 q-error

Variant Geomean P50 P90 Max
none 65.266 208.681 208.681 208.681
topn 30.214 129.000 250.000 250.000
cms 1.202 1.080 1.667 1.667
topn_cms 1.202 1.080 1.667 1.667

以代表性的 wide_hot_key = 0 为例,真实结果为 2500 行:

统计方式 估算行数
Top-N 10
CMS 2716
Top-N + CMS 2716

这个结果揭示了 Top-N 的容量限制:当 200 个热点竞争 50 个位置时,某些热点会被漏掉;CMS 虽然是近似估计,却能把结果从 10 行修正到接近真实值的 2716 行。

如何为不同工作负载选择统计策略

没有一种统计方式能同时取得最低开销和最高精度。更合理的选择取决于查询模式与数据分布。

工作负载特征 更合适的策略 原因
以范围过滤为主,需要频繁刷新统计信息 kll_fast 单次扫描、内存较低,范围估计较稳定
更看重桶级 NDV 和一般等值估计 kll_full 第二次扫描可获得更准确的桶内统计
热点值少且高度集中 KLL + Top-N Top-N 可精确记录最常见值
热点值较多,超过 Top-N 容量 KLL + CMS,或 KLL + Top-N + CMS CMS 能覆盖更宽的频率分布
数据分布接近均匀,等值过滤不关键 KLL + 普通 NDV 避免额外统计开销

在 Databend 中,这些概率数据结构的价值不只是让 ANALYZE 更快,而是帮助优化器用有限资源建立足够可靠的数据画像。对于存储在对象存储中的大规模数据,降低统计收集的扫描、排序和内存成本,能够减少后台维护对前台查询的影响,也让计算资源的使用更加可控。

总结

KLL、Top-N 和 CMS 分别解决了基数估计中的三个不同问题:

  • KLL 以一次扫描和可合并 Sketch 近似分位点,降低等深直方图构建的排序与内存开销。

  • Top-N 精确记录最常见值,修正直方图对突出热点的估计。

  • CMS 用固定空间覆盖更宽的热点集合,弥补 Top-N 容量有限的问题。

Benchmark 也说明了它们的边界:kll_fast 适合快速、低内存地生成范围统计,但单独用于等值估计时误差较大;kll_full 提高了桶级精度,却不一定缩短执行时间;CMS 则能以有限的额外开销显著改善宽热点数据的等值估计。

对 Databend 这样的云原生分析数据库而言,优化器统计不是一个孤立的内核功能。更低的 ANALYZE 成本、更准确的基数估计和更稳定的执行计划,最终都会反映到大规模分析的查询性能、资源效率与成本可预测性上。

相关推荐
bonechips1 小时前
RAG实战(一):EPUB 加载、文本切割与向量入库
前端·数据库
imuliuliang1 小时前
分治法在大数据计算中的并行化应用探索7
算法
Lsir10110_2 小时前
【力扣hot100】矩阵题通关复盘
算法·leetcode·矩阵
退休倒计时2 小时前
【每日一题】LeetCode 207. 课程表 TypeScript
算法·leetcode·typescript
xialage1682 小时前
服装辅料厂家生成式引擎GEO优化之时机、价值与落地路径
大数据·百度
稚南城才子,乌衣巷风流2 小时前
树的重心:概念、性质与算法详解
算法·深度优先·图论
何时梦醒2 小时前
🏯 从零搭建《天龙八部》RAG 智能问答系统 — 完整实战学习日志
数据库·人工智能
无忧.芙桃2 小时前
MySQL数据库原理与实践(四):基本查询
大数据·数据库·mysql
不会就选b2 小时前
算法日常・每日刷题--<哈希>1
算法·哈希算法