引言
最近在做效能優化相關的事情,其中涉及到了BloomFilter,於是對BloomFilter總結了下,本文組織結構如下:
- BloomFilter的使用場景
- BloomFilter的原理
- BloomFilter的實現及優化
本文同步釋出在個人部落格oserror.com。
BloomFilter的使用場景
首先,簡單來看下BloomFilter是做什麼的?
A Bloom filter is a space-efficient probabilistic data structure, conceived by Burton Howard Bloom in 1970, that is used to test whether an element is a member of a set. False positive matches are possible, but false negatives are not, thus a Bloom filter has a 100% recall rate. In other words, a query returns either "possibly in set" or "definitely not in set".
上述描述引自維基百科,特點總結為如下:
- 空間效率高的概率型資料結構,用來檢查一個元素是否在一個集合中
- 對於一個元素檢測是否存在的呼叫,BloomFilter會告訴呼叫者兩個結果之一:可能存在或者一定不存在
其次,為什麼需要BloomFilter?
常用的資料結構,如hashmap,set,bit array都能用來測試一個元素是否存在於一個集合中,相對於這些資料結構,BloomFilter有什麼方面的優勢呢?
- 對於hashmap,其本質上是一個指標陣列,一個指標的開銷是sizeof(void *),在64bit的系統上是64個bit,如果採用開鏈法處理衝突的話,又需要額外的指標開銷,而對於BloomFilter來講,返回可能存在的情況中,如果允許有1%的錯誤率的話,每個元素大約需要10bit的儲存空間,整個儲存空間的開銷大約是hashmap的15%左右(資料來自維基百科)
- 對於set,如果採用hashmap方式實現,情況同上;如果採用平衡樹方式實現,一個節點需要一個指標儲存資料的位置,兩個指標指向其子節點,因此開銷相對於hashmap來講是更多的
- 對於bit array,對於某個元素是否存在,先對元素做hash,取模定位到具體的bit,如果該bit為1,則返回元素存在,如果該bit為0,則返回此元素不存在。可以看出,在返回元素存在的時候,也是會有誤判的,如果要獲得和BloomFilter相同的誤判率,則需要比BloomFilter更大的儲存空間
當然,BloomFilter也有它的劣勢,如下:
- 相對於hashmap和set,BloomFilter在返回元素可能存在的情況中,有一定的誤判率,這時候,呼叫者在誤判的時候,會做一些不必要的工作,而對於hashmap和set,不會存在誤判情況
- 對於bit array,BloomFilter在插入和查詢元素是否存在時,需要做多次hash,而bit array只需要做一次hash,實際上,bit array可以看做是BloomFilter的一種特殊情況
最後,以一個例子具體描述使用BloomFilter的場景,以及在此場景下,BloomFilter的優勢和劣勢。
一組元素存在於磁碟中,資料量特別大,應用程式希望在元素不存在的時候儘量不讀磁碟,此時,可以在記憶體中構建這些磁碟資料的BloomFilter,對於一次讀資料的情況,分為以下幾種情況:
- 請求的元素不在磁碟中,如果BloomFilter返回不存在,那麼應用不需要走讀盤邏輯,假設此概率為P1;如果BloomFilter返回可能存在,那麼屬於誤判情況,假設此概率為P2
- 請求的元素在磁碟中,BloomFilter返回存在,假設此概率為P3
如果使用hashmap或者set的資料結構,情況如下:
- 請求的資料不在磁碟中,應用不走讀盤邏輯,此概率為P1+P2
- 請求的元素在磁碟中,應用走讀盤邏輯,此概率為P3
假設應用不讀盤邏輯的開銷為C1,走讀盤邏輯的開銷為C2,那麼,BloomFilter和hashmap的開銷為
Cost(BloomFilter) = P1 * C1 + (P2 + P3) * C2
Cost(HashMap) = (P1 + P2) * C1 + P3 * C2;
Delta = Cost(BloomFilter) - Cost(HashMap)
= P2 * (C2 - C1)複製程式碼
因此,BloomFilter相當於以增加P2 * (C2 - C1)
的時間開銷,來獲得相對於hashmap而言更少的空間開銷。
既然P2是影響BloomFilter效能開銷的主要因素,那麼BloomFilter設計時如何降低概率P2(即false positive probability)呢?,接下來的BloomFilter的原理將回答這個問題。
BloomFilter的原理
原理
BloomFilter通常採用bit array實現,假設其bit總數為m,初始化時m個bit都被置成0。
BloomFilter中插入一個元素,會使用k個hash函式,來計算出k個在bit array中的位置,然後,將bit array中這些位置的bit都置為1。
以一個例子,來說明新增的過程,這裡,假設m=19,k=2,如下:
如上圖,插入了兩個元素,X和Y,X的兩次hash取模後的值分別為4,9,因此,4和9位被置成1;Y的兩次hash取模後的值分別為14和19,因此,14和19位被置成1。
BloomFilter中查詢一個元素,會使用和插入過程中相同的k個hash函式,取模後,取出每個bit對應的值,如果所有bit都為1,則返回元素可能存在,否則,返回元素不存在。
為什麼bit全部為1時,是表示元素可能存在呢?
還是以上圖的例子說明,如果要查詢的元素是X,k個hash函式計算後,取出的bit都是1,此時,X本身也是存在的;假如,要查詢另一個元素Z,其hash計算出來的位置為9,14,此時,BloomFilter認為此元素存在,但是,Z實際上是不存在的,此現象稱為false positive。
最後,BloomFilter中不允許有刪除操作,因為刪除後,可能會造成原來存在的元素返回不存在,這個是不允許的,還是以一個例子說明:
上圖中,剛開始時,有元素X,Y和Z,其hash的bit如圖中所示,當刪除X後,會把bit 4和9置成0,這同時會造成查詢Z時,報不存在的問題,這對於BloomFilter來講是不能容忍的,因為它要麼返回絕對不存在,要麼返回可能存在。
放到之前的磁碟讀資料的例子來講,如果刪除了元素X,導致應用讀取Z時也會返回記錄不存在,這是不符合預期的。
BloomFilter中不允許刪除的機制會導致其中的無效元素可能會越來越多,即實際已經在磁碟刪除中的元素,但在bloomfilter中還認為可能存在,這會造成越來越多的false positive,在實際使用中,一般會廢棄原來的BloomFilter,重新構建一個新的BloomFilter。
引數如何取值
在實際使用BloomFilter時,一般會關注false positive probability,因為這和額外開銷相關。實際的使用中,期望能給定一個false positive probability和將要插入的元素數量,能計算出分配多少的儲存空間較合適。
假設BloomFilter中元素總bit數量為m,插入的元素個數為n,hash函式的個數為k,false positive probability記做p,它們之間有如下關係(具體推導過程請參考維基百科):
如果需要最小化false positive probability,則k的取值如下
k = m * ln2 / n; 公式一複製程式碼
而p的取值,和m,n又有如下關係
m = - n * lnp / (ln2) ^ 2 公式二複製程式碼
把公式一代入公式二,得出給定n和p,k的取值應該為
k = -lnp / ln2複製程式碼
最後,也同樣可以計算出m。
BloomFilter實現及優化
基本版實現
基礎的資料結構如下:
template<typename T>
class BloomFilter
{
public:
BloomFilter(const int32_t n, const double false_positive_p);
void insert(const T &key);
bool key_may_match(const T &key);
private:
std::vector<char> bits_;
int32_t k_;
int32_t m_;
int32_t n_;
double p_;
};複製程式碼
其中bits_是用vector
整個BloomFilter包含三個操作:
- 初始化:即上述程式碼中的建構函式
- 插入:即上述程式碼中的insert
- 判斷是否存在:即上述程式碼中的key_may_match
初始化
根據BloomFilter原理一節中的方法進行計算,程式碼如下:
template<typename T>
BloomFilter<T>::BloomFilter(const int32_t n, const double false_positive_p)
: bits_(), k_(0), m_(0), n_(n), p_(false_positive_p)
{
k_ = static_cast<int32_t>(-std::log(p_) / std::log(2));
m_ = static_cast<int32_t>(k_ * n * 1.0 / std::log(2));
bits_.resize((m_ + 7) / 8, 0);
}複製程式碼
這裡開始實現的時候犯了個低階的錯誤,一開始用的是bits_.reserve
,導致BloomFilter的false positive probability非常高,原因是reserve方法只分配記憶體,並不進行初始化。
插入
即設定每個hash函式計算出來的bit為1,程式碼如下
template<typename T>
void BloomFilter<T>::insert(const T &key)
{
uint32_t hash_val = 0xbc9f1d34;
for (int i = 0; i < k_; ++i) {
hash_val = key.hash(hash_val);
const uint32_t bit_pos = hash_val % m_;
bits_[bit_pos/8] |= 1 << (bit_pos % 8);
}
}複製程式碼
判斷是否存在
即計算每個hash函式對應的bit的值,如果全為1,則返回存在;否則,返回不存在。
template<typename T>
bool BloomFilter<T>::key_may_match(const T &key)
{
uint32_t hash_val = 0xbc9f1d34;
for (int i = 0; i < k_; ++i) {
hash_val = key.hash(hash_val);
const uint32_t bit_pos = hash_val % m_;
if ((bits_[bit_pos/8] & (1 << (bit_pos % 8))) == 0) {
return false;
}
}
return true;
}複製程式碼
下面進行了一組測試,設定期望的false positive probability為0.1,模擬key從10000增長到100000的場景,觀察真實的false positive probability的情況:
key_nums_=10000 expected false positive rate=0.1 real false positive rate=0.1252
key_nums_=20000 expected false positive rate=0.1 real false positive rate=0.1252
key_nums_=30000 expected false positive rate=0.1 real false positive rate=0.1257
key_nums_=40000 expected false positive rate=0.1 real false positive rate=0.1211
key_nums_=50000 expected false positive rate=0.1 real false positive rate=0.1277
key_nums_=60000 expected false positive rate=0.1 real false positive rate=0.1263
key_nums_=70000 expected false positive rate=0.1 real false positive rate=0.126
key_nums_=80000 expected false positive rate=0.1 real false positive rate=0.1219
key_nums_=90000 expected false positive rate=0.1 real false positive rate=0.1265
key_nums_=100000 expected false positive rate=0.1 real false positive rate=0.1327複製程式碼
由於實現的時候,會對k進行取整,根據取整後的結果(k=3),計算出來的理論值是0.1250,可以,看出實際測出來的值和理論值差別不大。
優化
前面實現的版本中,多次呼叫了hash_func函式,這對於計算比較長的字串的hash的開銷是比較大的,為了模擬這種場景,插入1000w行的資料,使用perf top來抓取其效能資料,結果如下:
如上圖,除了生成資料的函式外,佔用CPU最高的就屬於hash_func了,佔用了13%的CPU。
分析之前的程式碼可以知道,insert和key_may_match時,都會多次呼叫hash_func,這個開銷是比較大的。
leveldb和維基百科中都有提到,根據之前的研究,可以採用兩次hash的方式來替代上述的多次的計算,基本的思路如下:
template<typename T>
void BloomFilter<T>::insert2(const T &key)
{
uint32_t hash_val = key.hash(0xbc9f1d34);
const uint32_t delta = (hash_val >> 17) | (hash_val << 15);
for (int i = 0; i < k_; ++i) {
const uint32_t bit_pos = hash_val % m_;
bits_[bit_pos/8] |= 1 << (bit_pos % 8);
hash_val += delta;
}
}複製程式碼
即先用通常的hash函式計算一次,然後,使用移位操作計算一次,最後,k次計算的時候,不斷累加兩次的結果。
經過優化後,效能資料圖如下:
和之前效能圖對比發現,hash_func的CPU使用率已經減少到4%了。
對比完效能之後,我們還需要對比hash函式按照如此優化後,false positive probability的變化情況:
before_opt
key_nums_=10000 expected false positive rate=0.1 real false positive rate=0.1252
key_nums_=20000 expected false positive rate=0.1 real false positive rate=0.1252
key_nums_=30000 expected false positive rate=0.1 real false positive rate=0.1257
key_nums_=40000 expected false positive rate=0.1 real false positive rate=0.1211
key_nums_=50000 expected false positive rate=0.1 real false positive rate=0.1277
key_nums_=60000 expected false positive rate=0.1 real false positive rate=0.1263
key_nums_=70000 expected false positive rate=0.1 real false positive rate=0.126
key_nums_=80000 expected false positive rate=0.1 real false positive rate=0.1219
key_nums_=90000 expected false positive rate=0.1 real false positive rate=0.1265
key_nums_=100000 expected false positive rate=0.1 real false positive rate=0.1327
after_opt
key_nums_=10000 expected false positive rate=0.1 real false positive rate=0.1244
key_nums_=20000 expected false positive rate=0.1 real false positive rate=0.1327
key_nums_=30000 expected false positive rate=0.1 real false positive rate=0.134
key_nums_=40000 expected false positive rate=0.1 real false positive rate=0.1389
key_nums_=50000 expected false positive rate=0.1 real false positive rate=0.1342
key_nums_=60000 expected false positive rate=0.1 real false positive rate=0.1548
key_nums_=70000 expected false positive rate=0.1 real false positive rate=0.141
key_nums_=80000 expected false positive rate=0.1 real false positive rate=0.1536
key_nums_=90000 expected false positive rate=0.1 real false positive rate=0.1517
key_nums_=100000 expected false positive rate=0.1 real false positive rate=0.154複製程式碼
優化後,最大的false positive probability增長了2%左右,這個可以增加k來彌補,因為,優化後的hash演算法,在k增長時,帶來的開銷相對來講不大。
備註,本節採用perf抓取效能資料圖,命令如下
sudo perf record -a --call-graph dwarf -p 9125 sleep 60
sudo perf report -g graph複製程式碼
本文的程式碼在(bloomfilter.cpp)(github.com/Charles0429…
PS:
本部落格更新會在第一時間推送到微信公眾號,歡迎大家關注。