上一篇 介绍了 NumericIndexedVector 的内部机制——BSI 编码、加 / 减 / 乘 / 除 / 比较的位级算法、以及 ClickHouse 里”类型 + 聚合函数 + 普通 UDF”的三层结构。本篇介绍它的最佳实践:分桶设计与位置编码工作机理、判断什么样的 workload 适合迁移、SQL 如何改写与写入链路如何设计,以及一份来自论文的实测收益。

本文使用的 benchmark 数字均来自 Xiong & Wang 的论文在微信场景中的实测。

1. 分桶与位置编码

NumericIndexedVector 用得好不好,关键看两件事——分桶位置编码。分桶让 vector 能分片到分布式集群、并支持实验平台所需的方差估计;位置编码在桶内再做一次”key → 紧凑 position”的重新编码,让每个 BSI bitmap 都接近最省存储。本节先按 32 位 key 把这两件事讲清楚(§1.1 + §1.2),§1.3 再延伸到 64 位 key——本质上是同一套 hash + 分桶 + 桶内编码的方案。

1.1 分桶的两个核心作用 + MurmurHash32 方案

NumericIndexedVector 上的分桶承担两件事:

  1. 数据分片:不同桶可以分散到 ClickHouse 不同 shard 上,让构造与查询都用上分布式集群的并行能力。
  2. 方差估计:实验平台 / A-B 测试场景下需要为指标估计方差。如果分桶是随机且统计独立的,可以直接基于桶聚合结果估算整体指标的方差(具体方法见 Covariance Estimation and its Application in Large-Scale Online Controlled Experiments),不必再扫描一次原始明细。

工程上同时满足两个作用的常见方案是 MurmurHash32 + 高位连续号段分桶(适用于 UInt32 key 场景):

  • MurmurHash32 在 UInt32 空间内是双射——业务 key 经 hash 后分布均匀,原 key 也可由 hash 反推。这一性质同时保证:分片均匀(作用 1)、桶内随机独立(作用 2)。
  • 取 hash 值的 高 10 位作为 bucket id,可分 1024 个桶。bucket id 仅作分片路由(决定数据落到哪个 shard / 哪个 vector)。
  • vector 内部按 完整的 32 位 hash 值 构造(不截断高 10 位)。桶内的 hash 值集中在一个 2222^{22}(约 4M)的连续 hash 段里——具体是 [bucket_id × 2^{22}, (bucket_id+1) × 2^{22})。桶内 hash 值数量约为总基数的 1/1024(几万到几十万个),仍稀疏分布在 4M 个位置里,由 §1.2 的位置编码在桶内做 0 起步重编码,把每个 BSI bitmap 压到接近最优。
-- MurmurHash32 + 高 10 位分桶(UInt32 key)
SELECT
  dt,
  bitShiftRight(murmurHash3_32(key), 22) AS bucket_id,
  groupNumericIndexedVectorState(murmurHash3_32(key), val) AS v_state    -- vector 内按完整 hash 构造
FROM source_table
GROUP BY dt, bucket_id;

三个反例,对应”分桶 / hash / 高位连续号段”三个设计层面各失败一种:

  • 不分桶,把业务 key 全部灌进单个 vector:所有数据落在同一个 vector 上,无法分散到不同 shard,分布式集群的并行能力被浪费;单 vector 体积也过大,构造与查询都会变慢。
  • 分桶但不 hash:直接按业务 key 的高位分桶。业务 key 高位通常不均匀(user-id 集中在某段、订单号按时间递增等),桶之间负载偏斜——分片不均、方差估计也失效。
  • hash 后用 mod 等方式随机分桶(而非高位连续号段):桶内的 key 不再集中在一个连续 hash 段、而是散落到整个 32 位空间。底层 RoaringBitmap 的 top-level container 大都摊到极少的 key、几乎都退化成 Array 存储,单桶存储成本上升、查询也变慢。

