深入理解PHP+Redis實現布隆過濾器(億級大資料處理和駭客攻防必備)

小松聊PHP进阶發表於2024-04-08

布隆過濾器

極簡概括

英文名稱Bloom Filter,用於判斷一個元素是否在一個大資料集合中,如果檢測到存在則有可能存在,如果不存在則一定不存在。
Redis官網對於布隆過濾器的說明:https://redis.io/docs/data-types/probabilistic/bloom-filter/

使用場景

  • 防止快取穿透:用於快速判斷某個商品資料是否存在於快取中,如果存在,則執行下游流程,如果不存在,在此處直接攔截,避免下游流程引發的算力消耗,很適合對抗駭客刷介面的行為。
  • 網路爬蟲:在爬取網頁時,可以用布隆過濾器來判斷一個 URL 是否已經被訪問過,從而避免重複爬取相同的頁面。
  • 拼寫檢查器:用於快速檢查一個單詞是否是正確拼寫的,減少不必要的詞典查詢。
  • 使用者名稱防重:註冊或者更改某些應用的使用者名稱時,提示使用者名稱已存在,再超大資料量的情況下,可以用布隆過濾器替代MySQL去抗。
  • 整體來說,適用於讀多寫少的場景,如果是寫多讀少,不推薦用布隆過濾器。

優點

  • 節省空間:用位元位來儲存,比用一個位元組儲存0和1更加節省空間。
  • 插入查詢迅速:布隆過濾器內部使用位陣列(bit array)來表示資料儲存狀態,這種結構使得在查詢時只需對位陣列進行一系列簡單的位操作,而不需要遍歷。
  • 保密性很好:Redis BitMap裡儲存的都是0和1,就算駭客拿到了資料,缺少程式碼邏輯,也根本不知道是幹啥的。

缺點

  • 不支援刪除:強制沒問題,不支援刪除是為了防止雜湊碰撞引起的誤刪問題。一個雜湊值,可能是多個源資料的轉換後的結果。
  • 誤判:本來一個資料不存在與這個集合當中,但是它判斷資料時存在這個集合當中,這是由雜湊碰撞產生的,這種無法避免,只能減少誤判的機率。 所以布隆過濾器有個特點:不存在一定不存在,存在卻不一定存在。
  • 避免:可以新增多個不同的的hash函式演算法和bit位的長度,從而降低誤判的發生,會降低效能,這需要在效能和誤判之間做好權衡。

時間複雜度

布隆過濾器的查詢時間複雜度是常數(很快)級別的,通常記為 O(k),其中 k 是雜湊函式的個數,也是布隆過濾器中每個元素對應的位陣列位置數量。

原理

  • 儲存:判斷一個元素是否在一個大資料集合中,存在就是1不存在就是0,這個集合可以由一個二進位制向量(向量 === 向量,有大小有方向)表示(二進位制向量,可以類比成一個陣列,但是每個單位只能儲存1位元的資料),用Redis的BitMap結構儲存最合適。
  • 雜湊轉換:一般會有多個雜湊演算法,為了減少雜湊衝突的機率。
  • 長度選擇:布隆過濾器雜湊位的大小,要看業務場景所使用資料的上限。
  • 判斷規則:例如4條資料儲存,有3個雜湊演算法,也就佔用12個bit。判斷任意一個資料是否存在,也就在bitmap上判斷3個值,如果這3個值全部都在,則表示可能存在這個資料,如果小於3,則表示資料不存在。

用PHP寫Redis布隆過濾器擴充套件

我用CRC32演算法作為雜湊運算寫了一個,儲存是用Redis的BitMap,演算法數量設定到3,誤差率達到了25.69%,設定到10,誤差率仍有1.08%,效能降下來了,難怪網上有人評論不要手動實現布隆過濾器。受一些演算法限制,寫不了太好的方案。
同時在網上尋找更好的方案,發現網上的一些雜湊演算法報錯,所以也不用。
尋找composer包,目前也沒有找到太好的視線布隆過濾器的方案,不是不支援Redis,就是版本太舊,不支援PHP8。
所以需要考慮其它方向的解決方案。

/**
 * @ 封裝一個布隆過濾器類,切勿商用,否則要出事
 */
class BloomFilter {
    //redis物件
    private $redis;
    //布隆過濾器名稱
    private $bloom_filter_name;
    //位元數量
    private $bit_num;
    //函式數量
    private $func_num;


    /**
     * @function 初始化成員屬性
     * @param    $redis             object redis物件
     * @param    $bloom_filter_name string 布隆過濾器名稱
     * @param    $bit_num           int    位元數量
     * @param    $func_num          int    雜湊函式數量
     * @return   void
     */
    public function __construct($redis, $bloom_filter_name, $bit_num, $func_num) {
        $this->redis             = $redis;
        $this->bloom_filter_name = $bloom_filter_name;
        $this->bit_num           = $bit_num;
        $this->func_num          = $func_num;
    }


