上一篇 介绍了 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 上的分桶承担两件事:
- 数据分片:不同桶可以分散到 ClickHouse 不同 shard 上,让构造与查询都用上分布式集群的并行能力。
- 方差估计:实验平台 / 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 值集中在一个 (约 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 值范围压到了 ≈ 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 字节 / 元素) 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 的常见组合方式。
容量:单桶最多承接 个 unique key(位置编码用 UInt32 表示 position);全系统容量约 ,足够承载目前大部分业务。
-- 概念性 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 直接相加)。
还原成 Map:ToMap 得到每个 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 适合迁移的场景
满足以下三个条件时,迁移收益显著:
- 按
UIntkey 做 join,或按 key 做GROUP BY,且 key 基数较高(千万到亿量级); - 参与运算的列是整数或浮点数(或可量化为整数);整数位宽在
UInt64或对应有符号类型范围内,浮点数走默认 40 + 24 定点编码(精度上限见 §2.2 浮点说明); - 下游运算是 pointwise 算术 / 条件过滤 / sum / count 这类可向量化的操作。
2.2 不适合迁移的场景
- 值域是
Decimal或字符串。BSI 只定义在整数(含定点小数模拟实数)上,无法直接承接这类值域。Decimal的核心承诺是十进制精确(例如0.1 + 0.2 = 0.3严格成立),但 BSI 的定点小数底层是二进制(按 分刻度),无法精确表示所有十进制小数——硬塞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 key或A JOIN B ON A.key = B.key后再按 key 聚合,且 key 基数 、cols都是实数列(整数或浮点),则属于适合迁移的场景。
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));
va 和 vb 共享同一套位置编码,“同一 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 的 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 TB | 1.6 TB | ~40%(降 60%) |
单次全局 sum 耗时 | 59.2 s | 0.6 s | ~100× |
| 预计算 CPU hours | x | x / 4 | ~4× |
| Ad-hoc 查询平均延迟 | 22.3 s | 6.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
groupBitmap:groupBitmap只回答”是否存在”,不携带值;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),桶内位置编码完全不变。