RoaringBitmap:智能压缩版 Bitmap 的原理与取舍

如果你已经了解了 Bitmap(位图)Bloom Filter(布隆过滤器),那么 RoaringBitmap 可以理解为:

一种“智能压缩版 Bitmap”,既保持了 Bitmap 查询快的优点,又解决了 Bitmap 太占内存的问题。

很多大数据系统(Spark、ClickHouse、Druid、Pinot、Lucene、Elasticsearch 等)都在使用它。(GitHub)


一、Bitmap 为什么不够好?

先回顾 Bitmap。

假设我们要表示下面这些数字:

{1,2,5,100}

Bitmap 会直接开到最大值:

0 1 2 3 4 5 6 ... 99 100

0 1 1 0 0 1 0 ...  0   1

优点:

  • 查询 O(1)
  • 并集、交集直接位运算
  • CPU 极快

例如

A:
00110100

B:
10100100

AND
00100100

OR
10110100

几乎就是 CPU 一条指令。


但是问题来了

假设:

只有三个数字

1
1000000
999999999

Bitmap 必须开到:

999999999 bit
≈125MB

实际上只保存了:

3 个数字

却浪费了:

999999996 个 bit

这就是 Bitmap 最大的问题:

数据越稀疏,越浪费内存。


二、RoaringBitmap 解决什么问题?

RoaringBitmap 的目标就是:

稀疏时像数组一样存,稠密时像 Bitmap 一样存。

一句话:

自动选择最省空间的数据结构。 (GitHub)


三、RoaringBitmap 的核心思想

RoaringBitmap 并没有维护一个超级大的 Bitmap。

它首先把整数按照 高 16 位 分组。

例如:

32 bit 整数

xxxxxxxx xxxxxxxx | yyyyyyyy yyyyyyyy
     高16位             低16位

例如数字:

100
1000
65535
70000
90000

分组后:

Container0(高16位=0)

100
1000
65535


Container1(高16位=1)

70000
90000

也就是说:

整个 32 位整数空间,被拆成很多:

65536 个小块

每块负责:

2^16 = 65536 个数字

论文称之为:

Container(容器)

整个 Bitmap 就变成:

Root

 ├── Container0
 ├── Container1
 ├── Container2
 ├── ...

而不是:

一个超级大的 Bitmap

这样可以避免为空白区域分配内存。(roaringbitmap.readthedocs.io)


四、Container 有三种存储方式

这也是 RoaringBitmap 最厉害的地方。

它不会固定采用 Bitmap。

而是:

根据当前 Container 的数据密度自动选择。


第一种:Array Container(稀疏)

假设这一块只有:

100
200
500

不用 Bitmap。

直接数组:

[
100,
200,
500
]

查询:

二分查找

空间非常小。

一般用于:

元素 <=4096

(roaringbitmap.readthedocs.io)


第二种:Bitmap Container(稠密)

如果:

这一块里面

30000 个数字

数组就太长了。

直接 Bitmap:

65536 bit

≈8KB

例如:

000101011001...

查询:

bitmap[offset]

O(1)

位运算:

AND

OR

XOR

CPU 极快。

一般:

元素 >4096

自动切换。(roaringbitmap.readthedocs.io)


第三种:Run Container(连续)

后来又加入了一种:

Run Container

例如:

100
101
102
103
104
105

Bitmap:

111111

数组:

100
101
102
103
104
105

其实最好的表示就是:

(start=100,length=6)

即可。

也就是:

100~105

存储:

[
(start,length)
]

连续数据压缩率极高。(阿里云)


五、什么时候切换?

例如:

开始:

Container0

100

300

500

只有三个元素。

使用:

Array Container

后来:

不断 add()

变成:

5000 个元素

RoaringBitmap 自动转换:

Bitmap Container

如果:

后来删除很多:

只剩几十个

又自动退回:

Array Container

所以:

用户完全不用关心底层结构。


六、为什么速度仍然很快?

例如:

A
B

都有:

Container0
Container1
Container5

做交集:

Container0

Bitmap AND Bitmap

↓

结果

不用扫描整个几十亿 Bitmap。

只处理:

存在数据的 Container

因此:

空 Container

直接跳过。