1.2 位置编码:桶内的进一步紧凑编码

§1.1 把每个桶承接的 hash 值范围压到了 2222^{22} ≈ 4M 的连续段。但桶内实际出现过的 hash 值(约几万到几十万个)仍稀疏分布在这 4M 个位置里,不会自动落到 0..N 的连续区间。

位置编码在每个桶内再做一次重新编码:把桶内出现过的 key 按出现顺序映射到 0, 1, 2, ... 连续位置上,BSI 的每个 bitmap 都基于这个连续 position 空间来构造(论文 §3.4)。

工程价值有两条:

  • 桶内 BSI bitmap 极致紧凑:每个 bitmap 的 cardinality 等于”非零 key 数量”,且都是 0 起步的连续整数,落到底层 RoaringBitmap 的 Run / Array container 上——存储成本接近最优。底层 RoaringBitmap 的三类 container 选型规律:

    桶内 position 分布Container 类型每桶开销
    连续区间多(位置编码后的常态)Run(start, length)最小,几十字节描述整段
    稀疏(≤ 4096)Array:排序数组(2 字节 / 元素)8\leq 8 KB
    密集随机(> 4096)Bitmap:8 KB 原始位图固定 8 KB

    位置编码让桶内 position 集中到少数 top-level container 上(多为最省的 Run container),每桶仅几十字节到几 KB;不做位置编码时桶内 key 散落在业务 key 空间,需要更多 top-level container 承接,整体存储成本随 key 分布的稀疏度上升。

  • 共享编码可消除 join:两个 vector 共享同一份位置编码时,“同一 position 即同一 key”。后续的 pointwise 运算因此可以隐式完成”按 key 匹配”,不再需要显式 join 或 shuffle。

位置编码一致性也是后面使用阶段的隐性前提:两个参与 pointwise 运算的 vector 必须来自同一份编码,否则”同一 position”对应的 key 不同。跨分区 / 跨版本合并前需要先通过 Merge 显式统一编码。

1.3 把 index 扩展到 64 位 key

业务中的 key 经常是 64 位——全局雪花 id、跨租户全局 user-id、哈希指纹等。承接 64 位 key 的方案沿用 §1.1 的同一套 hash + 分桶 + 桶内位置编码 思路,只是 hash 函数换成 64 位输出,分桶用更直接的 mod:

  • hash + 分桶:用 murmurHash3_64 把业务 key 打散成 64 位 hash 值,对 1024 取余作为 bucket_id,得到 1024 个桶(和 §1.1 同一量级,足以分片,单桶基数也保持在合理范围);

  • 桶内位置编码:每个桶内部维护一个 编码字典 + 当前最大编码值,把出现过的 key 映射到 0 起步的连续编码上——已经编码过的 key 返回旧编码,新 key 分配 max + 1。这就是位置编码本身的逻辑:

    # 每个 bucket_id 各自维护一份编码字典 + 当前最大编码值
    encoding: Dict[bucket_id, Dict[key, UInt32]] = {}
    next_pos: Dict[bucket_id, UInt32] = {}
    
    def encode(bucket_id, key) -> UInt32:
        if key in encoding[bucket_id]:
            return encoding[bucket_id][key]   # 已编码过,返回原编码
        pos = next_pos[bucket_id]
        encoding[bucket_id][key] = pos        # 新 key,分配下一个连续编码
        next_pos[bucket_id] = pos + 1
        return pos

当前限制NumericIndexedVector 在 ClickHouse 内目前没有原生支持 64 位 key(key 参数类型上限是 UInt32,详见上一篇 §6)。本节给出的”hash 分桶 + 桶内位置编码”是用现有 32 位接口在工程层面承接 64 位业务 key 的常见组合方式。

容量:单桶最多承接 2322^{32} 个 unique key(位置编码用 UInt32 表示 position);全系统容量约 1024×2324.4×10121024 \times 2^{32} \approx 4.4 \times 10^{12},足够承载目前大部分业务。

