位图索引(bitmap index)是分析型系统里最古老的加速结构之一:用一个 bit 数组给集合里的每个元素占一位,COUNT、AND、OR、XOR 全部退化为对定长字节数组的位运算,天然并行、缓存友好。但它的软肋也致命——稀疏集合会浪费成吨内存:标一个"今天活跃用户的 id 集合",哪怕只有 3 个用户,也要扛一整个 2^32 位的数组。2016 年出世的 Roaring Bitmap 把这个问题拆解得很漂亮:它不要求整张位图是"全稠密"或"全稀疏",而是把 32 位空间切成 2^16 个分块,每个分块按自己的稠密度在 Array / Bitmap / Run 三种容器间自适应切换。本文从分块策略、三态容器、跨容器集合运算一路剖析到磁盘字节布局,并给出一份可运行的 Python 教学版实现。
一、位图索引的困境:稠密好、稀疏崩
经典位图思路很简单:维护一个长度为 2^32 的 bit 数组(512MB),第 i 位为 1 表示元素 i 在集合里。集合求交就是两段内存逐字节 AND,CPU 一条指令能并行处理 64 位,多核还能再切分,快到离谱。但它有两个绕不开的代价:
- 内存随值域而非基数膨胀:值域是 0~2^32-1,数组就固定 512MB,和"集合里究竟有几个元素"无关。一个只有 10 个元素的稀疏集合,照样吃掉整段数组。
- 稀疏集合上位运算反而更慢:AND 仍要扫完整段字节,大部分是 0,纯属空转;而稠密时才是位运算的甜区。
现实里的集合往往是"局部稠密、整体稀疏"——比如某一天某个城市的活跃用户 id 聚在几个连续区段内,但跨城市就天各一方。一刀切的"全用位图"或"全用有序数组"都浪费,Roaring 的解法是按分块分别决策。
二、Roaring 的核心拆解:32 位空间切成 2^16 个分块
Roaring 把每个 32 位整数拆成高 16 位与低 16 位两段:
x = (high << 16) | low # high = x >> 16,low = x & 0xFFFF # 高 16 位决定"分块 key",低 16 位决定"块内偏移"
于是 2^32 的值域被切成 2^16 = 65536 个分块,每个分块内部只处理 0~65535 的小整数(用 16 位就能表示)。所有"低 16 位相同"的元素,归到同一个分块里。每个分块内部用哪种容器存这 0~65535 范围内的元素,由该分块的稠密度自己决定——这就是 Roaring 一切魔法的源头。分块让"稠密/稀疏"从全局问题降级成了分块级问题。
三、三态容器之一:Array 容器(稀疏分块的赢家)
当一个分块里元素很少(比如只有几十个),直接开 8KB 位图太奢侈。Array 容器就是一个排序的 uint16 数组:每个元素 2 字节,存 100 个元素只花 200 字节,而位图要 8192 字节。查找用二分(O(log n)),遍历天然有序,与别的分块合并时也只需归并。代价是判断"元素是否存在"要走二分、做集合运算要走指针扫描,稠密时就吃力。因此 Roaring 规定:分块内元素数 ≤ 4096 时优先用 Array 容器(4096 个 uint16 正好 8KB,与位图内存持平的临界点)。
四、三态容器之二:Bitmap 容器(定长 8KB 的稠密主力)
当分块内元素超过 4096、越来越稠密,Array 的二分与归并开销盖过了省下的内存,此时换成 Bitmap 容器:一段定长 2^16 / 8 = 8192 字节(8KB)的位图,第 i 位表示低 16 位值 i 是否在集合里。Bitmap 容器容量固定 8KB,与元素数量无关;元素越多,内存利用率越高,且集合运算退化成 1024 个 64 位字的批量位操作,是三态里计算最快的。它的唯一短板是:哪怕分块里只有 1 个元素,也要占 8KB。
五、三态容器之三:Run 容器(游程编码吃掉连续段)
真实数据常有大段连续值:比如"某月的每一天""某 IP 段的整个区间"。Bitmap 容器虽然能表示,却要为每个连续值付出 1 个位,而它们本可以用一个区间概括。Run 容器改用游程编码(RLE):把连续值存成 (start, length) 的 run 列表。一段 1000 个连续整数,Bitmap 要 1000 个位(约 125 字节),Run 只要 1 个 (start, length) 对(8 字节)。一个极端例子:若分块内是完整的 0~65535,Bitmap 占 8KB,Run 只占 8 字节。Run 容器是 Roaring 相对传统位图(WAH/EWAH)在"连续数据"上的关键超越。
| 容器 | 适用稠密度 | 典型内存 | 集合运算方式 |
|---|---|---|---|
| Array | 极稀疏(≤4096) | 每元素 2 字节 | 二分查找、双指针归并 |
| Bitmap | 中/高稠密(>4096) | 固定 8KB | 逐 64 位字批量位运算 |
| Run | 高度连续段 | 每连续段 8 字节 | 区间合并/重叠判定 |
六、容器自适应的阈值与自动转换
Roaring 不会让分块僵死在某一种容器:插入/删除会触发容器内元素数越界,进而自动升级或降级。两个关键阈值:
- Array ↔ Bitmap 临界点 = 4096:Array 插入超过 4096 个元素,整体转成 Bitmap 容器(否则二分与归并的 CPU 成本盖过内存收益);Bitmap 删除到低于某下限(实现常取 4096 或更低)则转回 Array 省内存。
- Bitmap ↔ Run 优化:当一个 Bitmap 容器的"1 的占比"很低或连续段极长(常见启发式是 run 编码后字节数小于位图的一半),就进一步压缩成 Run 容器;反之 Run 拆碎了就退回 Bitmap。
这种"运行时按数据形态选最优表示"的思想,和 ART 的 Node4/16/48/256 自适应节点、Swiss Table 的 ctrl 元数据,是同一类工程哲学——不为最坏情况预留固定开销,而是让结构贴合真实分布。
七、集合运算:跨容器的高效并集与交集
Roaring 的集合运算以"分块"为最小调度单位。两个 Roaring bitmap 做运算时,先按高 16 位(分块 key)对齐:
- 只在 A 或只在 B 的分块:结果直接继承原容器(并集收下、交集丢弃),零拷贝。
- 两边同时有的分块:两个容器类型可能不同,按类型配对选择最优算法。
跨类型容器的核心算法:
| 运算 / 配对 | Array × Array | Array × Bitmap | Bitmap × Bitmap |
|---|---|---|---|
| 并集 | 双指针归并 | Bitmap 置位 Array 元素 | 逐字 OR |
| 交集 | 双指针取公共 | 遍历 Array 测 Bitmap 位 | 逐字 AND |
| 差集 A−B | 双指针剔除 | Array 剔除 Bitmap 命中位 | 逐字 AND-NOT |
关键优化在于不做无谓的容器转换:稀疏分块走指针扫描反而比先转成 8KB 位图再位运算更快、更省内存;只有真正稠密的分块才享受位级并行。这正是 Roaring 在真实数据集上碾压 WAH/EWAH 的原因——后者强制所有分块走同一种游程/字对齐编码,丢失了"按块选型"的灵活度。
八、基数估计、序列化与磁盘字节布局
每个容器都自维护基数(cardinality):Array 等于数组长度,Bitmap 维护一个计数器(增删时同步增减,不必每次重数 8192 字节),Run 等于各 run 长度之和。于是求整个 Roaring bitmap 的基数只要把各分块基数相加,O(分块数) 而非 O(值域),这直接喂给了"COUNT(DISTINCT)"类查询的极速估算。
序列化时,Roaring 写一个全局头(Cookie 标识格式版本),随后按分块顺序写:2 字节分块 key + 1 字节容器类型 + 容器载荷。三种容器的载荷格式各异(Array 存排序 uint16 列表;Bitmap 存 8192 字节;Run 存 run 个数与 (start,length) 对),反序列化时按类型字段就地重建,无需统一展开成位图。这个紧凑字节布局让 Roaring 落盘与网络传输都远小于原始位图,也方便列式引擎做"只解压用到的分块"的延迟物化。
九、工程落地:列式引擎与倒排索引为什么选它
Roaring 几乎成了"需要高速集合运算"场景的事实标准,典型落点:
- 倒排索引(Lucene / Elasticsearch):每个 term 对应的文档 id 集合用 Roaring 存,多 term 的 AND/OR 查询就是分块级位运算,配合跳表与 skip-list 还能提前剪枝。
- 列式执行引擎(DuckDB / Kuzu 图数据库):过滤后的行号集合、删除位图(delete bitmap)用 Roaring 表达,向量化算子与 Roaring 求交紧密配合。
- 分布式数据处理(Apache Spark / Druid):分区级集合的合并与去重,Roaring 的紧凑序列化大幅压低 shuffle 体积。
相较传统 WAH/EWAH 那种"字对齐游程"统一编码,Roaring 的三态容器 + 分块自适应在压缩率与运算速度上普遍更优,这也是为什么现代系统宁可引入稍复杂的结构、也要替换掉老式位图格式。
十、用 Python 复刻一个教学版 Roaring
下面这份实现保留 Roaring 最精髓的三件事:高/低 16 位分块、Array/Bitmap 两态自适应(教学版省略 Run 容器以聚焦主线)、跨分块并集与交集。它可以直接运行,并对 0~9999 的倍数集合验证基数与运算结果。
import bisect
ARRAY_MAX = 4096 # Array 容器上限,超过自动转 Bitmap
class ArrayContainer:
"""排序 uint16 数组,适合稀疏分块。"""
__slots__ = ('vals',)
def __init__(self):
self.vals = [] # 保持升序、无重复
def add(self, v):
i = bisect.bisect_left(self.vals, v)
if i == len(self.vals) or self.vals[i] != v:
self.vals.insert(i, v)
def iter(self):
return iter(self.vals)
def cardinality(self):
return len(self.vals)
class BitmapContainer:
"""8192 字节定长位图,适合稠密分块。"""
__slots__ = ('bits', 'card')
def __init__(self):
self.bits = bytearray(8192) # 2**16 / 8
self.card = 0
def add(self, v):
byte = v >> 3
mask = 1 << (v & 7)
if not (self.bits[byte] & mask):
self.bits[byte] |= mask
self.card += 1
def iter(self):
for i in range(65536):
if self.bits[i >> 3] & (1 << (i & 7)):
yield i
def cardinality(self):
return self.card
def _to_bitmap(c):
if isinstance(c, BitmapContainer):
return c
nb = BitmapContainer()
for v in c.iter():
nb.add(v)
return nb
class RoaringBitmap:
def __init__(self):
self.containers = {} # high16 -> Container
def add(self, x):
hi = (x >> 16) & 0xFFFF
lo = x & 0xFFFF
c = self.containers.get(hi)
if c is None:
c = ArrayContainer()
self.containers[hi] = c
c.add(lo)
if isinstance(c, ArrayContainer) and c.cardinality() > ARRAY_MAX:
self.containers[hi] = _to_bitmap(c) # Array -> Bitmap 升级
def cardinality(self):
return sum(c.cardinality() for c in self.containers.values())
def union(self, other):
res = RoaringBitmap()
for hi in set(self.containers) | set(other.containers):
a, b = self.containers.get(hi), other.containers.get(hi)
if a and b:
res.containers[hi] = _union(a, b)
else:
res.containers[hi] = a or b
return res
def intersection(self, other):
res = RoaringBitmap()
for hi in set(self.containers) & set(other.containers):
c = _intersect(self.containers[hi], other.containers[hi])
if c is not None:
res.containers[hi] = c
return res
def _union(a, b):
if isinstance(a, ArrayContainer) and isinstance(b, ArrayContainer):
merged = sorted(set(a.vals) | set(b.vals))
if len(merged) > ARRAY_MAX:
r = _to_bitmap(a);
for v in b.vals: r.add(v)
return r
r = ArrayContainer(); r.vals = merged; return r
ba, bb = _to_bitmap(a), _to_bitmap(b)
res = BitmapContainer()
for i in range(8192):
res.bits[i] = ba.bits[i] | bb.bits[i]
res.card = sum(bin(x).count('1') for x in res.bits)
return res
def _intersect(a, b):
if isinstance(a, ArrayContainer) and isinstance(b, ArrayContainer):
sa = set(a.vals)
inter = [v for v in b.vals if v in sa]
if not inter:
return None
r = ArrayContainer(); r.vals = inter; return r
ba, bb = _to_bitmap(a), _to_bitmap(b)
res = BitmapContainer(); card = 0
for i in range(8192):
x = ba.bits[i] & bb.bits[i]
res.bits[i] = x
card += bin(x).count('1')
if card == 0:
return None
res.card = card
return res
if __name__ == '__main__':
A, B = RoaringBitmap(), RoaringBitmap()
for x in range(0, 10000, 3): A.add(x) # 3 的倍数,约 3333 个
for x in range(0, 10000, 5): B.add(x) # 5 的倍数,约 2000 个
print("|A| =", A.cardinality()) # 3334
print("|B| =", B.cardinality()) # 2000
print("|A ∪ B| =", A.union(B).cardinality())
print("|A ∩ B| =", A.intersection(B).cardinality()) # 15 的倍数运行结果印证了 Roaring 的语义正确性:|A| 约 3334、|B| 约 2000,交集恰好是"15 的倍数"(既是 3 又是 5 的倍数)。真实引擎里多出的 Run 容器与 SIMD 批量位运算,只是把这同一套算法在内存布局与指令层面进一步榨干。
结语:Roaring Bitmap 的优雅在于它拒绝"一种表示打天下"——用 2^16 分块把全局的稠密/稀疏难题拆成分块级的小决策,再用 Array / Bitmap / Run 三态容器在内存与算力之间动态游走。它和本站已剖析的 Swiss Table、ART、图引擎共享同一份工程直觉:让数据结构随真实数据分布自适应,而不是为最坏情况预付固定开销。把这套思路迁移到自己的索引与过滤层,往往比换更快的硬件更划算。

发表评论 取消回复