首页标签分类
Spark 4.0 Bitmap 使用回顾笔记
2026-04-06 · 更新 2026-04-06约 11 分钟 · 2946 字
大数据杂文记
000

目录

Spark 4.0 Bitmap 使用回顾笔记
版本说明
Spark 4.0 中和 bitmap 相关的核心函数
1. 什么是 Bitmap,原理是什么
1.1 Bitmap 是什么
1.2 在 Spark 4.0 里它是怎么工作的
最简单示例:去重
2. Bitmap 的引入解决了什么问题
2.1 解决 count(distinct) 在大数据场景下代价高的问题
2.2 解决多阶段聚合难合并的问题
示例:两份局部结果合并
2.3 解决大整数值域下“单一大位图过大”的问题
示例:大值域 ID 的写法
3. Bitmap 的适用场景
3.1 适合的场景
场景 A:整数 ID 的精确去重
场景 B:分布式多阶段聚合
场景 C:需要“集合合并”而不只是最终计数
3.2 不太适合的场景
场景 A:原始主键是字符串,且没有稳定整数映射
场景 B:只是一次性的小数据去重
场景 C:只需要近似去重
4. Bitmap 和 Roaring Bitmap 的区别
4.1 一句话区别
4.2 原理区别
普通 Bitmap
Roaring Bitmap
4.3 对比表
4.4 在 Spark 4.0 里怎么理解两者关系
5. 实战速记:Spark 4.0 中 bitmap 的最常见写法
5.1 单表精确去重
5.2 分组去重
5.3 大值域整数去重
5.4 合并多个局部 bitmap
正交 Bitmap 在大数据去重中的使用说明
1. 什么是正交 Bitmap
2. 它解决的到底是什么问题
2.1 普通 Bitmap 聚合的问题
2.2 正交 Bitmap 的解决思路
3. 一个具体场景
4. Spark 4.0 中如何实现
5. 日桶 Bitmap 预聚合
5.1 建结果表
5.2 每天产出日级桶 Bitmap
5.3 这段 SQL 干了什么
6. 季度查询 SQL
6.1 这段 SQL 的执行逻辑
7. 为什么它是精确的

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):把一组位位置聚合成 bitmap
  • bitmap_or_agg(col):把多个 bitmap 做 OR 并集
  • bitmap_count(col):统计 bitmap 中 置 1 的位数,也就是去重后的数量

1. 什么是 Bitmap,原理是什么

1.1 Bitmap 是什么

Bitmap 本质上就是一种用二进制位表示集合成员关系的数据结构。

如果某个整数出现过,对应位置记为 1;没出现过,记为 0

例如,集合:

{1, 2, 4}

可以理解为:

  • 1 出现 -> 对应位设为 1
  • 2 出现 -> 对应位设为 1
  • 3 没出现 -> 对应位设为 0
  • 4 出现 -> 对应位设为 1

image-20260406150858366

所以它特别适合做两类事:

  1. 去重
  2. 集合运算,比如并集

这是 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(...):统计置位数

最简单示例:去重

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

解释:

  • 124 各占一个位置
  • 2 虽然出现两次,但同一位只会被置一次
  • 所以最终去重数量是 3

这就是 bitmap 去重最核心的原理。


2. Bitmap 的引入解决了什么问题

2.1 解决 count(distinct) 在大数据场景下代价高的问题

在大数据里,最常见需求之一是:

  • 统计 UV
  • 统计去重设备数
  • 统计去重订单数

直接写:

sql
自动换行:关
放大阅读
展开代码
COUNT(DISTINCT user_id)

当然可以,但在数据量大、分区多、重复值很多时,通常会带来更高的:

  • shuffle 成本
  • 内存压力
  • 聚合代价

而 bitmap 的思路是:

  • 不直接保存“所有去重后的值对象”
  • 而是把“是否出现过”压成位信息
  • 最后只统计置位数

所以它更像是把“去重”转换成“置位 + 计数”。

image-20260406141209105

2.2 解决多阶段聚合难合并的问题

在分布式场景里,经常不是一次算完,而是:

  • 每个分区先做局部聚合
  • 再把局部结果合并成全局结果

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

解释:

  • 第一份局部 bitmap 表示 {1,2}
  • 第二份局部 bitmap 表示 {2,3}
  • bitmap_or_agg 合并后得到 {1,2,3}
  • bitmap_count 得到 3

image-20260406141905704

这说明 bitmap 很适合 分区预聚合 -> 全局合并 的计算模型。

2.3 解决大整数值域下“单一大位图过大”的问题

如果 ID 值域很大,只构造一张超大的位图会很浪费。