-- 概念性 SQL:encode 是上面定义的位置编码逻辑(实际由 NumericIndexedVector 内部完成)
SELECT
  dt,
  murmurHash3_64(key64) % 1024 AS bucket_id,
  groupNumericIndexedVectorState(
    encode(bucket_id, key64),    -- 桶内字典查询 / 自增分配
    val) AS v_state
FROM raw_64bit_table
GROUP BY dt, bucket_id;

数据模型:

CREATE TABLE vec_64 (
  dt Date,
  bucket_id UInt32,     -- murmurHash3_64(key64) % 1024
  v AggregateFunction(groupNumericIndexedVector, UInt32, UInt64),
  PRIMARY KEY (dt, bucket_id)
);

查询时三种典型操作:

Pointwise 运算:两侧表按 bucket_id 做 inner join,对同一 bucket 内的 vector 执行 pointwise 运算;两侧 bucket 不匹配时按运算语义处理——加法补 0、乘法丢弃、减法保留左值。

SELECT numericIndexedVectorAllValueSum(
  numericIndexedVectorPointwiseMultiply(
    groupNumericIndexedVectorMerge(a.v_state),
    groupNumericIndexedVectorMerge(b.v_state)))
FROM vec_a a INNER JOIN vec_b b
  ON a.bucket_id = b.bucket_id;

全局聚合AllValueSum / Cardinality 在每个 bucket 的结果上再做一次标量聚合(sum 对 sum、cardinality 对 cardinality 直接相加)。

还原成 MapToMap 得到每个 bucket 的 Map(UInt32, value)——返回的是构造时传入的位置编码值(即 encode(bucket_id, key64) 的结果)。业务层若需要回到原始 64 位 key,需维护一份逐桶的反向映射 (bucket_id, position) → key64;反解时一定要带上 bucket_id,因为不同桶里的相同 position 对应的是不同的 key64

具体桶数可以根据业务量级合理设置(这里以 1024 为例)。

分桶设计正确时,§4 的 benchmark 数字才能完整复现——存储与查询的数量级收益均依赖 hash + 分桶 + 位置编码 这套组合到位

2. 适用场景

迁移到 NumericIndexedVector 的工程成本包括:构造路径多一条、物化视图改造、下游 SQL 重写。因此第二件事是判断手上的 workload 是否值得迁移。

2.1 适合迁移的场景

满足以下三个条件时,迁移收益显著:

  1. UInt key 做 join,或按 key 做 GROUP BY,且 key 基数较高(千万到亿量级);
  2. 参与运算的列是整数或浮点数(或可量化为整数);整数位宽在 UInt64 或对应有符号类型范围内,浮点数走默认 40 + 24 定点编码(精度上限见 §2.2 浮点说明);
  3. 下游运算是 pointwise 算术 / 条件过滤 / sum / count 这类可向量化的操作。

2.2 不适合迁移的场景

  • 值域是 Decimal 或字符串。BSI 只定义在整数(含定点小数模拟实数)上,无法直接承接这类值域。Decimal 的核心承诺是十进制精确(例如 0.1 + 0.2 = 0.3 严格成立),但 BSI 的定点小数底层是二进制(按 2f2^{-f} 分刻度),无法精确表示所有十进制小数——硬塞 Decimal 进去等于把它退化成浮点,丢掉了它存在的根本意义。建议在上游把 Decimal 乘以系数转成整数(例如金额一律乘 100 存为分)后再使用。
  • 需要按非 key 列做 group。vector 的维度就是 key,更换 group 维度等价于重新构造 vector。
  • 需要窗口函数 / rank / 顺序相关语义。位置编码不保留时序,也不保留任何顺序关系。
  • key 基数较低(百万以下)且查询模式简单。行式方案的绝对延迟通常更低,构造 vector 的开销反而拖累性能。

