如何優雅高效的在海量數據存儲與查找
對於這個問題我們首先可能會想到直接去存儲這40億個數據,當然這確實是一種方法。但是我們是否考慮過這樣做的后果呢?如果你的解決方案就是上面的那種方法的話,那你可能真的沒有考慮過后果。所以你有必要繼續往下讀。
我們先不管后果是什么,現在我來帶大家看一組數據,假設這40億個數據是4個字節的unsigned int 型的數據。那嘛現在 我們要存儲這40億個數據就需要的空間為:(40 * 10^8) * 4byte = 14.9GB (注意這里所換算機制:1GB=2^10Mb=2^20kb=2^30byte,下面涉及到的計算也是采用這種方式)所以大家看見了后果就是占用了這么大的內存空間,一般計算機上的內存根本就放不下嘛,所以這還怎么干活。。。
該怎么辦呢?
那么我們就來看看該怎么辦?
假如我們換一種思路不直接去存儲這40億個數據,而是間接的去存儲的話是不是就可以解決了?
我們采用位圖(bitmap)的方式來存儲這40億個數據,用每一位來表示一個數據這樣的話是不是就節省了極大的空間呢?
那我們就來計算一下這樣做所占用的空間是多少?
因為這40億個數據都是位於[0, 2^32 - 1],所以我們要存儲的0~2^32-1這個范圍內的所有數據共2^32個。首先大家要明確:每一位指的是一個bit位,而1byte(1字節)是8個bit.這個大家應該都知道。那么就是:2^32bit = 2^29byte = 2^19kbyte=2^9Mb=512Mb。
512Mb 與14.9GB相比是不是遠遠要少呢。因此我們只需要開辟512M的內存存下2^32個數據,然后把給定的40億個數據對應位置置為1當我們判斷某一個數據是不是在給定的40億個數據中的時候只需要查看對應位置是不是1即可。
說到這里大家是不是有些感悟呢?可能有些愛問的小伙伴就該問了,這個位操作到底該怎么存儲呢?我后面會寫一篇關於這個問題的,本篇文章旨在講清楚具體的原理流程,具體的操作會另出一篇文章的。
嗯,我們繼續剛才的話題,讀者讀到這里就會問了:上面說的都是關於bitmap的跟標題roaringBitMap有啥關系呢?其實講上面的問題是起到一個拋磚引玉的作用,讓大家先了解一下bitmap的用法,對我們學習接下來要講到roaringBitMap有很大的幫助。
如何更加優雅高效的存儲與查找?
我們繼續看上面的問題,我們用位圖(bitmap)的方式存儲了[0, 2^32 - 1]區間上的數據,比直接存儲節省了極大的空間。而且在查找的時候使用的是位操作,其時間復雜度為O(1)也是非常高效的。
但是問題又來啦:
在我們使用bitmap的時候,比如某一個軟件,我們統計使用該軟件的在線人數的時候,在這里用戶的id為一個int型數據,當某個用戶上線的時候我們就要把位圖對應的位置,置為1以此來標記該用戶在線。在這里假設用戶的id范圍在[0, 2^32 - 1],那么根據上面的計算我們就需要開辟512M的空間來存儲這個數據范圍。由此看來好像也沒什么問題是吧?但是,假如我們只有幾個用戶在線的時候,還要開辟512M的內存空間是不是就顯得大材小用了呢,遠遠的浪費了空間(注意位圖的使用是在一開始的時候就要開辟出所數據范圍需要的空間,在這里是512M)。
所以針對上面空間還是無法高效的利用的問題,大佬們提出了位圖壓縮的方式進一步優化空間利用率,位圖壓縮的方式有多種,在這里我們來聊一聊roaringBitMap。
roaringBitMap的工作原理
好了現在開始進入我們今天的正題roaringBitmap了,對於學習任何一個新的東西我認為我們都應該帶着一下3個問題去學習,今天學習roaringbitmap也是一樣即:
- 是什么? roaringBitmap是個什么東東?
- 什么用? roaring有什么用處?
- 怎么用? 怎么用roaingBitMap?
首先我們看一下roaringbitmap是什么?
roaringbitmap屬於是位圖的一個進化,即壓縮位圖,不過在roaringbitmap中不只包含bitmap這一種數據結構,而是包涵了多種存儲的方式,以此來達到壓縮位圖的目的,具體的存儲方式下面會講到。
那么roaringbitmap有什么用呢?
在實際的業務當中我們可以使用roaringbitmap來存儲用戶的屬性標簽,增刪改查這些屬性標簽等,以及根據這些存儲的用戶的標簽通過並集,交集等方法來篩選出特定的用戶,當然也用於其他的一些場景中這里就不一一列舉了。
然后在這里寫一個例,以便讓大家更好的理解roaringbitmap.如下圖有一個存儲用戶性別的roaringbitmap_sex,以及一個存儲喜好唱歌的roaringbitmap_like,現在我們需要找出:性別為男並且喜歡唱歌的用戶,那么我們便可以求roaringbitmap_sex和roaringbitmap_like的交集即可,結果中二進制為1的位便是我們需要找的用戶。(這里的roaringbitmap_sex和roaringbitmap_like是位圖,不懂可以先大致看一下就行,下面會講的)。