    /**
     * @function 向布隆過濾器中新增資料
     * @param    $val string 待新增的值
     * @return   array
     */
    public function add($val) {
        //開啟管道,方便批次操作
        $pipe = $this->redis->multi();

        //模擬多個雜湊演算法
        for ($i = 0; $i < $this->func_num; $i++) {
            $hash = $this->hash($val . '_' . $i);
            $pipe->setbit($this->bloom_filter_name, $hash, 1);
        }

        return $pipe->exec();
    }


    /**
     * @function 布隆過濾器判斷某個值是否存在
     * @param    $val string 待新增的值
     * @return   bool
     */
    public function exists($val) {
        //開啟管道,方便批次操作
        $pipe = $this->redis->multi();

        for ($i = 0; $i < $this->func_num; $i++) {
            $hash = $this->hash($val . '_' . $i);
            $pipe->getbit($this->bloom_filter_name, $hash);
        }

        //批處理
        $results = $pipe->exec();
        foreach ($results as $bit) {
            if ($bit == 0) {
                return false;
            }
        }

        return true;
    }


    /**
     * @function 透過一些CRC32雜湊演算法,獲取指定值的BitMap儲存位置
     * @param    $string string 待計算雜湊的資料
     * @return   int
     */
    private function hash($string) {
        //因為crc32演算法獲取的是9~10位數字,方便取模
        return crc32($string) % $this->bit_num;
    }
}

//----------------------------------------------------------------------------------------------------
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$bloom_filter = new BloomFilter($redis, "test_key",10000, 3);

$bloom_filter->add('xyz');

var_dump($bloom_filter->exists('xyz')); //true
var_dump($bloom_filter->exists('abc')); //false

用C語言實現Redis布隆過濾器擴充套件(服務端)

這種方式不需要考慮對Redis點陣圖的操作,而是直接呼叫Redis Bloom Filter的功能,所以實現思路與上文說明有所不同。

Redis擴充套件安裝官方擴充套件:https://redis.io/docs/data-types/probabilistic/configuration
Redis Bloom Filter地址:https://github.com/RedisBloom/RedisBloom

目前的最新版本是2.6.12,但是編譯報錯,用2.2.18就好了
wget https://github.com/RedisBloom/RedisBloom/archive/refs/tags/v2.2.18.tar.gz
tar zxvf v2.2.18.tar.gz
cd RedisBloom-2.2.18
make
此時會生成一個redisbloom.so
mkdir /usr/local/redis/ext/
mv redisbloom.so /usr/local/redis/ext/redis_bloom_filter.so
vim /usr/local/redis/etc/redis.conf中新增如下一行
loadmodule /usr/local/redis/ext/redis_bloom_filter.so
儲存
重啟Redis,我這裡設定了Redis服務
service redis restart
檢視是否啟動
> ps aux | grep redis
root      14504  0.7  0.6  52432  6580 ?        Ssl  23:17   0:00 /usr/local/redis/bin/redis-server 0.0.0.0:6379
root      14549  0.0  0.1 112828   996 pts/0    S+   23:17   0:00 grep --color=auto redis

進入redis命令列,使用命令檢視擴充套件是否安裝成功
redis-cli
bf.exists bloom_filter_key test_val
返回0表示安裝成功

常見指令:

指令 含義 示例 備註
BF.RESERVE 配置多少數量下有多大誤差,誤差越小效能越差 BF.RESERVE 布名 0.001 1000000 100萬條資料允許0.1%的誤差
BF.ADD 向某個布隆過濾器中新增資料 BF.ADD 布名 值1 返回1證明插入成功
BF.EXISTS 判斷某個布隆過濾是否存在一個值 BF.EXISTS 布名 值1 返回1說明存在,返回0說明不存在
BF.MADD 向某個布隆過濾器中插入多個值 BF.MADD 布名 值2 值3 值4 返回
1) (integer) 1
2) (integer) 1
3) (integer) 1
BF.MEXISTS 判斷某個布隆過濾是否存在多個值 BF.MEXISTS 布名 值2 值3 值5 返回
1) (integer) 1
2) (integer) 1
3) (integer) 0

注意:使用這個外掛,值是MBbloom--型別的,而不是點陣圖或者其它型別。
自己封裝了一個類,如下:

class BloomFilter {
    //存放redis物件
    private $redis;


    /**
     * @function 初始化Redis物件
     * @param    $bloom_filter_name string 布隆過濾器名稱
     * @param    $num               int 要儲存多少資料
     * @param    $error_percentage  float 誤差百分比
     * @return   void
     */
    public function __construct($bloom_filter_name, $num, $error_percentage) {
        $redis = new Redis();
        $redis->connect('192.168.0.180', 6379);
        $redis->rawCommand('BF.RESERVE', $bloom_filter_name, bcdiv($error_percentage, 100, 8), $num);
        $this->redis = $redis;
    }


