Spark 4.0 Bitmap 使用回顾笔记
Spark 4.0 Bitmap 使用回顾笔记
版本说明
本文以 Spark 4.0 官方文档为基准说明。需要先明确一点:
- Spark 4.0 对外暴露的是一组 bitmap SQL 函数
- 这些函数在 3.5.0 引入,到了 4.0 继续可用
- Spark 4.0 release note 里还能看到底层依赖 RoaringBitmap 升级到
1.3.0,但 SQL 层并没有直接暴露一个叫RoaringBitmap的 API 给使用
Spark 4.0 中和 bitmap 相关的核心函数
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 的位数,也就是去重后的数量
1. 什么是 Bitmap,原理是什么
1.1 Bitmap 是什么
Bitmap 本质上就是一种用二进制位表示集合成员关系的数据结构。
如果某个整数出现过,对应位置记为 1;没出现过,记为 0。
例如,集合:
{1, 2, 4}
可以理解为:
1出现 -> 对应位设为12出现 -> 对应位设为13没出现 -> 对应位设为04出现 -> 对应位设为1
所以它特别适合做两类事:
- 去重
- 集合运算,比如并集
这是 bitmap/bitset 的基本原理;而 Spark 4.0 的 SQL bitmap 函数就是围绕这个原理构建的。
1.2 在 Spark 4.0 里它是怎么工作的
在 Spark SQL 中,通常是这条链路:
原始整数ID -> 位位置 -> bitmap -> 计数
对应函数就是:
bitmap_bit_position(user_id):把user_id映射成位位置bitmap_construct_agg(...):聚合成位图bitmap_count(...):统计置位数
最简单示例:去重
sqlWITH 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;
结果:
sql3
解释:
1、2、4各占一个位置2虽然出现两次,但同一位只会被置一次- 所以最终去重数量是
3
这就是 bitmap 去重最核心的原理。
2. Bitmap 的引入解决了什么问题
2.1 解决 count(distinct) 在大数据场景下代价高的问题
在大数据里,最常见需求之一是:
- 统计 UV
- 统计去重设备数
- 统计去重订单数
直接写:
sqlCOUNT(DISTINCT user_id)
当然可以,但在数据量大、分区多、重复值很多时,通常会带来更高的:
- shuffle 成本
- 内存压力
- 聚合代价
而 bitmap 的思路是:
- 不直接保存“所有去重后的值对象”
- 而是把“是否出现过”压成位信息
- 最后只统计置位数
所以它更像是把“去重”转换成“置位 + 计数”。
2.2 解决多阶段聚合难合并的问题
在分布式场景里,经常不是一次算完,而是:
- 每个分区先做局部聚合
- 再把局部结果合并成全局结果
bitmap 特别适合这个模式,因为局部结果可以先变成 bitmap,再通过 bitmap_or_agg 合并。
示例:两份局部结果合并
sqlWITH 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;
结果:
sql3
解释:
- 第一份局部 bitmap 表示
{1,2} - 第二份局部 bitmap 表示
{2,3} bitmap_or_agg合并后得到{1,2,3}bitmap_count得到3
这说明 bitmap 很适合 分区预聚合 -> 全局合并 的计算模型。
2.3 解决大整数值域下“单一大位图过大”的问题
如果 ID 值域很大,只构造一张超大的位图会很浪费。
所以 Spark 4.0 同时提供了:
bitmap_bucket_numberbitmap_bit_position
这意味着 Spark 并不是简单让你维护一张无限扩大的 bitmap,而是让你把大值域拆成 bucket + 位位置 的方式来处理。
示例:大值域 ID 的写法
plaintextWITH 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;
结果:
sql4
这个写法的重点不是你手工算每一位,而是:
- 让 Spark 帮你确定桶号
- 让 Spark 帮你确定位位置
- 你只负责按桶聚合、最后汇总
当然看后续的roaring bitmap实现,它可以自动对不同的数据密集程度做自动的压缩处理。
这就是 Spark 4.0 里 bitmap 处理大值域整数的标准思路。
3. Bitmap 的适用场景
3.1 适合的场景
场景 A:整数 ID 的精确去重
例如:
- 用户 ID
- 设备 ID
- 商品 ID
- 订单 ID
典型需求:
- 日 UV
- 周活/月活
- 每个分组下的去重人数
场景 B:分布式多阶段聚合
例如:
- 先分区聚合 bitmap
- 再上层
bitmap_or_agg合并 - 最后
bitmap_count
这比直接传输大批去重后的明细值更适合分布式执行。
场景 C:需要“集合合并”而不只是最终计数
bitmap 的好处不只是最终的去重数,还包括:
- 可以先把集合结果保存成 bitmap
- 后面再继续合并
- 最后再取 count
3.2 不太适合的场景
场景 A:原始主键是字符串,且没有稳定整数映射
bitmap 天然更适合整数集合。
如果原始键是:
- UUID
- 手机号字符串
- 用户名
- URL
通常要先做整数映射。 如果直接哈希成整数再去重,会引入哈希碰撞风险,此时就不是绝对精确去重。
场景 B:只是一次性的小数据去重
如果数据量不大、逻辑也不复杂,直接:
sqlCOUNT(DISTINCT user_id)
往往更直观。
场景 C:只需要近似去重
如果你并不要求精确值,而只想要近似结果,Spark 4.0 里还有:
approx_count_distinct- HLL sketch 相关函数
这种场景下,bitmap 不一定是最优选择。
4. Bitmap 和 Roaring Bitmap 的区别
4.1 一句话区别
- Bitmap:一种“用位表示集合”的思想/结构
- Roaring Bitmap:一种“对 bitmap 做了分块和压缩优化的工程实现”
也可以理解成:
Roaring Bitmap = 更适合大数据场景的压缩版 bitmap
4.2 原理区别
普通 Bitmap
普通 bitmap 的特点是:
- 每个值对应一个 bit
- 最大值越大,需要开的位空间越大
- 如果数据很稀疏,会非常浪费
例如只存两个值:
{1, 100000000}
普通 bitmap 也得面对非常大的位空间。
Roaring Bitmap
Roaring Bitmap 的核心改进是:
- 把 32 位整数空间按 65536 一块分块
- 每块根据数据分布选择更合适的容器:
- array container
- bitset/bitmap container
- run container
- 这样在稀疏、稠密、连续区间等不同情况下,都能更节省空间
Roaring 官方和格式规范都明确说明了这一点。
4.3 对比表
| 维度 | 普通 Bitmap | Roaring Bitmap |
|---|---|---|
| 基本思想 | 一值一位 | 一值一位,但按块压缩 |
| 空间占用 | 与最大值强相关 | 与数据分布强相关 |
| 稀疏数据 | 容易浪费 | 更友好 |
| 连续区间 | 没有特别优化 | 可用 run container |
| 稠密数据 | 很直接 | 可退化成 bitmap container |
| 工程复杂度 | 简单 | 更复杂,但更实用 |
4.4 在 Spark 4.0 里怎么理解两者关系
在 Spark 4.0 中,你写的是 bitmap SQL 函数,不是直接写 Roaring Bitmap API:
- 你用
bitmap_construct_agg - 你用
bitmap_or_agg - 你用
bitmap_count
但从 Spark 4.0 release note 可以确认,Spark 运行时依赖了 RoaringBitmap,而 Roaring 官方站点也把 Apache Spark 列为使用者之一。
因此可以把关系理解为:
Spark 4.0 对外提供 bitmap 语义,对内使用更高效的压缩位图实现。
需要注意的是:
- SQL 使用层面:你只需要关心 bitmap 函数
- 底层实现层面:RoaringBitmap 负责更好的压缩和性能
这也是为什么在 Spark 4.0 里,bitmap 既能保持集合/去重语义,又更适合大规模数据计算。
5. 实战速记:Spark 4.0 中 bitmap 的最常见写法
5.1 单表精确去重
sparksqlSELECT bitmap_count(bitmap_construct_agg(bitmap_bit_position(user_id))) AS uv FROM your_table;
5.2 分组去重
sparksqlSELECT dt, bitmap_count(bitmap_construct_agg(bitmap_bit_position(user_id))) AS uv FROM your_table GROUP BY dt;
5.3 大值域整数去重
sparksqlWITH 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;
5.4 合并多个局部 bitmap
sparksqlSELECT bitmap_count(bitmap_or_agg(bm)) AS uv FROM partial_bitmap_table;
正交 Bitmap 在大数据去重中的使用说明
1. 什么是正交 Bitmap
“正交 Bitmap”不是某一种单独的数据结构,更准确地说,它是一种把去重对象按互斥规则拆分后,再分别做 Bitmap 聚合,最后把结果直接相加的计算方法。
在用户去重场景里,所谓“正交”指的是:
- 全量用户集合被拆成若干个互不重叠的子集
- 每个用户只能属于其中一个子集
- 各子集内部可以独立做 Bitmap 去重
- 各子集之间由于天然互斥,所以最终结果可以直接累加
可以写成:
U = U0 ∪ U1 ∪ U2 ∪ ... ∪ U(N-1) 且任意 Ui ∩ Uj = 空集
那么对于某个查询用户集合 S:
|S| = Σ |S ∩ Ui|
2. 它解决的到底是什么问题
2.1 普通 Bitmap 聚合的问题
在海量数据场景下,普通 Bitmap 聚合通常是这样做的:
- 每个计算节点扫描明细数据
- 每个节点构造本地用户 Bitmap
- 把多个大 Bitmap 回传给上层
- 上层做 OR 合并
- 最后再 count
这个方案在用户规模达到几亿时会遇到明显问题:
- 单个 Bitmap 状态很大
- 多个节点之间传输中间状态开销大
- 最终聚合节点内存压力很高
- 上层汇总容易成为瓶颈
- 并发高时容易出现 OOM 或长尾任务
2.2 正交 Bitmap 的解决思路
正交 Bitmap 不再试图构造“一个超级大的全局 Bitmap”,而是先把用户集合拆成若干互斥桶:
bucket_id = 某个对 user_id 的确定性映射
例如:
- 同一个 user_id 永远进入同一个 bucket
- 不同 bucket 之间不会重复包含同一个用户
这样一来:
- 每个 bucket 内部做自己的 Bitmap 去重
- 最终结果不需要合成一个更大的全局 Bitmap
- 只需要对各 bucket 的去重数求和
本质上就是: 全局大对象聚合 改为 多个互斥小对象聚合 + 数字求和
这比“把所有用户都塞进一个大 Bitmap 再 OR”更适合分布式系统。
3. 一个具体场景
目标:统计 2026Q1 去重活跃用户数。
源表:
sparksqldwd_user_event( dt DATE, user_id BIGINT, event_name STRING, channel STRING )
活跃定义:event_name in ('login', 'visit', 'pay')
如果直接按季度扫明细再做全局 Bitmap 聚合,随着用户规模变大,最终汇总状态会越来越重。
更合理的方式是:
- 先做“日级 + 桶级”的 Bitmap 预聚合
- 查询季度时,只按 bucket 汇总 Bitmap
- 每个 bucket 计算 distinct 数
- 最后求和
4. Spark 4.0 中如何实现
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 聚合”组合实现的。
5. 日桶 Bitmap 预聚合
5.1 建结果表
sparksqlCREATE TABLE IF NOT EXISTS mart_user_active_day_bitmap ( bucket_id BIGINT, user_bitmap BINARY, dt DATE ) USING PARQUET PARTITIONED BY (dt);
5.2 每天产出日级桶 Bitmap
sparksqlINSERT 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);
5.3 这段 SQL 干了什么
它不是在算当天 UV 数,而是在生产“当天每个 bucket 的用户集合状态”。
可以理解为:
- bucket_id 决定这个用户属于哪个逻辑桶
- bitmap_bit_position(user_id) 决定用户在桶内的哪个 bit 位置
- bitmap_construct_agg(...) 把这个桶内所有用户构造成一个 Bitmap
这样每日产出的不是单个超大 Bitmap,而是一批按 bucket 拆分的 Bitmap 状态。
6. 季度查询 SQL
季度查询时,不直接扫明细,而是扫日预聚合结果。
sparksqlWITH 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;
6.1 这段 SQL 的执行逻辑
第一步,按 bucket_id 汇总季度内所有天的 Bitmap;同一个 bucket 在不同天出现的相同 user_id,在 bitmap_or_agg 时会被自动去重,第二步,对每个 bucket 计算去重人数: bitmap_count(q1_bitmap)。第三步,对所有 bucket 的人数求和:SUM(bitmap_count(...)),由于 bucket 之间互斥,所以这里的求和是精确的。
7. 为什么它是精确的
关键不在于“同一个用户始终跑在同一台机器”,而在于:
- 同一个 user_id 永远映射到同一个 bucket_id
- 同一个 bucket 在查询阶段会被聚合到一起
- 不同 bucket 之间没有用户重叠
因此:
- 跨天重复在桶内被去掉
- 跨桶不会重复计数
- 最终可以直接把各桶结果加起来
版权声明