理解 Bitmap 的设计及优化

1,003 阅读6分钟

三种bitmap

分类支持的存储类型问题常用实现库
普通bitmap( Java Bitset)支持Int 32位(4字节)类型- 内存占用较大,例如要存储int类型的数据,假如数据稀疏,只存储几个数,依然需要占用512M = 2^32(int的个数)/8/1024/1024 内存空间。 输入只支持 Int 类型Java Bitset
RoaringBitmap( 压缩 bitmap)支持Int 32位(4字节)类型Roaring Bitmap库
Roaring64map(64位bitmap,更优)支持Bigint 64位(4字节)类型CRoaring库

为了优化内存占用,有了RoaringBitmap和Roaring64Map

CRoaring库同时支持32位和64位的整型集合存储。其中RoaringBitmap主要处理32位,Roaring64Map则利用RoaringBitmap实现了64位支持。

RoaringBitmap、Roaring64map和字典编码

RoaringBitmap

RoaringBitmap 的实现如下图所示,

  • 第一层称之为 Chunk(高 16 位),如果该取值范围内没有数据则 Chunk 不会创建
  • 第二层称之为 Container(低 16 位),会依据数据分布进行创建(Container内的值实际是区间内的offset)

RoaringBitmap Container有三种: Array Container ,Run Container和 Bitmap Container。

Array Container 存放稀疏的数据,Bitmap Container 存放稠密的数据。结合实现,若一个 Container 里面的 Integer 数量小于 4096,就用 Short 类型的有序数组来存储值。若大于 4096,就用 Bitmap 来存储值。Run Container是Bitmap的退化处理操作,压缩存储有一定连续值的数据存储。

以下是十进制 821697800 和 191037 的存储方式

由此可以知道,如果计算两个bitmap的交集: {191037,821697800} & {191037}是需要遍历多个Chunk,其中191037所在的Chunk还是一个数组。

如果是计算{0,1}&{1}呢?可以想象到简单很多吧!

Roaring64Map

对于64位整型,Roaring64Map使用map存储:std::map<uint32_t, Roaring> 。key是64位整型的高32位。低32位还是一个RoaringBitmap。所以64位整型的稀疏度更高了!

以上是两个比较稀疏的用户 id 群互相计算,计算方式是遍历左右 Roaring64Map 的每一个 RoaringBitmap,然后依次互相计算,性能比较差。

综上,

字典 编码 就是想把用户真实的bitmap数据进行 压缩 存储,减少使用的 std ::map桶,每个RoaringBitmap中减少Chunk,每个Chunk中尽可能出现Bitmap Container。这样计算时可以用更少的桶 遍历 ,和更快的位运算。

设计疑问

单个普通bitmap最多存储多少个数?

普通bitmap是由数组实现,数据使用bit来占位「二进制0.1」,如果是int数组,使用N/32方式定位数组位置即下标,使用N%32 取余方式定位在int的32bit中的位置。

bitmap 数组 能存储多少数,取决于数组的类型是int/long 和数组的长度。

bitmap为int数组类型,可以存储 32bit × 2^31(数组长度)个数,大约是 10^10 次方,60亿个数

bitmap为long数组类型,可以存储 64bit × 2^31个数, 是上边的2倍。

如下 来计算数组需要多少空间「N代表要存储最大的数值」

int temp[]=new int[1+N/32]

long temp[]=new long[1+N/64]

int/long的最大值

Java中,int的最大值是 2^31 次方,覆盖负数、数:-2147483648、2147483647。内存占用4字节; long的最大值是 2^63 次方,覆盖负数、正数;内存占用8字节。

数组 的最大容量?

数组的length的类型是int,所以数组最大容量是 2^31 次方(int的最大值)。

例如int类型的 数组 ,4字节 * 2^31 /1024(2^10)/1024/1024 = 8G,也就是最大8G内存(不用long作为length的类型,是因为没有8字节 * 2^63/1024(2^10)/1024/1024= G 这么大内存的机器)。

延伸:String的最大长度,是char[],最大长度也是 2^31 次方,内存 2字节 * 2^31 = 4G

bitmap存储的数据过于稀疏会怎么样?

普通bitmap 数据稀疏。 又比如要存入(10,8887983,93452134)这三个数据,我们需要建立一个 99999999 长度的 BitMap ,但是实际上只存了3个数据,这时候就有很大的空间浪费,碰到这种问题的话,可以通过引入 Roaring BitMap 来解决。

RoaringBitmap数据稀疏

RoaringBitmap中,Array Container存储的是这个数据,需要遍历array,BitMap Container存储的是数据根据取模的占位符,是位计算。当数据过于稀疏时,RoaringBitmap使用Array Container,遍历array,以至于效率不如BitMap Container的位计算。

如何解决数据稀疏问题?字典 编码

数据过于稀疏时,RoaringBitmap 底层存储结构用的是 Array Container 而不是 BitMap Container,Array Container 性能远远差于 BitMap Container。因此我们可以使用全局字典将用户 ID 映射成连续递增的 ID,这样的话数据稠密,就会使用bitmap container,效率会高很多。

字典 编码 /全局字典是为了提升bitmap的计算性能。

原理上讲:字典 编码 就是想把用户真实的bitmap数据进行 压缩 存储,减少使用的 std ::map桶,每个RoaringBitmap中减少Chunk,每个Chunk中尽可能出现Bitmap Container。这样计算时可以用更少的桶 遍历 ,和更快的位运算。

总体原则:分片正交原则

BitEngine中使用了bitmap64类型,它存储id类型,

1 建表的时候如果设置了BitEngineEncode属性,则导入的前置条件是:

  保证相同的id出现在相同的物理机上。

2 建表的时候如果没有设置BitEngineEncode属性,则没有任何限制

为什么有这个原则?

BitEngine字典编码是在每台物理机上存储独立的字典,因此如果相同的id导入到不同的物理机上,编码就会不同,也就是同一个id在不同机器上被编码了多次,导致查询结果出现偏差,设计见BitEngine 字典编码

为什么要字典编码?

BitEngine在多个业务的使用中发现,使用字典编码比不使用字典编码性能有50-100倍的提升,跟BitMap64存储原理相关,详见字典编码性能比较

去重工具对比

  • HLL
  • bloomfilter