速度非常快。(arXiv)


七、举一个查询例子

例如:

用户标签:

喜欢篮球:

1
2
5
8
10

用户标签:

喜欢足球:

2
5
6
9

Bitmap:

篮球

010011010

足球

001011001

交集:

AND

↓

2
5

RoaringBitmap:

底层也是:

Container

↓

Bitmap

↓

AND

速度几乎一样。

但是:

如果用户 ID:

1
100000
99999999

Bitmap:

需要几千万 bit

RoaringBitmap:

只有三个数字

几个数组即可

八、为什么比 Bitmap 更省内存?

假设:

最大 ID:

10 亿

但是:

实际只有:

1000 个 ID

Bitmap:

10亿 bit

≈125MB

RoaringBitmap:

Root

↓

几个 Container

↓

Array

↓

1000 个 uint16

可能:

几 KB

甚至:

几十 KB

即可。


九、实际应用场景

RoaringBitmap 非常适合表示整数集合,尤其是需要频繁做集合运算的场景:

场景 用法
搜索引擎 文档 ID 集合,快速求交集(多个关键词同时命中)
推荐系统 用户标签、兴趣集合
广告系统 人群圈选(年龄 + 地区 + 兴趣)
数据仓库 Bitmap Index
ClickHouse 去重、Bitmap 聚合
Apache Druid 用户分析
Apache Spark 高效集合操作
Redis 模块 大规模用户 ID 管理

这些系统使用 RoaringBitmap 的主要原因是:内存占用低,同时交集、并集等集合运算仍然非常快。(GitHub)


十、RoaringBitmap 与其他数据结构对比

数据结构 查询 内存 去重 交集/并集 适用场景
HashSet O(1) 较高 较慢 通用集合
Bitmap O(1) 很高(稀疏数据) 极快 ID 连续、数据密集
Bloom Filter O(k) 极低 ❌(可能误判) 不擅长 判断“可能存在”
RoaringBitmap 接近 O(1) 极快 稀疏 + 稠密混合的大规模整数集合

十一、可以把它理解成什么?

如果只记住一句话,我建议记住下面这张思维图:

                     Bitmap
                (一个巨大位图)
                      │
      ┌───────────────┴───────────────┐
      │                               │
   数据稠密                        数据稀疏
      │                               │
   查询很快                     内存浪费严重
      └───────────────┬───────────────┘
                      │
                RoaringBitmap
                      │
        把数据切成很多 2^16 的小块(Container)
                      │
      ┌───────────────┼───────────────┐
      │               │               │
 Array Container  Bitmap Container  Run Container
  (稀疏数组)        (稠密位图)        (连续区间)
      │               │               │
      └───────────────┴───────────────┘
                      │
       自动选择最优存储 + 保持高速集合运算

因此,RoaringBitmap 可以理解为 Bitmap 的“自适应升级版”:它将整数空间划分为多个容器,每个容器根据数据分布自动选择数组、位图或区间编码等表示方式,从而在保持 Bitmap 高速查询和集合运算能力的同时,大幅降低稀疏数据场景下的内存消耗。这一设计也是它成为现代搜索引擎、分析数据库和大数据系统中广泛采用的压缩位图格式的核心原因。(arXiv)


补充:RoaringBitmap 的局限与取舍

有,而且 RoaringBitmap 并不是 Bitmap 的全方位升级版,它是在空间、查询、集合运算之间做的平衡。

很多人看到它以后会觉得:

“既然比 Bitmap 省内存,又一样快,那是不是全面替代 Bitmap 了?”

实际上不是。


1. 删除和插入并非真正 O(1)

先看 Bitmap。

假设:

bitmap[100] = 1

插入:

bitmap[100] = 1

删除:

bitmap[100] = 0

仅修改一个 bit。

时间:

O(1)

RoaringBitmap 不一样。

例如:

Container0

[100,200,300]

属于:

ArrayContainer

删除:

remove(200)

变成:

[100,300]

数组需要移动元素。

时间:

O(n)

更麻烦的是:

4097 个元素

删除后:

4096 个元素

可能触发:

BitmapContainer
ArrayContainer

容器转换。

这时候需要:

重新构建容器

会有额外开销。