所以 Spark 4.0 同时提供了:

  • bitmap_bucket_number
  • bitmap_bit_position

这意味着 Spark 并不是简单让你维护一张无限扩大的 bitmap,而是让你把大值域拆成 bucket + 位位置 的方式来处理。

示例:大值域 ID 的写法

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

这个写法的重点不是你手工算每一位,而是:

  • 让 Spark 帮你确定桶号
  • 让 Spark 帮你确定位位置
  • 你只负责按桶聚合、最后汇总

image-20260406142540690

当然看后续的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:只是一次性的小数据去重

如果数据量不大、逻辑也不复杂,直接:

sql
自动换行:关
放大阅读
展开代码
COUNT(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 的核心改进是:

  1. 把 32 位整数空间按 65536 一块分块
  2. 每块根据数据分布选择更合适的容器:
    • array container
    • bitset/bitmap container
    • run container
  3. 这样在稀疏、稠密、连续区间等不同情况下,都能更节省空间

image-20260406150302556

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 单表精确去重

sparksql
自动换行:关
放大阅读
展开代码
SELECT bitmap_count(bitmap_construct_agg(bitmap_bit_position(user_id))) AS uv FROM your_table;

5.2 分组去重

sparksql
自动换行:关
放大阅读
展开代码
SELECT dt, bitmap_count(bitmap_construct_agg(bitmap_bit_position(user_id))) AS uv FROM your_table GROUP BY dt;

5.3 大值域整数去重

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;

5.4 合并多个局部 bitmap

sparksql
自动换行:关
放大阅读
展开代码
SELECT 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|

image-20260406144945772

2. 它解决的到底是什么问题

2.1 普通 Bitmap 聚合的问题

在海量数据场景下,普通 Bitmap 聚合通常是这样做的:

  1. 每个计算节点扫描明细数据
  2. 每个节点构造本地用户 Bitmap
  3. 把多个大 Bitmap 回传给上层
  4. 上层做 OR 合并
  5. 最后再 count

这个方案在用户规模达到几亿时会遇到明显问题:

  • 单个 Bitmap 状态很大
  • 多个节点之间传输中间状态开销大
  • 最终聚合节点内存压力很高
  • 上层汇总容易成为瓶颈
  • 并发高时容易出现 OOM 或长尾任务

2.2 正交 Bitmap 的解决思路

正交 Bitmap 不再试图构造“一个超级大的全局 Bitmap”,而是先把用户集合拆成若干互斥桶:

bucket_id = 某个对 user_id 的确定性映射

例如:

  • 同一个 user_id 永远进入同一个 bucket
  • 不同 bucket 之间不会重复包含同一个用户

这样一来:

  • 每个 bucket 内部做自己的 Bitmap 去重
  • 最终结果不需要合成一个更大的全局 Bitmap
  • 只需要对各 bucket 的去重数求和

本质上就是: 全局大对象聚合 改为 多个互斥小对象聚合 + 数字求和

这比“把所有用户都塞进一个大 Bitmap 再 OR”更适合分布式系统。

3. 一个具体场景

目标:统计 2026Q1 去重活跃用户数。

源表:

sparksql
自动换行:关
放大阅读
展开代码
dwd_user_event( dt DATE, user_id BIGINT, event_name STRING, channel STRING )

活跃定义:event_name in ('login', 'visit', 'pay')

如果直接按季度扫明细再做全局 Bitmap 聚合,随着用户规模变大,最终汇总状态会越来越重。

更合理的方式是:

  1. 先做“日级 + 桶级”的 Bitmap 预聚合
  2. 查询季度时,只按 bucket 汇总 Bitmap
  3. 每个 bucket 计算 distinct 数
  4. 最后求和

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 建结果表

sparksql
自动换行:关
放大阅读
展开代码
CREATE 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

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);

5.3 这段 SQL 干了什么

它不是在算当天 UV 数,而是在生产“当天每个 bucket 的用户集合状态”。

可以理解为:

  • bucket_id 决定这个用户属于哪个逻辑桶
  • bitmap_bit_position(user_id) 决定用户在桶内的哪个 bit 位置
  • bitmap_construct_agg(...) 把这个桶内所有用户构造成一个 Bitmap

这样每日产出的不是单个超大 Bitmap,而是一批按 bucket 拆分的 Bitmap 状态。

6. 季度查询 SQL

季度查询时,不直接扫明细,而是扫日预聚合结果。

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;

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 之间没有用户重叠

因此:

  • 跨天重复在桶内被去掉
  • 跨桶不会重复计数
  • 最终可以直接把各桶结果加起来

本文作者:hedeoer

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!