roaringbitmap怎么用?
這里要說的怎么樣可不只是簡單的怎么調用,在這里我們要說的是roaringbitmap的底層工作原理,讓大家清楚roaringbitmap底層是如何工作的,這里會從以下幾方便來講解。
roaringbitmap工作原理
- 首先,將 32bit int(無符號的)類型數據 划分為 2^16 個桶(即使用數據的前16位二進制作為桶的編號),每個桶有一個Container(可以理解為容器也可以理解為這個桶,容器和桶在這里可以理解為一個東西,只是說法不一樣而已) 來存放一個數值的低16位。
- 在存儲和查詢數值時,將數值 k 划分為高 16 位和低 16 位,取高 16 位值找到對應的桶,然后在將低 16 位值存放在相應的 Container 中。這樣說可能比較抽象不易理解,下面以一個例子來幫助大家理解。
比如我們要將31這個數放進roarigbitmap中,它的16進制為:0000001F,前16位為0000,后16為001F。所以我們先需要根據前16位的值:0,找到它對應的通的編號為0,然后根據后16位的值:31,確定這個值應該放到桶中的哪一個位置,如下圖所示。

這里的小桶到底是什么大家可以先不必深究,下面會講的。大家需要注意大桶里面的各個小桶(container)是在需要的時候才會申請開辟的,並不是一開始就全部申請的,而且大桶中小桶都是按序號有序排列在大桶里面的。
roaringbitmap中的四種小桶
在上面我們提到了大桶里裝了許多的小桶(其實說container(容器)更標准),那么現在我們就來看一看小桶究竟是什么,在roaringbitmap中共有4種小桶:arraycontainer(數組容器),bitmapcontainer(位圖容器),runcontainer(行程步長容器),sharedcontainer(共享容器)。下面我們分別來介紹一下這4種容器。
arraycontiner
在創建一個新container時,如果只插入一個元素,RBM(roaringbitmap)默認會用ArrayContainer來存儲。當ArrayContainer(其中每一個元素的類型為 short int 占兩個字節,且里面的元素都是按從大到小的順序排列的)的容量超過4096(這里是指4096個short int即8k)后,會自動轉成BitmapContainer(這個所占空間始終都是8k)存儲。4096這個閾值很聰明,低於它時ArrayContainer比較省空間,高於它時BitmapContainer比較省空間。也就是說ArrayContainer存儲稀疏數據,BitmapContainer存儲稠密數據,可以最大限度地避免內存浪費。下面這個圖可以很清楚的看懂這種關系。

bitmapcontainer
這個容器其實就是我們最開講的位圖,只不過這里位圖的位數為2^16(65536)個,也就是2^16個bit,計算下來起所占內存就是8kb。然后每一位用0,1表示這個數不存在或者存在,如下圖:

runcontainer
這是一種利用步長來壓縮空間的方法,我們舉個例子:
比如連續的整數序列 11, 12, 13, 14, 15, 27, 28, 29 會被 壓縮為兩個二元組 11, 4, 27, 2 表示:11后面緊跟着4個連續遞增的值,27后面跟着2個連續遞增的值,那么原先16個字節的空間,現在只需要8個字節,是不是節省了很多空間呢。不過這種容器不常用,所以在使用的時候需要我們自行調用相關的轉換函數來判斷是不是需要將arraycontiner,或bitmapcontainer轉換為runcontainer。
sharedcontainer
這種容器它本身是不存儲數據的,只是用它來指向arraycontainer,bitmapcontainer或runcontainer,就好比指針的作用一樣,這個指針可以被多個對象擁有,但是指針所指針的實質東西是被這多個對象所共享的。在我們進行roaringbitmap之間的拷貝的時候,有時並不需要將一個container拷貝多份,那么我們就可以使用sharedcontainer來指向實際的container,然后把sharedcontainer賦給多個roaringbitmap對象持有,這個roaringbitmap對象就可以根據sharedcontainer找到真正存儲數據的container,這可以省去不必要的空間浪費。
這些container之間的關系可以用下面這幅圖來表示:

其中的roaring_array是roaringbitmap對象,而途中的sharedcontainer則表示被多個roaring_array里面的小桶共享。
最后我們來將roaringbitmap相比於普通的bitmap的優勢總結為以下幾點:
內存上:
- bitmap比較適用於數據分布比較稠密的存儲場景中,對於原始的Bitmap來說,若要存儲uint32類型數據,這就需要2 ^ 32長度的bit數組 通過計算可以發現(2 ^ 32 / 8 bytes = 512MB), 一個普通的Bitmap需要耗費512MB的存儲空間。如果我們只存儲幾個數據的話依然需要占用521M空間,這就有些浪費空間了,因此我們可以采用對位圖進行壓縮的RoaringBitMap,以此減少內存和提高效率。
性能上:
roaringbitmap除了比bitmap占用內存少之外,其並集和交集操作的速度也要比bitmap的快。原因歸結為以下幾點:
- 計算上的優化
對於roaringbitmap本質上是將大塊的bitmap分成各個小塊,其中每個小塊在需要存儲數據的時候才會存在。所以當進行交集或並集運算的時候,roaringbitmap只需要去計算存在的一些塊而不需要像bitmap那樣對整個大的塊進行計算。如果塊內非常稀疏,那么只需要對這些小整數列表進行集合的 AND、OR 運算,這樣的話計算量還能繼續減輕。這里既不是用空間換時間,也沒有用時間換空間,而是用邏輯的復雜度同時換取了空間和時間。
同時在roaringbitmap中32位長的數據,被分割成高 16 位和低 16 位,高 16 位表示塊偏移,低16位表示塊內位置,單個塊可以表達 64k 的位長,也就是 8K 字節。這樣可以保證單個塊都可以全部放入 L1 Cache,可以顯著提升性能。
2. 程序邏輯上的優化
(1)roaringbitmap維護了排好序的一級索引,以及有序的arraycontainer當進行交集操作的時候,只需要根據一級索引中對應的值來獲取需要合並的容器,而不需要合並的容器則不需要對其進行操作直接過濾掉。
(2)當進行合並的arraycontainer中數據個數相差過大的時候采用基於二分查找的方法對arraycontainer求交集,避免不必要的線性合並花費的時間開銷。
(3)roaingbitmap在做並集的時候同樣根據一級索引只對相同的索引的容器進行合並操作,而索引不同的直接添加到新的roaringbitmap上即可,不需要遍歷容器。
(4)roaringbitmap在合並容器的時候會先預測結果,生成對應的容器,避免不必要的容器轉換操作。