    /**
     * @function 向布隆過濾器中新增資料
     * @param    $bloom_filter_name  string 布隆過濾器名稱
     * @param    $val                string 要新增的資料
     * @return   int
     */
    public function add($bloom_filter_name, $val) {
        return $this->redis->rawCommand('BF.ADD', $bloom_filter_name, $val);
    }


    /**
     * @function 布隆過濾器判斷指定的值是否存在
     * @param    $bloom_filter_name  string 布隆過濾器名稱
     * @param    $val                string 要新增的資料
     * @return   int
     */
    public function exists($bloom_filter_name, $val) {
        return $this->redis->rawCommand('BF.EXISTS', $bloom_filter_name, $val);
    }


    /**
     * @function 向布隆過濾器中批次新增資料
     * @param    $bloom_filter_name  string 布隆過濾器名稱
     * @param    $vals               array  要新增的資料
     * @return   int
     */
    public function mAdd($bloom_filter_name, $vals) {
        $args = array_merge(['BF.MADD'], [$bloom_filter_name], $vals);
        return $this->redis->rawCommand(...$args);
    }


    /**
     * @function 布隆過濾器批次判斷指定的值是否存在
     * @param    $bloom_filter_name  string 布隆過濾器名稱
     * @param    $vals               array  要新增的資料
     * @return   array
     */
    public function mExists($bloom_filter_name, $vals) {
        $args = array_merge(['BF.MEXISTS'], [$bloom_filter_name], $vals);
        return $this->redis->rawCommand(...$args);
    }
}

//呼叫-----------------------------------
$bloom_filter = new BloomFilter('key',100000, 0.01);

//1 重複插入返回0
var_dump($bloom_filter->add('key', 'v100'));
//[1, 1, 1]
var_dump($bloom_filter->mAdd('key', ['v2', 'v3', 'v4']));
//1
var_dump($bloom_filter->exists('key', 'v100'));
//[1, 1, 0]
var_dump($bloom_filter->mExists('key', ['v2', 'v3', 'v5']));

Redis布隆過濾器效能與誤差實測

看起來這個效能很高,足以應對99%的場景了,使用MySQL測試一億條資料中定值查詢1條資料,需要278.71秒的搜尋時間。
對於與布隆過濾器,在一億條資料下進行一億次查詢:

動作 數量 配置誤差值(百分比) 實測誤差數量 消耗時間(秒)
mAdd,批次寫入操作 100000000 0.01% / 357.068
mExists,批次讀取操作 100000000 0.01% 5103 306.646
批次寫入示例程式碼:
$bloom_filter = new BloomFilter('test_key',100000000, 0.01);

$start = microtime(true);
for($i = 0; $i < 100000000; $i++) {
    $arr[] = $i;
    if(count($arr) > 100000) {
        $bloom_filter->mAdd('test_key', $arr);
        $arr = [];
    }
}
echo microtime(true) - $start;


批次讀取示例程式碼:
$bloom_filter = new BloomFilter('test_key',100000000, 0.01);

$arr = [];
$int = 0;

$start = microtime(true);
for($i = 0; $i < 100000000; $i++) {
    $arr[] = uniqid();
    if(count($arr) > 100000) {
        $exists = $bloom_filter->mExists('test_key', $arr);
        $arr = [];
        foreach($exists as $v) {
            if($v) {
                $int++;
            }
        }
    }
}
echo microtime(true) - $start;

echo "--";
echo $int;

常見問題

為什麼布隆過濾器不用遍歷每條資料

在海量的資料下遍歷會很耗時,因此不能用遍歷,定址過程可以理解為PHP的陣列利用下標方式去找到對應的值,檢視是0還是1,相當於於array[key] = 1,PHP陣列的底層實現是雜湊表,透過陣列的下標,可以直接找到對應的記憶體地址,所以時間複雜度是O(1),而不是O(n)。

使用布隆過濾器防止快取穿透

  • 快取穿透:常見於搶購場景的黃牛,他們為了牟利,利用指令碼不斷拼接新引數去頻繁請求介面。從伺服器角度來說,如果Redis記憶體不存在,就會往資料庫中查,大量查詢任務直接穿透了Redis,壓力打到了資料庫,就會給資料庫帶來很大的壓力。
  • 就有3種解決方案:
    1. 透過異常行為做攔截:搶購一般會讓使用者登入, 讓伺服器知道誰在搶購,如果單位時間內Redis被穿透的次數過多,直接封禁。思路:上游新增Redis::get(get flash_sale . 使用者id) > 10的判斷,如果要查資料庫,redis就Redis::incr(flash_sale . 使用者id),直到超過指定次數,然後上游直接攔截。
    2. 如果沒有查到資料,也在快取中儲存,值為1即可,但是這有2個弊端,1是快取過後可能用不上,2是大量的快取,也會增加儲存的負擔。
    3. 使用布隆過濾器:上文有講,把他放到業務邏輯的上游做判斷,如果過濾器中存在,則走下游流程,如果不存在,則直接阻斷其後續流程。