浮点的说明:浮点本身是支持的——会走上一篇介绍的定点小数路径(默认 24 位小数,约 7 位有效十进制小数)。但如果业务对精度有极端要求(超出 24 位小数能表达的范围),建议在上游乘以系数转成整数后再使用。

2.3 一句话总结

如果 SQL 形如 SELECT key, f(cols...) FROM ... GROUP BY keyA JOIN B ON A.key = B.key 后再按 key 聚合,且 key 基数 107\geq 10^7cols 都是实数列(整数或浮点),则属于适合迁移的场景。

3. 使用教程

确定了 workload 适用之后,使用教程分两部分:SQL 改写的三个常见范式(§3.1–§3.3)和写入链路的两种构造方案(§3.4–§3.5)。

3.1 SQL 改写:单表 pointwise 计算

场景:单表内、同 key 下的列间算术。

-- 行式
SELECT key, val_a * val_b + val_c AS combined
FROM T;

vector 改写:

WITH
  va AS (SELECT groupNumericIndexedVector(key, val_a) FROM T),
  vb AS (SELECT groupNumericIndexedVector(key, val_b) FROM T),
  vc AS (SELECT groupNumericIndexedVector(key, val_c) FROM T)
SELECT numericIndexedVectorToMap(
  numericIndexedVectorPointwiseAdd(
    numericIndexedVectorPointwiseMultiply(va, vb),
    vc));

收益来源:

  • 行式 scan 替换为 RoaringBitmap 的按位运算。高位 bitmap 普遍为空,CPU 实际处理的位数远小于行式扫描的总字节数。
  • 需要 per-key 明细使用 numericIndexedVectorToMap,需要全局聚合则用 numericIndexedVectorAllValueSum
  • 单表场景的提速通常在几倍到十几倍,比下面的 join 场景小一个数量级。

3.2 SQL 改写:两表按 key join + 聚合

场景A JOIN B ON A.key = B.key 后做 pointwise 运算 + 全局或 per-key 聚合。本范式的收益最为显著。

-- 行式:hash join + 行式 sum
SELECT sum(A.val_a * B.val_b)
FROM A JOIN B ON A.key = B.key;

vector 改写:

WITH
  va AS (SELECT groupNumericIndexedVector(key, val_a) FROM A),
  vb AS (SELECT groupNumericIndexedVector(key, val_b) FROM B)
SELECT numericIndexedVectorAllValueSum(
  numericIndexedVectorPointwiseMultiply(va, vb));

vavb 共享同一套位置编码,“同一 position 即同一 key”——hash join 的语义被编码消化,无 shuffle、无行式 scan。需要 per-key 明细时将外层的 AllValueSum 替换为 ToMap

本范式的收益不是线性的——key 基数越大、join 越倾斜,收益越显著。论文实测同 workload 的预计算 CPU 消耗降低到原来的 ~1/4,ad-hoc 平均延迟从 22.3 s 降到 6.0 s(§4 给出完整对照)。

3.3 SQL 改写:条件过滤后求和

场景sum(if(cond, val, 0)) / sum(val) WHERE cond 这类条件聚合。

-- 行式
SELECT sum(if(val_a < 100, val_b, 0))
FROM T;

vector 改写:

WITH
  va AS (SELECT groupNumericIndexedVector(key, val_a) FROM T),
  vb AS (SELECT groupNumericIndexedVector(key, val_b) FROM T)
SELECT numericIndexedVectorAllValueSum(
  numericIndexedVectorPointwiseMultiply(
    numericIndexedVectorPointwiseLess(va, 100),
    vb));

要点:

  • numericIndexedVectorPointwiseLess(va, 100) 输出一个 value {0,1}\in \{0, 1\} 的 vector——即满足条件的 key 集合
  • PointwiseMultiply 将该 mask 作为 filter 与 vb 相乘;
  • AllValueSum 收尾。

