什麼是布隆過濾器?在php裡你怎麼用?

PHPer技術棧發表於2022-05-19

引言

在介紹布隆過濾器之前我們首先引入幾個場景。

場景一

在一個高併發的計數系統中,如果一個key沒有計數,此時我們應該返回0,但是訪問的key不存在,相當於每次訪問快取都不起作用了。那麼如何避免頻繁訪問數量為0的key而導致的快取被擊穿?

有人說, 將這個key的值置為0存入快取不就行了嗎?確實,這是一個好的方案。大部分情況我們都是這樣做的,當訪問一個不存在的key的時候,設定一個帶有過期時間的標誌,然後放入快取。不過這樣做的缺點也很明顯,浪費記憶體和無法抵禦隨機key攻擊。

場景二

在一個黑名單系統中,我們需要設定很多黑名單內容。比如一個郵件系統,我們需要設定黑名單使用者,當判斷垃圾郵件的時候,要怎麼去做。比如爬蟲系統,我們要記錄下來已經訪問過的連結避免下次訪問重複的連結。

在郵件很少或者使用者很少的情況下,我們用普通資料庫自帶的查詢就能完成。在資料量太多的時候,為了保證速度,通常情況下我們會將結果快取到記憶體中,資料結構用hash表。這種查詢的速度是O(1),但是記憶體消耗也是驚人的。打個比方,假如我們要存10億條資料,每條資料平均佔據32個位元組,那麼需要的記憶體是64G,這已經是一個驚人的大小了。

一種解決思路

能不能有一種思路,查詢的速度是O(1),消耗記憶體特別小呢?前輩門早就想出了一個很好的解決方案。由於上面說的場景判斷的結果只有兩種狀態(是或者不是,存在或者不存在),那麼對於所存的資料完全可以用位來表示!

資料本身則可以通過一個hash函式計算出一個key,這個key是一個位置,而這個key所對的值就是0或者1(因為只有兩種狀態),如下圖:

圖片

布隆過濾器原理

上面的思路其實就是布隆過濾器的思想,只不過因為hash函式的限制,多個字串很可能會hash成一個值。為了解決這個問題,布隆過濾器引入多個hash函式來降低誤判率。

下圖表示有三個hash函式,比如一個集合中有x,y,z三個元素,分別用三個hash函式對映到二進位制序列的某些位上,假設我們判斷w是否在集合中,同樣用三個hash函式來對映,結果發現取得的結果不全為1,則表示w不在集合裡面。

圖片

布隆過濾器處理流程

布隆過濾器應用很廣泛,比如垃圾郵件過濾,爬蟲的url過濾,防止快取擊穿等等。下面就來說說布隆過濾器的一個完整流程,相信讀者看到這裡應該能明白布隆過濾器是怎樣工作的。

第一步:開闢空間

開闢一個長度為m的位陣列(或者稱二進位制向量),這個不同的語言有不同的實現方式,甚至你可以用檔案來實現。

第二步:尋找hash函式

獲取幾個hash函式,前輩們已經發明瞭很多執行良好的hash函式,比如BKDRHash,JSHash,RSHash等等。這些hash函式我們直接獲取就可以了。

第三步:寫入資料

將所需要判斷的內容經過這些hash函式計算,得到幾個值,比如用3個hash函式,得到值分別是1000,2000,3000。之後設定m位陣列的第1000,2000,3000位的值位二進位制1。

第四步:判斷

接下來就可以判斷一個新的內容是不是在我們的集合中。判斷的流程和寫入的流程是一致的。

誤判問題

布隆過濾器雖然很高效(寫入和判斷都是O(1),所需要的儲存空間極小),但是缺點也非常明顯,那就是會誤判。當集合中的元素越來越多,二進位制序列中的1的個數越來越多的時候,判斷一個字串是否在集合中就很容易誤判,原本不在集合裡面的字串會被判斷在集合裡面。

數學推導

布隆過濾器原理十分簡單,但是hash函式個數怎麼去判斷,誤判率有多少?

假設二進位制序列有m位,那麼經過當一個字串hash到某一位的概率為:

1?
也就是說當前位被反轉為1的概率:

?(1)=1?
那麼這一位沒有被反轉的概率為:

?(0)=1−1?
假設我們存入n各元素,使用k個hash函式,此時沒有被翻轉的概率為:

?(0)=(1−1?)??
那什麼情況下我們會誤判呢,就是原本不應該被翻轉的位,結果翻轉了,也就是

?(誤判)=1−(1−1?)??
由於只有k個hash函式同時誤判了,整體才會被誤判,最後誤判的概率為

?(誤判)=(1−(1−1?)??)?
要使得誤判率最低,那麼我們需要求誤判與m、n、k之間的關係,現在假設m和n固定,我們計算一下k。可以首先看看這個式子:

(1−1?)??
由於我們的m很大,通常情況下我們會用2^32來作為m的值。上面的式子中含有一個重要極限

lim?→∞(1+1?)?=?
因此誤判率的式子可以寫成

?(誤判)=(1−(?)−??/?)?
接下來令?=−?/?,兩邊同時取對數,求導,得到:

?′1?=??(1−???)+?????(−???)1−???
讓?′=0,則等式後面的為0,最後整理出來的結果是

(1−???)??(1−???)=????????
計算出來的k為??2??,約等於0.693??,將k代入p(誤判),我們可以得到概率和m、n之間的關係,最後的結果

(1/2)??2??,約等於0.6185?/?

以上我們就得出了最佳hash函式個數以及誤判率與mn之前的關係了。

下表是m與n比值在k個hash函式下面的誤判率

圖片

php+Redis實現的布隆過濾器

由於Redis實現了setbit和getbit操作,天然適合實現布隆過濾器,redis也有布隆過濾器外掛。這裡使用php+redis實現布隆過濾器。

首先定義一個hash函式集合類,這些hash函式不一定都用到,實際上32位hash值的用3個就可以了,具體的數量可以根據你的位序列總量和你需要存入的量決定,

上面已經給出最佳值。

class BloomFilterHash
{
    /**
    * 由Justin Sobel編寫的按位雜湊函式
    */
  public function JSHash($string, $len = null)
    {
        $hash = 1315423911;
        $len || $len = strlen($string);
        for ($i = 0; $i < $len; $i++) {
            $hash ^= (($hash << 5) + ord($string[$i]) + ($hash >> 2));
        }
        return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
    }

    /**
    * 該雜湊演算法基於AT&T貝爾實驗室的Peter J. Weinberger的工作。
    * Aho Sethi和Ulman編寫的“編譯器(原理,技術和工具)”一書建議使用採用此特定演算法中的雜湊方法的雜湊函式。
    */
  public function PJWHash($string, $len = null)
    {
        $bitsInUnsignedInt = 4 * 8; //(unsigned int)(sizeof(unsigned int)* 8);
          $threeQuarters = ($bitsInUnsignedInt * 3) / 4;
        $oneEighth = $bitsInUnsignedInt / 8;
        $highBits = 0xFFFFFFFF << (int)($bitsInUnsignedInt - $oneEighth);
        $hash = 0;
        $test = 0;
        $len || $len = strlen($string);
        for ($i = 0; $i < $len; $i++) {
            $hash = ($hash << (int)($oneEighth)) + ord($string[$i]);
        }
        $test = $hash & $highBits;
        if ($test != 0) {
            $hash = (($hash ^ ($test >> (int)($threeQuarters))) & (~$highBits));
        }
        return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
    }

    /**
    * 類似於PJW Hash功能,但針對32位處理器進行了調整。它是基於UNIX的系統上的widley使用雜湊函式。
    */
  public function ELFHash($string, $len = null)
    {
        $hash = 0;
        $len || $len = strlen($string);
        for ($i = 0; $i < $len; $i++) {
            $hash = ($hash << 4) + ord($string[$i]);
            $x = $hash & 0xF0000000;
            if ($x != 0) {
                $hash ^= ($x >> 24);
            }
            $hash &= ~$x;
        }
        return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
    }

    /**
    * 這個雜湊函式來自Brian Kernighan和Dennis Ritchie的書“The C Programming Language”。
    * 它是一個簡單的雜湊函式,使用一組奇怪的可能種子,它們都構成了31 .... 31 ... 31等模式,它似乎與DJB雜湊函式非常相似。
    */
  public function BKDRHash($string, $len = null)
    {
        $seed = 131;  # 31 131 1313 13131 131313 etc..
  $hash = 0;
        $len || $len = strlen($string);
        for ($i = 0; $i < $len; $i++) {
            $hash = (int)(($hash * $seed) + ord($string[$i]));
        }
        return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
    }