如何減少布隆過濾器誤判的機率

  • 增加雜湊函式的數量:1個雜湊函式演算法可能會衝突,多個與源資料進行雜湊演算法,就能減少衝突的發生。
  • 增加布隆過濾器的長度:例如10個位元槽位,存1000條資料,大機率有衝突的情況,但是將長度增加到2000,這就能減少衝突機率的發生。

搶購場景商品被刪除了,如何同步到布隆過濾器

先看預設場景,看看要不要做處理:商品被刪除,MySQL無資料,布隆過濾器有資料。

  • 如果redis快取無商品資料:
    透過布隆過濾器->快取無資料->查資料庫->無資料->返回商品資訊不存在。
  • 如果redis快取仍舊有商品資訊:
    透過布隆過濾器->快取有->其它下單流程。

此時會發現,如果redis快取仍舊有商品資訊,還會有問題,解決方案:

  1. 可以非同步重建布隆過濾器:這和定期重新整理快取一個道理,防止快取的長期不一致。
  2. 維護一個計數布隆過濾器:表示該位置被幾個資料進行了引用,如果是1,直接刪了就行。這個可以用hash去實現hIncrBy(bloom_filter_flash_sale, 取模後的數字雜湊位置, 1),如果大於1,可以去查詢資料庫,做個兜底的判斷策略。

50億量級的URL集合,如何再4G的記憶體中判斷其中一個url是否在這個集合當中

這個面試題老生常談,所以在這裡著重提一嘴。
會出現在搜尋引擎的爬蟲判斷中,爬蟲爬過了,就不在重複爬取了,
可以用布隆過濾器。
4G = 34359738368Bit,340億的位元,如果設定3種雜湊演算法,也就意味著佔用150億個位元位,還是能存的下。

布隆過濾器為什麼用雜湊函式而不是源資料

1.節省空間,2.增加查詢速度。
源資料可能偏大,透過雜湊函式轉換後,結果成數字,這個數字就是位元陣列的下標。布隆過濾器可以近似的理解為維護的是一個所有值都為數字的PHP索引陣列,但是陣列的佔用單位是位元組,而布隆過濾器可以使用更小的位元,充分利用裝置儲存資源。

為什麼不推薦自定義寫布隆過濾器

演算法:普通開發者缺少演算法思維,做出來的布隆過濾器機率不可控,或者容易衝突。為了防止雜湊函式的值轉化為數字後位數過長(例如md5(1) 為c4ca4238a0b923820dcc509a6f75849b,轉10進位制是261578874264819908609102035485573088411),需要對資料長度進行取模,不取模還好,取模後極大減少了布隆過濾器的長度。例如10000條資料,設定3種雜湊演算法,設定3萬個位元位,取模後的值大多小於3萬,所以衝突的機率增加了很多。
理解深度:可能一部分開發者不知道Redis點陣圖,或者為什麼用雜湊函式,還挺停留在用Redis string做判斷的基礎上,雖然能實現,但是佔用空間有很大差距。

補充

雜湊運算

  • 極簡概括:雜湊運算是透過一些演算法,將任意長度的任意格式的二進位制資料轉換為固定長度資料的過程。
  • 演算法舉例:sha1、sha256、sha512、md5。
  • 演算法特點:
    • 結果確定:給定相同的輸入,雜湊函式會產生相同的輸出。
    • 長度確定:無論輸入的大小如何,輸出的長度是固定的。
    • 計算迅速:好的雜湊函式可在合理的時間內對輸入進行雜湊計算。
    • 不可逆向:給一個雜湊過後的值,由於初始資料資訊丟失,無法透過這個值還原初始的資訊。彩虹表並不是還原,而是對比。
    • 修改敏感:修改資料的任何一個地方,就算很小的改動,也會導致算出來的雜湊值完全不同。

雜湊碰撞

不同的輸入資料,經過雜湊計算後得到相同的輸出值。
例如32位輸出的md5演算法,一共有1632 ≈ 3.4 ×1038種值,但是宇宙空間裡的資訊量卻無窮的多,輸入資料無窮多但輸出資料有限,這就導致雜湊碰撞的產生。
演算法生成的資訊越長,碰撞機率越低,這是個機率問題,不是一定發生或一定不發生。

相關文章