比较 → 乘法 mask → 聚合 是 vector SQL 中最常用的三步组合。§3.2 的 join 改写中常会再嵌套一层条件 mask,结构与本范式一致。

3.4 构造时机:Ad-hoc vs 预聚合

确定了 SQL 形态之后,剩下的问题是这些 vector 在什么时机构造。Ad-hoc 构造(查询时执行 groupNumericIndexedVector)和预聚合(写入时通过物化视图把 vector 落到 AggregateFunction 列)是两种不同的工程方案。

Ad-hoc 构造 —— 查询时直接读表 SELECT groupNumericIndexedVector(key, val) FROM T

  • 适用:key 基数可控、扫描分区数量有限、查询维度灵活(同一张表可能按多个不同的 group 维度访问)。
  • 成本:每次查询都需要扫描整列并构造 vector,构造路径本身耗时较大。

预聚合 —— 建立带 AggregateFunction(groupNumericIndexedVector, ...) 列的目标表,使用物化视图在写入时持久化 vector:

CREATE MATERIALIZED VIEW mv_vectors TO target_table AS
SELECT
  dt,
  groupNumericIndexedVectorState(key, val_a) AS va_state,
  groupNumericIndexedVectorState(key, val_b) AS vb_state
FROM source_table
GROUP BY dt;

查询时通过 groupNumericIndexedVectorMerge 将 state 合并为 vector,再传入 pointwise / 聚合算子:

SELECT numericIndexedVectorAllValueSum(
  numericIndexedVectorPointwiseMultiply(
    groupNumericIndexedVectorMerge(va_state),
    groupNumericIndexedVectorMerge(vb_state)))
FROM target_table
WHERE dt BETWEEN '2026-01-01' AND '2026-01-29';
  • 适用:同一张 vector 表被反复查询的高频 workload,例如 A/B 实验指标平台、用户画像查询、订单 / 留存面板。
  • 成本:写入链路变长,存储多一份 AggregateFunction。§4 给出的收益数字主要来自该路径。

3.5 注意事项

