布隆過濾器
極簡概括
英文名稱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種解決方案:
- 透過異常行為做攔截:搶購一般會讓使用者登入, 讓伺服器知道誰在搶購,如果單位時間內Redis被穿透的次數過多,直接封禁。思路:上游新增
Redis::get(get flash_sale . 使用者id) > 10
的判斷,如果要查資料庫,redis就Redis::incr(flash_sale . 使用者id)
,直到超過指定次數,然後上游直接攔截。 - 如果沒有查到資料,也在快取中儲存,值為1即可,但是這有2個弊端,1是快取過後可能用不上,2是大量的快取,也會增加儲存的負擔。
- 使用布隆過濾器:上文有講,把他放到業務邏輯的上游做判斷,如果過濾器中存在,則走下游流程,如果不存在,則直接阻斷其後續流程。
- 透過異常行為做攔截:搶購一般會讓使用者登入, 讓伺服器知道誰在搶購,如果單位時間內Redis被穿透的次數過多,直接封禁。思路:上游新增
如何減少布隆過濾器誤判的機率
- 增加雜湊函式的數量:1個雜湊函式演算法可能會衝突,多個與源資料進行雜湊演算法,就能減少衝突的發生。
- 增加布隆過濾器的長度:例如10個位元槽位,存1000條資料,大機率有衝突的情況,但是將長度增加到2000,這就能減少衝突機率的發生。
搶購場景商品被刪除了,如何同步到布隆過濾器
先看預設場景,看看要不要做處理:商品被刪除,MySQL無資料,布隆過濾器有資料。
- 如果redis快取無商品資料:
透過布隆過濾器->快取無資料->查資料庫->無資料->返回商品資訊不存在。 - 如果redis快取仍舊有商品資訊:
透過布隆過濾器->快取有->其它下單流程。
此時會發現,如果redis快取仍舊有商品資訊,還會有問題,解決方案:
- 可以非同步重建布隆過濾器:這和定期重新整理快取一個道理,防止快取的長期不一致。
- 維護一個計數布隆過濾器:表示該位置被幾個資料進行了引用,如果是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種值,但是宇宙空間裡的資訊量卻無窮的多,輸入資料無窮多但輸出資料有限,這就導致雜湊碰撞的產生。
演算法生成的資訊越長,碰撞機率越低,這是個機率問題,不是一定發生或一定不發生。