BloomFilter 原理,實現及優化

Charles0429發表於2016-12-05

引言

最近在做效能優化相關的事情,其中涉及到了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,對於一次讀資料的情況,分為以下幾種情況:

  1. 請求的元素不在磁碟中,如果BloomFilter返回不存在,那麼應用不需要走讀盤邏輯,假設此概率為P1;如果BloomFilter返回可能存在,那麼屬於誤判情況,假設此概率為P2
  2. 請求的元素在磁碟中,BloomFilter返回存在,假設此概率為P3

如果使用hashmap或者set的資料結構,情況如下:

  1. 請求的資料不在磁碟中,應用不走讀盤邏輯,此概率為P1+P2
  2. 請求的元素在磁碟中,應用走讀盤邏輯,此概率為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,如下:

BloomFilter 原理,實現及優化
bloomfilter insert

如上圖,插入了兩個元素,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中不允許有刪除操作,因為刪除後,可能會造成原來存在的元素返回不存在,這個是不允許的,還是以一個例子說明:

BloomFilter 原理,實現及優化
bloomfilter delete

上圖中,剛開始時,有元素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模擬的bit array,其他對應於BloomFilter原理一節所說的幾個引數。

整個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來抓取其效能資料,結果如下:

BloomFilter 原理,實現及優化
bloomfilter hash opt before

如上圖,除了生成資料的函式外,佔用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次計算的時候,不斷累加兩次的結果。

經過優化後,效能資料圖如下:

BloomFilter 原理,實現及優化
bloomfilter hash opt after

和之前效能圖對比發現,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:
本部落格更新會在第一時間推送到微信公眾號,歡迎大家關注。

BloomFilter 原理,實現及優化
qocde_wechat

參考文獻

相關文章