2. 随机写入不如 Bitmap

Bitmap:

set(999999999)

直接定位:

offset

修改 bit 即可。


RoaringBitmap:

需要先:

找到 Container
高16位
定位Container
低16位
更新

过程变成:

Root查找
+
Container查找
+
更新

虽然仍然很快,

但已经不是 Bitmap 那种:

一个数组下标

的极致性能。


3. 容器切换存在额外成本

RoaringBitmap 最厉害的地方:

Array
Bitmap
Run

自动切换。

但切换本身是有代价的。

例如:

ArrayContainer

里面:

4096个元素

继续插入:

4097个元素

会发生:

Array
Bitmap

需要:

遍历4096个元素
重新生成Bitmap

一次性成本较高。


例如:

4095
4096
4095
4096
...

来回震荡。

会导致:

频繁转换容器

性能下降。

因此很多实现会做:

hysteresis(滞后阈值)

避免反复切换。


4. 不适合大量连续写入

例如日志系统:

1
2
3
4
5
...
100000000

不断追加。

这种场景:

HashSet

或者:

普通 Bitmap

反而可能更简单。

因为:

RoaringBitmap

需要维护:

Container
索引
转换
压缩

额外元数据。


5. 极度稠密时反而不占优势

假设:

0 ~ 10亿

几乎全部存在。

例如:

95%

都存在。


普通 Bitmap:

10亿 bit

≈125MB

固定。


RoaringBitmap:

除了 BitmapContainer 之外还有:

Root
Container索引
Cardinality统计

等元数据。

因此:

极度稠密

场景下:

RoaringBitmap

未必更省。

甚至可能稍大。


6. 只适用于整数集合

RoaringBitmap 本质上存的是:

uint32

或者:

uint64

集合。


例如:

用户ID
文档ID
商品ID
订单ID

非常适合。


但是:

用户名
邮箱
URL

不能直接存。

需要:

字符串
映射
整数ID
RoaringBitmap

多一层转换。


7. TopN、排序能力弱

例如:

100
5
8
1000
7

RoaringBitmap 关心的是:

是否存在

而不是:

插入顺序

或者:

权重

因此:

Top100
最大值
最小值
排行榜

这类操作,

远不如:

B+Tree
SkipList
Heap

合适。


8. 序列化比 Bitmap 更复杂

Bitmap:

直接 dump 内存

即可。


RoaringBitmap:

需要保存:

Root
Container类型
Array数据
Bitmap数据
Run数据

结构类似:

{
  containerType,
  cardinality,
  payload
}

序列化和反序列化逻辑更复杂。


9. CPU Cache 命中率不如 Bitmap

这是很多人忽略的。


Bitmap:

连续内存
111001010...

CPU 非常喜欢。

Cache 命中率极高。


RoaringBitmap:

Root
Container
Array

存在:

指针跳转

或者:

多段内存

访问模式没那么连续。

因此:

纯查询性能

理论上永远打不过:

连续Bitmap

只是差距通常不大。


10. 最重要的一条:它优化的是“稀疏+稠密混合场景”

RoaringBitmap 最适合:

最大ID非常大

实际数据不多

例如:

用户ID:
1~100亿

实际在线用户:
500万

这种情况:

Bitmap

会爆内存。

HashSet

集合运算慢。


RoaringBitmap:

内存接近 HashSet

交集速度接近 Bitmap

这就是它成功的原因。


级总结

可以记住下面这个表:

数据结构 插入 删除 查询 交集/并集 内存
HashSet O(1) O(1) O(1) 较慢
Bitmap O(1) O(1) O(1) 极快 最大
RoaringBitmap 近 O(1) 近 O(1) 近 O(1) 极快 很低

RoaringBitmap 的核心取舍可以概括为:

牺牲一点点:
    插入性能
    删除性能
    实现复杂度

换来:
    数十倍~数百倍内存节省
    接近 Bitmap 的集合运算速度

所以在搜索引擎、广告人群圈选、OLAP 分析、推荐系统中,它几乎成为了 Bitmap 的事实标准;但在需要高频随机更新、极度稠密数据、或者需要排序/范围索引的场景中,普通 Bitmap、HashSet、B+Tree 等结构仍然更合适。