    /**
    * 這是在開源SDBM專案中使用的首選演算法。
    * 雜湊函式似乎對許多不同的資料集具有良好的總體分佈。它似乎適用於資料集中元素的MSB存在高差異的情況。
    */
  public function SDBMHash($string, $len = null)
    {
        $hash = 0;
        $len || $len = strlen($string);
        for ($i = 0; $i < $len; $i++) {
            $hash = (int)(ord($string[$i]) + ($hash << 6) + ($hash << 16) - $hash);
        }
        return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
    }

    /**
    * 由Daniel J. Bernstein教授製作的演算法,首先在usenet新聞組comp.lang.c上向世界展示。
    * 它是有史以來發布的最有效的雜湊函式之一
    */
  public function DJBHash($string, $len = null)
    {
        $hash = 5381;
        $len || $len = strlen($string);
        for ($i = 0; $i < $len; $i++) {
            $hash = (int)(($hash << 5) + $hash) + ord($string[$i]);
        }
        return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
    }

    /**
     * Donald E. Knuth在“計算機程式設計藝術第3卷”中提出的演算法,主題是排序和搜尋第6.4章。
     */
  public function DEKHash($string, $len = null)
    {
        $len || $len = strlen($string);
        $hash = $len;
        for ($i = 0; $i < $len; $i++) {
            $hash = (($hash << 5) ^ ($hash >> 27)) ^ ord($string[$i]);
        }
        return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
    }

    /**
    * 參考 http://www.isthe.com/chongo/tech/comp/fnv/
    */  public function FNVHash($string, $len = null)
    {
        $prime = 16777619; //32位的prime 2^24 + 2^8 + 0x93 = 16777619
  $hash = 2166136261; //32位的offset
  $len || $len = strlen($string);
        for ($i = 0; $i < $len; $i++) {
            $hash = (int)($hash * $prime) % 0xFFFFFFFF;
            $hash ^= ord($string[$i]);
        }
        return ($hash % 0xFFFFFFFF) & 0xFFFFFFFF;
    }
}

接著就是連線redis來進行操作

/**
 * 使用redis實現的布隆過濾器
 */
abstract class BloomFilterRedis
{
    /**
     * 需要使用一個方法來定義bucket的名字
     */
    protected $bucket;

    protected $hashFunction;

    public function __construct($config, $id)
    {
        if (!$this->bucket || !$this->hashFunction) {
            throw new Exception("需要定義bucket和hashFunction", 1);
        }
        $this->Hash = new BloomFilterHash;
        $this->Redis = new YourRedis; //假設這裡你已經連線好了
    }

    /**
     * 新增到集合中
     */
    public function add($string)
    {
        $pipe = $this->Redis->multi();
        foreach ($this->hashFunction as $function) {
            $hash = $this->Hash->$function($string);
            $pipe->setBit($this->bucket, $hash, 1);
        }
        return $pipe->exec();
    }

    /**
     * 查詢是否存在, 存在的一定會存在, 不存在有一定機率會誤判
     */
    public function exists($string)
    {
        $pipe = $this->Redis->multi();
        $len = strlen($string);
        foreach ($this->hashFunction as $function) {
            $hash = $this->Hash->$function($string, $len);
            $pipe = $pipe->getBit($this->bucket, $hash);
        }
        $res = $pipe->exec();
        foreach ($res as $bit) {
            if ($bit == 0) {
                return false;
            }
        }
        return true;
    }
}

上面定義的是一個抽象類,如果要使用,可以根據具體的業務來使用。比如下面是一個過濾重複內容的過濾器。

/**
 * 重複內容過濾器
 * 該布隆過濾器總位數為2^32位, 判斷條數為2^30條. hash函式最優為3個.(能夠容忍最多的hash函式個數)
 * 使用的三個hash函式為
 * BKDR, SDBM, JSHash
 *
 * 注意, 在儲存的資料量到2^30條時候, 誤判率會急劇增加, 因此需要定時判斷過濾器中的位為1的的數量是否超過50%, 超過則需要清空.
 */
class FilteRepeatedComments extends BloomFilterRedis
{
    /**
     * 表示判斷重複內容的過濾器
     * @var string
     */
    protected $bucket = 'rptc';

    protected $hashFunction = array('BKDRHash', 'SDBMHash', 'JSHash');
}
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章