维度结论原因
位宽选择按值域最大宽度保守取整(需要 UInt24 时使用 UInt32高位未使用时对应 bitmap 为空,存储与计算成本接近零;溢出则需要截断或抛异常
有符号 vs 无符号可能出现负值或做差运算时强制使用有符号补码扩展只在构造期完成;事后转换需要重新构造所有 bitmap
位置编码一致性pointwise 运算的两个 vector 必须来自同一份编码不同编码下”同一 position”对应的 key 不同;跨分区 / 跨版本合并前需先通过 Merge 统一(§1.2)
不适用的操作不在 vector 上执行窗口函数 / 排序 / rank位置编码不保留时序与顺序

4. 实测收益与对照

本节数字均来自 Xiong & Wang 论文在微信场景中的实测。四张关键表合并为一张:

维度行式NumericIndexedVector收益
存储总量4.1 TB1.6 TB~40%(降 60%)
单次全局 sum 耗时59.2 s0.6 s~100×
预计算 CPU hoursxx / 4~4×
Ad-hoc 查询平均延迟22.3 s6.0 s~3.7×

四个数字对应四种不同的收益来源,分别说明如下。

4.1 存储减少 60%:BSI 是压缩格式

行式 (key, value) 列对每对 key-value 都要存一份完整元组(例如 UInt32 + UInt64 ≈ 12 字节),存储成本随 unique key 基数线性增长。Vector 把这件事拆成两层:位置编码把 key 映射到 0..N-1 的紧凑 position;BSI 再把 value 按二进制位切成多个 bitmap,每个 bitmap 都用 RoaringBitmap 紧凑存储——位置编码后的连续 position 落到 Run container 上几乎不占字节,value 高位的 bitmap 整片为空时也不占空间。稀疏列在 BSI 下天然紧凑——未压缩状态下的 vector 大小与行式列经过 LZ4 / ZSTD 压缩后的大小相当。BSI 在编码阶段完成了压缩,再叠常规通用压缩的额外收益有限。

4.2 单次 sum 提速 ~100×:SIMD + 稀疏位宽

numericIndexedVectorAllValueSum 在压缩表示上直接按位执行 POPCOUNT × 位权。高位 bitmap 普遍为空,CPU 实际处理的数据量是行式的个位数百分比,再叠加 SIMD 即得到 100× 的提速。行式方案的优化上限是”不解压、不反序列化”,但列的总字节数是固定开销,无法消除。

4.3 预计算 CPU 降到 ~1/4:join 被编码消化

该收益来自 §3.2 的 join 改写。hash join + shuffle 的开销随 key 基数线性增长,改为 PointwiseMultiply + AllValueSum 后,key 不再是被匹配的维度,而是被编码进了 position。CPU 与网络带宽同步降低,shuffle 阶段被完全消除。

4.4 Ad-hoc 平均延迟 ~3.7×:三种收益叠加

Ad-hoc 查询的延迟下降来自三种收益的叠加:存储减少 → IO 减少;运算向量化 → CPU 减少;join 消除 → 没有 shuffle 抖动。22.3 s → 6.0 s 的平均值掩盖了 tail 延迟的差异——重度 join 查询场景下,vector 方案的 p99 延迟改善幅度大于平均延迟改善幅度

4.5 横向对照与边界

  • vs groupBitmapgroupBitmap 只回答”是否存在”,不携带值;NumericIndexedVector 是其上位替代。但在不需要数值的画像 / 圈人场景下,groupBitmap 仍是首选。
  • vs 行式 (key, value) + hash join:行式更灵活,但成本随 key 基数线性增长。在小基数 / 简单查询场景下,行式方案的绝对延迟通常更低。
  • vs dense array / 定长数值向量:小 key 空间(百万以内的连续 id)下 dense 表示更快;稀疏 / 大 key 空间(千万以上、非连续)需要 BSI 才能承载存储。
  • 不适用场景:高基数非 key 列 group、Decimal / 字符串值域、窗口函数 / 时序语义。与 §2.2 列出的不适用场景一致。

结语

NumericIndexedVector 的最佳适用场景是 「高基数 UInt key + 实数列(整数或浮点) + pointwise / sum / count 下游」。在该场景下,论文实测的收益是 存储 -60%、单点 sum 100×、预计算 CPU 4×、ad-hoc 延迟 3.7×;本篇按”分桶 → 适用场景 → 使用教程”的顺序展开,正是因为前两项工程设计(分桶位置编码)决定了上述收益能不能拿满。

将上一篇的”算法层”与本篇的”工程层”放在一起,整个系列中反复出现、容易被忽略的几条设计取舍:

  • 编码阶段完成压缩:BSI 的多个 bitmap 之间的稀疏性本身承担了压缩职责,这与行式列依赖 LZ4 / ZSTD 是两套思路。该结论解释了为什么 BSI 列再叠通用压缩的额外收益有限。
  • join 消除的本质是 key 进入 position:位置编码把”按 key 匹配”编码进了位置,因此 pointwise 运算等价于隐式 join。能否拿到该收益取决于两个 vector 是否共享同一份位置编码——这也是 §3.5 注意事项中将”位置编码一致性”列在表中的原因。
  • 分桶 + 位置编码是工程边界:单桶 vector 内部的存储成本对位置编码后的 position 分布高度敏感,最好与最差档位之间可达一个数量级。业务 key 在构造前需要做 hash + 分桶,避免直接灌进单一 vector。
  • 64 位 key 不是单独的扩展,而是同一套方案换 hash 函数 + 分桶方式:把 murmurHash3_32 + 高位号段分桶(§1.1)换成 murmurHash3_64 + mod 分桶(§1.3),桶内位置编码完全不变。