本文以 Spark 4.0 官方文档为基准说明。需要先明确一点:
1.3.0,但 SQL 层并没有直接暴露一个叫 RoaringBitmap 的 API 给使用Spark 4.0 常用的 bitmap 函数主要有 5 个:
bitmap_bit_position(col):把输入值映射成位位置bitmap_bucket_number(col):把输入值映射成桶号bitmap_construct_agg(col):把一组位位置聚合成 bitmapbitmap_or_agg(col):把多个 bitmap 做 OR 并集bitmap_count(col):统计 bitmap 中 置 1 的位数,也就是去重后的数量Bitmap 本质上就是一种用二进制位表示集合成员关系的数据结构。
如果某个整数出现过,对应位置记为 1;没出现过,记为 0。
例如,集合:
{1, 2, 4}
可以理解为:
1 出现 -> 对应位设为 12 出现 -> 对应位设为 13 没出现 -> 对应位设为 04 出现 -> 对应位设为 1所以它特别适合做两类事:
这是 bitmap/bitset 的基本原理;而 Spark 4.0 的 SQL bitmap 函数就是围绕这个原理构建的。
在 Spark SQL 中,通常是这条链路:
原始整数ID -> 位位置 -> bitmap -> 计数
对应函数就是:
bitmap_bit_position(user_id):把 user_id 映射成位位置bitmap_construct_agg(...):聚合成位图bitmap_count(...):统计置位数sql自动换行:关放大阅读展开代码WITH t AS ( SELECT * FROM VALUES (1), (2), (2), (4) AS tab(user_id) ) SELECT bitmap_count(bitmap_construct_agg(bitmap_bit_position(user_id))) AS distinct_cnt FROM t;
结果:
sql自动换行:关放大阅读展开代码3
解释:
1、2、4 各占一个位置2 虽然出现两次,但同一位只会被置一次3这就是 bitmap 去重最核心的原理。
count(distinct) 在大数据场景下代价高的问题在大数据里,最常见需求之一是:
直接写:
sql自动换行:关放大阅读展开代码COUNT(DISTINCT user_id)
当然可以,但在数据量大、分区多、重复值很多时,通常会带来更高的:
而 bitmap 的思路是:
所以它更像是把“去重”转换成“置位 + 计数”。
在分布式场景里,经常不是一次算完,而是:
bitmap 特别适合这个模式,因为局部结果可以先变成 bitmap,再通过 bitmap_or_agg 合并。
sql自动换行:关放大阅读展开代码WITH p1 AS ( SELECT bitmap_construct_agg(bitmap_bit_position(user_id)) AS bm FROM VALUES (1), (2), (2) AS tab(user_id) ), p2 AS ( SELECT bitmap_construct_agg(bitmap_bit_position(user_id)) AS bm FROM VALUES (2), (3) AS tab(user_id) ) SELECT bitmap_count(bitmap_or_agg(bm)) AS distinct_cnt FROM ( SELECT bm FROM p1 UNION ALL SELECT bm FROM p2 ) s;
结果:
sql自动换行:关放大阅读展开代码3
解释:
{1,2}{2,3}bitmap_or_agg 合并后得到 {1,2,3}bitmap_count 得到 3这说明 bitmap 很适合 分区预聚合 -> 全局合并 的计算模型。
如果 ID 值域很大,只构造一张超大的位图会很浪费。
所以 Spark 4.0 同时提供了:
bitmap_bucket_numberbitmap_bit_position这意味着 Spark 并不是简单让你维护一张无限扩大的 bitmap,而是让你把大值域拆成 bucket + 位位置 的方式来处理。
plaintext自动换行:关放大阅读展开代码WITH t AS ( SELECT * FROM VALUES (1), (2), (70000), (70001), (70001) AS tab(user_id) ), bucketed AS ( SELECT bitmap_bucket_number(user_id) AS bucket_id, bitmap_construct_agg(bitmap_bit_position(user_id)) AS bm FROM t GROUP BY bitmap_bucket_number(user_id) ) SELECT SUM(bitmap_count(bm)) AS distinct_cnt FROM bucketed;
结果:
sql自动换行:关放大阅读展开代码4
这个写法的重点不是你手工算每一位,而是:
当然看后续的roaring bitmap实现,它可以自动对不同的数据密集程度做自动的压缩处理。
这就是 Spark 4.0 里 bitmap 处理大值域整数的标准思路。
例如:
典型需求:
例如:
bitmap_or_agg 合并bitmap_count这比直接传输大批去重后的明细值更适合分布式执行。
bitmap 的好处不只是最终的去重数,还包括:
bitmap 天然更适合整数集合。
如果原始键是:
通常要先做整数映射。 如果直接哈希成整数再去重,会引入哈希碰撞风险,此时就不是绝对精确去重。
如果数据量不大、逻辑也不复杂,直接:
sql自动换行:关放大阅读展开代码COUNT(DISTINCT user_id)
往往更直观。
如果你并不要求精确值,而只想要近似结果,Spark 4.0 里还有:
approx_count_distinct这种场景下,bitmap 不一定是最优选择。
也可以理解成:
Roaring Bitmap = 更适合大数据场景的压缩版 bitmap
普通 bitmap 的特点是:
例如只存两个值:
{1, 100000000}
普通 bitmap 也得面对非常大的位空间。
Roaring Bitmap 的核心改进是:
Roaring 官方和格式规范都明确说明了这一点。
| 维度 | 普通 Bitmap | Roaring Bitmap |
|---|---|---|
| 基本思想 | 一值一位 | 一值一位,但按块压缩 |
| 空间占用 | 与最大值强相关 | 与数据分布强相关 |
| 稀疏数据 | 容易浪费 | 更友好 |
| 连续区间 | 没有特别优化 | 可用 run container |
| 稠密数据 | 很直接 | 可退化成 bitmap container |
| 工程复杂度 | 简单 | 更复杂,但更实用 |
在 Spark 4.0 中,你写的是 bitmap SQL 函数,不是直接写 Roaring Bitmap API:
bitmap_construct_aggbitmap_or_aggbitmap_count但从 Spark 4.0 release note 可以确认,Spark 运行时依赖了 RoaringBitmap,而 Roaring 官方站点也把 Apache Spark 列为使用者之一。
因此可以把关系理解为:
Spark 4.0 对外提供 bitmap 语义,对内使用更高效的压缩位图实现。
需要注意的是:
这也是为什么在 Spark 4.0 里,bitmap 既能保持集合/去重语义,又更适合大规模数据计算。
sparksql自动换行:关放大阅读展开代码SELECT bitmap_count(bitmap_construct_agg(bitmap_bit_position(user_id))) AS uv FROM your_table;
sparksql自动换行:关放大阅读展开代码SELECT dt, bitmap_count(bitmap_construct_agg(bitmap_bit_position(user_id))) AS uv FROM your_table GROUP BY dt;
sparksql自动换行:关放大阅读展开代码WITH bucketed AS ( SELECT bitmap_bucket_number(user_id) AS bucket_id, bitmap_construct_agg(bitmap_bit_position(user_id)) AS bm FROM your_table GROUP BY bitmap_bucket_number(user_id) ) SELECT SUM(bitmap_count(bm)) AS uv FROM bucketed;
sparksql自动换行:关放大阅读展开代码SELECT bitmap_count(bitmap_or_agg(bm)) AS uv FROM partial_bitmap_table;
“正交 Bitmap”不是某一种单独的数据结构,更准确地说,它是一种把去重对象按互斥规则拆分后,再分别做 Bitmap 聚合,最后把结果直接相加的计算方法。
在用户去重场景里,所谓“正交”指的是:
可以写成:
U = U0 ∪ U1 ∪ U2 ∪ ... ∪ U(N-1) 且任意 Ui ∩ Uj = 空集
那么对于某个查询用户集合 S:
|S| = Σ |S ∩ Ui|
在海量数据场景下,普通 Bitmap 聚合通常是这样做的:
这个方案在用户规模达到几亿时会遇到明显问题:
正交 Bitmap 不再试图构造“一个超级大的全局 Bitmap”,而是先把用户集合拆成若干互斥桶:
bucket_id = 某个对 user_id 的确定性映射
例如:
这样一来:
本质上就是: 全局大对象聚合 改为 多个互斥小对象聚合 + 数字求和
这比“把所有用户都塞进一个大 Bitmap 再 OR”更适合分布式系统。
目标:统计 2026Q1 去重活跃用户数。
源表:
sparksql自动换行:关放大阅读展开代码dwd_user_event( dt DATE, user_id BIGINT, event_name STRING, channel STRING )
活跃定义:event_name in ('login', 'visit', 'pay')
如果直接按季度扫明细再做全局 Bitmap 聚合,随着用户规模变大,最终汇总状态会越来越重。
更合理的方式是:
Spark 4.0 已提供相关内置函数,可用于实现这种按桶的正交 Bitmap 聚合:
bitmap_bucket_number(expr)
bitmap_bit_position(expr)
bitmap_construct_agg(expr)
bitmap_or_agg(expr)
bitmap_count(expr)
在 Spark 里,正交能力是通过“桶 + Bitmap 聚合”组合实现的。
sparksql自动换行:关放大阅读展开代码CREATE TABLE IF NOT EXISTS mart_user_active_day_bitmap ( bucket_id BIGINT, user_bitmap BINARY, dt DATE ) USING PARQUET PARTITIONED BY (dt);
sparksql自动换行:关放大阅读展开代码INSERT OVERWRITE TABLE mart_user_active_day_bitmap SELECT bitmap_bucket_number(user_id) AS bucket_id, bitmap_construct_agg(bitmap_bit_position(user_id)) AS user_bitmap, dt FROM dwd_user_event WHERE dt = DATE '2026-01-01' AND event_name IN ('login', 'visit', 'pay') AND user_id IS NOT NULL GROUP BY dt, bitmap_bucket_number(user_id);
它不是在算当天 UV 数,而是在生产“当天每个 bucket 的用户集合状态”。
可以理解为:
这样每日产出的不是单个超大 Bitmap,而是一批按 bucket 拆分的 Bitmap 状态。
季度查询时,不直接扫明细,而是扫日预聚合结果。
sparksql自动换行:关放大阅读展开代码WITH q1_bucket_bitmap AS ( SELECT bucket_id, bitmap_or_agg(user_bitmap) AS q1_bitmap FROM mart_user_active_day_bitmap WHERE dt >= DATE '2026-01-01' AND dt < DATE '2026-04-01' GROUP BY bucket_id ) SELECT SUM(bitmap_count(q1_bitmap)) AS q1_active_uv FROM q1_bucket_bitmap;
第一步,按 bucket_id 汇总季度内所有天的 Bitmap;同一个 bucket 在不同天出现的相同 user_id,在 bitmap_or_agg 时会被自动去重,第二步,对每个 bucket 计算去重人数: bitmap_count(q1_bitmap)。第三步,对所有 bucket 的人数求和:SUM(bitmap_count(...)),由于 bucket 之间互斥,所以这里的求和是精确的。
关键不在于“同一个用户始终跑在同一台机器”,而在于:
因此:
本文作者:hedeoer
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!