作者:京東科技 曹留界
在人群本地化實踐中我們介紹了人群ID中所有的pin的偏移量可以透過Bitmap儲存,而Bitmap所佔用的空間大小隻與偏移量的最大值有關係。假如現在要向Bitmap記憶體入兩個pin對應的偏移量,一個偏移量為1,另一個偏移量為100w,那麼Bitmap儲存直接需要100w bit的空間嗎?資料部將偏移量存入Bitmap時,又如何解決資料稀疏問題呢?本文將為大家解答這個問題。
一、BitMap
Bitmap的基本思想就是用一個bit位來標記某個元素對應的Value,而Key即是該元素。由於採用了Bit為單位來儲存資料,因此可以大大節省儲存空間。
如果想將數字2存入點陣圖中,則只需要將點陣圖陣列中下標為2的陣列值置為1。
但是,如果現在要儲存兩個人群ID對應的偏移量,一個偏移量為1,另一個偏移量為100w,如果將這兩個值直接放到點陣圖陣列中,那麼點陣圖陣列所需要的空間就是100wbit,會產生大量的空間浪費。那麼有什麼方法可以避免空間浪費嗎?答案就是RoaringBitMap!
二、RoaringBitMap
RoaringBitMap是一種高效壓縮點陣圖,簡稱RBM。RBM的概念於2016年由S. Chambi、D. Lemire、O. Kaser等人在論文《Better bitmap performance with Roaring bitmaps》 《Consistently faster and smaller compressed bitmaps with Roaring》中提出。下面我們結合java中的實現對其進行介紹。
2.1 實現思路
RBM主要將32位的整型(int)分為高16位和低16位(兩個short),其中高16位對應的數字使用16位整型有序陣列儲存,低16位根據不同的情況選擇三種不同的container來儲存,這三種container分別為:
•Array Container
底層資料結構為short型別的陣列,直接將數字低16位的值儲存到該陣列中。short型別的陣列始終保持有序,方便使用二分查詢,且不會儲存重複數值。因為這種Container儲存資料沒有任何壓縮,因此只適合儲存少量資料。其內部陣列容量是動態變化的,當容量不夠時會進行擴容,最大容量為4096。由於陣列是有序的,儲存和查詢時都可以透過二分查詢快速定位其在陣列中的位置。
ArrayContainer佔用的空間大小與儲存的資料量為線性關係,每個short為2位元組,因此儲存了N個資料的ArrayContainer佔用空間大致為2N位元組。儲存一個資料佔用2位元組,儲存4096個資料佔用8kb。
•Bitmap Container
底層實現為點陣圖。這種Container使用long[]儲存點陣圖資料。我們知道,每個Container處理16位整形的資料,也就是0~65535,因此根據點陣圖的原理,需要65536個位元來儲存資料,每個位元位用1來表示有,0來表示無。每個long有64位,因此需要1024個long來提供65536個位元。
因此,每個BitmapContainer在構建時就會初始化長度為1024的long[]。這就意味著,不管一個BitmapContainer中只儲存了1個資料還是儲存了65536個資料,佔用的空間都是同樣的8kb。
•Run Container
RunContainer中的Run指的是行程長度壓縮演算法(Run Length Encoding),對連續資料有比較好的壓縮效果。
它的原理是,對於連續出現的數字,只記錄初始數字和後續數量。即:
•對於數列11
,它會壓縮為11,0
;
•對於數列11,12,13,14,15
,它會壓縮為11,4
;
•對於數列11,12,13,14,15,21,22
,它會壓縮為11,4,21,1
;
原始碼中的short[] valueslength中儲存的就是壓縮後的資料。
這種壓縮演算法的效能和資料的連續性(緊湊性)關係極為密切,對於連續的100個short,它能從200位元組壓縮為4位元組,但對於完全不連續的100個short,編碼完之後反而會從200位元組變為400位元組。
如果要分析RunContainer的容量,我們可以做下面兩種極端的假設:
最好情況,即只存在一個資料或只存在一串連續數字,那麼只會儲存2個short,佔用4位元組
最壞情況,0~65535的範圍內填充所有的奇數位(或所有偶數位),需要儲存65536個short,128kb
也就RBM在存入一個32位的整形數字時,會先按照該數字的高16位進行分桶,以確定該數字要存入到哪個桶中。確定好分桶位置後,再將該數字對應的低16位放入到當前桶所對應的container中。
舉個例子
以十進位制數字131122為例,現在我們要將該數字放入到RBM中。第一步,先將該數字轉換為16進位制,131122對應的十六進位制為0x00020032;其中,高十六位對應0x0002,首先我們找到0x0002所在的桶,再將131122的低16位存入到對應的container中,131122的低16位轉換為10進位制就是50,沒有超過ArrayContainer的容量4096,所以將低16位直接放入到對應的ArrayContainer中。
如果要插入的數字低16位超過了4096,RBM會將ArrayContainer轉換為BitMapContainer。反之,如果資料在刪除之後,陣列中的最大資料小於4096,RBM會將BitMapContainer轉換回ArrayContainer。
RBM處理的是32位的數字,如果我們想處理Long型別的數字怎麼辦呢?這個時候可以使用Roaring64NavigableMap。Roaring64NavigableMap也是使用拆分模式,將一個long型別資料,拆分為高32位與低32位,高32位代表索引,低32位儲存到對應RoaringBitmap中,其內部是一個TreeMap型別的結構,會按照signed或者unsigned進行排序,key代表高32位,value代表對應的RoaringBitmap。
三、空間佔用對比
1、連續資料
分別向點陣圖中插入1w、10w、100w、1000w條連續資料,並且對比BitMap和RoaringBitMap佔用空間的大小。比較結果如下表所示:
10w資料佔用空間 | 100w資料佔用空間 | 1000w資料佔用空間 | |
---|---|---|---|
BitMap | 97.7KB | 976.6KB | 9.5MB |
RoaringBitMap | 16KB | 128KB | 1.2MB |
@Test
public void testSizeOfBitMap() {
//對比佔用空間大小 - 10w元素
RoaringBitmap roaringBitmap3 = new RoaringBitmap();
byte[] bits2 = new byte[100000];
for (int i = 0; i < 100000; i++) {
roaringBitmap3.add(i);
bits2[i] = (byte) i;
}
System.out.println("10w資料 roaringbitmap byte size:"+ roaringBitmap3.getSizeInBytes());
System.out.println("10w資料 點陣圖陣列 byte size:"+bits2.length);
RoaringBitmap roaringBitmap4 = new RoaringBitmap();
byte[] bits3 = new byte[1000000];
for (int i = 0; i < 1000000; i++) {
roaringBitmap4.add(i);
bits3[i] = (byte) i;
}
System.out.println("100w資料 roaringbitmap byte size:"+ roaringBitmap4.getSizeInBytes());
System.out.println("100w資料 點陣圖陣列 byte size:"+bits3.length);
RoaringBitmap roaringBitmap5 = new RoaringBitmap();
byte[] bits4 = new byte[10000000];
for (int i = 0; i < 10000000; i++) {
roaringBitmap5.add(i);
bits4[i] = (byte) i;
}
System.out.println("1000w資料 roaringbitmap byte size:"+ roaringBitmap5.getSizeInBytes());
System.out.println("1000w資料 點陣圖陣列 byte size:"+bits4.length);
}
執行截圖:
2、稀疏資料
我們知道,點陣圖所佔用空間大小隻和點陣圖中索引的最大值有關係,現在我們向點陣圖中插入1和999w兩個偏移量位的元素,再次對比BitMap和RoaringBitMap所佔用空間大小。
佔用空間 | |
---|---|
BitMap | 9.5MB |
RoaringBitMap | 24Byte |
@Test
public void testSize() {
RoaringBitmap roaringBitmap5 = new RoaringBitmap();
byte[] bits4 = new byte[10000000];
for (int i = 0; i < 10000000; i++) {
if (i == 1 || i == 9999999) {
roaringBitmap5.add(i);
bits4[i] = (byte) i;
}
}
System.out.println("兩個稀疏資料 roaringbitmap byte size:"+ roaringBitmap5.getSizeInBytes());
System.out.println("兩個稀疏資料 點陣圖陣列 byte size:"+bits4.length);
}
執行截圖: