使用 HyperLogLog 數(shù)據(jù)結構來進行估數(shù),它非常有價值,可以解決很多精確度不高的統(tǒng)計需求。
但是如果我們想知道某一個值是不是已經(jīng)在 HyperLogLog 結構里面了,它就無能為力了,它只提供了 pfadd 和 pfcount 方法,沒有提供 pfcontains 這種方法。
講個使用場景,比如我們在使用新聞客戶端看新聞時,它會給我們不停地推薦新的內容,它每次推薦時要去重,去掉那些已經(jīng)看過的內容。問題來了,新聞客戶端推薦系統(tǒng)如何實現(xiàn)推送去重的?
你會想到服務器記錄了用戶看過的所有歷史記錄,當推薦系統(tǒng)推薦新聞時會從每個用戶的歷史記錄里進行篩選,過濾掉那些已經(jīng)存在的記錄。問題是當用戶量很大,每個用戶看過的新聞又很多的情況下,這種方式,推薦系統(tǒng)的去重工作在性能上跟的上么?

實際上,如果歷史記錄存儲在關系數(shù)據(jù)庫里,去重就需要頻繁地對數(shù)據(jù)庫進行 exists 查詢,當系統(tǒng)并發(fā)量很高時,數(shù)據(jù)庫是很難扛住壓力的。
你可能又想到了緩存,但是如此多的歷史記錄全部緩存起來,那得浪費多大存儲空間???而且這個存儲空間是隨著時間線性增長,你撐得住一個月,你能撐得住幾年么?但是不緩存的話,性能又跟不上,這該怎么辦?
這時,布隆過濾器 (Bloom Filter) 閃亮登場了,它就是專門用來解決這種去重問題的。它在起到去重的同時,在空間上還能節(jié)省 90% 以上,只是稍微有那么點不精確,也就是有一定的誤判概率。
布隆過濾器
布隆過濾器可以理解為一個不怎么精確的 set 結構,當你使用它的 contains 方法判斷某個對象是否存在時,它可能會誤判。但是布隆過濾器也不是特別不精確,只要參數(shù)設置的合理,它的精確度可以控制的相對足夠精確,只會有小小的誤判概率。
當布隆過濾器說某個值存在時,這個值可能不存在;當它說不存在時,那就肯定不存在。打個比方,當它說不認識你時,肯定就不認識;當它說見過你時,可能根本就沒見過面,不過因為你的臉跟它認識的人中某臉比較相似 (某些熟臉的系數(shù)組合),所以誤判以前見過你。
Redis 中的布隆過濾器
Redis 官方提供的布隆過濾器到了 Redis 4.0 提供了插件功能之后才正式登場。布隆過濾器作為一個插件加載到 Redis Server 中,給 Redis 提供了強大的布隆去重功能。
下面我們來體驗一下 Redis 4.0 的布隆過濾器,為了省去繁瑣安裝過程,我們直接用 Docker 吧。
> docker pull redislabs/rebloom # 拉取鏡像
> docker run -p6379:6379 redislabs/rebloom # 運行容器
> redis-cli # 連接容器中的 redis 服務
布隆過濾器基本使用
布隆過濾器有二個基本指令,bf.add 添加元素,bf.exists 查詢元素是否存在,它的用法和 set 集合的 sadd 和 sismember 差不多。注意 bf.add 只能一次添加一個元素,如果想要一次添加多個,就需要用到 bf.madd 指令。同樣如果需要一次查詢多個元素是否存在,就需要用到 bf.mexists 指令。
127.0.0.1:6379> bf.add codehole user1
(integer) 1
127.0.0.1:6379> bf.add codehole user2
(integer) 1
127.0.0.1:6379> bf.add codehole user3
(integer) 1
127.0.0.1:6379> bf.exists codehole user1
(integer) 1
127.0.0.1:6379> bf.exists codehole user2
(integer) 1
127.0.0.1:6379> bf.exists codehole user3
(integer) 1
127.0.0.1:6379> bf.exists codehole user4
(integer) 0
127.0.0.1:6379> bf.madd codehole user4 user5 user6
1) (integer) 1
2) (integer) 1
3) (integer) 1
127.0.0.1:6379> bf.mexists codehole user4 user5 user6 user7
1) (integer) 1
2) (integer) 1
3) (integer) 1
4) (integer) 0
Java 客戶端 Jedis-2.x 沒有提供指令擴展機制,所以你無法直接使用 Jedis 來訪問 Redis Module 提供的 bf.xxx 指令。RedisLabs 提供了一個單獨的包 JReBloom,但是它是基于 Jedis-3.0,Jedis-3.0 這個包目前還沒有進入 release,沒有進入 maven 的中央倉庫,需要在 Github 上下載。在使用上很不方便,如果怕麻煩,還可以使用 lettuce,它是另一個 Redis 的客戶端,相比 Jedis 而言,它很早就支持了指令擴展。
public class BloomTest {
public static void main(String[] args) {
Client client = new Client();
client.delete("codehole");
for (int i = 0; i < 100000; i++) {
client.add("codehole", "user" + i);
boolean ret = client.exists("codehole", "user" + i);
if (!ret) {
System.out.println(i);
break;
}
}
client.close();
}
}
執(zhí)行上面的代碼后,你會張大了嘴巴發(fā)現(xiàn)居然沒有輸出,塞進去了 100000 個元素,還是沒有誤判,這是怎么回事?如果你不死心的話,可以將數(shù)字再加一個 0 試試,你會發(fā)現(xiàn)依然沒有誤判。
原因就在于布隆過濾器對于已經(jīng)見過的元素肯定不會誤判,它只會誤判那些沒見過的元素。所以我們要稍微改一下上面的腳本,使用 bf.exists 去查找沒見過的元素,看看它是不是以為自己見過了。
public class BloomTest {
public static void main(String[] args) {
Client client = new Client();
client.delete("codehole");
for (int i = 0; i < 100000; i++) {
client.add("codehole", "user" + i);
boolean ret = client.exists("codehole", "user" + (i + 1));
if (ret) {
System.out.println(i);
break;
}
}
client.close();
}
}
運行后,我們看到了輸出是 214,也就是到第 214 的時候,它出現(xiàn)了誤判。
那如何來測量誤判率呢?我們先隨機出一堆字符串,然后切分為 2 組,將其中一組塞入布隆過濾器,然后再判斷另外一組的字符串存在與否,取誤判的個數(shù)和字符串總量一半的百分比作為誤判率。
public class BloomTest {
private String chars;
{
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 26; i++) {
builder.append((char) ('a' + i));
}
chars = builder.toString();
}
private String randomString(int n) {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < n; i++) {
int idx = ThreadLocalRandom.current().nextInt(chars.length());
builder.append(chars.charAt(idx));
}
return builder.toString();
}
private List<String> randomUsers(int n) {
List<String> users = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
users.add(randomString(64));
}
return users;
}
public static void main(String[] args) {
BloomTest bloomer = new BloomTest();
List<String> users = bloomer.randomUsers(100000);
List<String> usersTrain = users.subList(0, users.size() / 2);
List<String> usersTest = users.subList(users.size() / 2, users.size());
Client client = new Client();
client.delete("codehole");
for (String user : usersTrain) {
client.add("codehole", user);
}
int falses = 0;
for (String user : usersTest) {
boolean ret = client.exists("codehole", user);
if (ret) {
falses++;
}
}
System.out.printf("%d %d\n", falses, usersTest.size());
client.close();
}
}
運行一下,等待大約一分鐘,輸出:
total users 100000
all trained
628 50000
可以看到誤判率大約 1% 多點。你也許會問這個誤判率還是有點高啊,有沒有辦法降低一點?答案是有的。
我們上面使用的布隆過濾器只是默認參數(shù)的布隆過濾器,它在我們第一次 add 的時候自動創(chuàng)建。Redis 其實還提供了自定義參數(shù)的布隆過濾器,需要我們在 add 之前使用bf.reserve指令顯式創(chuàng)建。如果對應的 key 已經(jīng)存在,bf.reserve會報錯。bf.reserve有三個參數(shù),分別是 key, error_rate和initial_size。錯誤率越低,需要的空間越大。initial_size參數(shù)表示預計放入的元素數(shù)量,當實際數(shù)量超出這個數(shù)值時,誤判率會上升。
所以需要提前設置一個較大的數(shù)值避免超出導致誤判率升高。如果不使用 bf.reserve,默認的error_rate是 0.01,默認的initial_size是 100。
接下來我們使用 bf.reserve 改造一下上面的腳本:
public class BloomTest {
private String chars;
{
StringBuilder builder = new StringBuilder();
for (int i = 0; i < 26; i++) {
builder.append((char) ('a' + i));
}
chars = builder.toString();
}
private String randomString(int n) {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < n; i++) {
int idx = ThreadLocalRandom.current().nextInt(chars.length());
builder.append(chars.charAt(idx));
}
return builder.toString();
}
private List<String> randomUsers(int n) {
List<String> users = new ArrayList<>();
for (int i = 0; i < 100000; i++) {
users.add(randomString(64));
}
return users;
}
public static void main(String[] args) {
BloomTest bloomer = new BloomTest();
List<String> users = bloomer.randomUsers(100000);
List<String> usersTrain = users.subList(0, users.size() / 2);
List<String> usersTest = users.subList(users.size() / 2, users.size());
Client client = new Client();
client.delete("codehole");
// 對應 bf.reserve 指令
client.createFilter("codehole", 50000, 0.001);
for (String user : usersTrain) {
client.add("codehole", user);
}
int falses = 0;
for (String user : usersTest) {
boolean ret = client.exists("codehole", user);
if (ret) {
falses++;
}
}
System.out.printf("%d %d\n", falses, usersTest.size());
client.close();
}
}
運行一下,等待約 1 分鐘,輸出如下:
total users 100000
all trained
6 50000
我們看到了誤判率大約 0.012%,比預計的 0.1% 低很多,不過布隆的概率是有誤差的,只要不比預計誤判率高太多,都是正?,F(xiàn)象。
注意事項
布隆過濾器的initial_size估計的過大,會浪費存儲空間,估計的過小,就會影響準確率,用戶在使用之前一定要盡可能地精確估計好元素數(shù)量,還需要加上一定的冗余空間以避免實際元素可能會意外高出估計值很多。
布隆過濾器的error_rate越小,需要的存儲空間就越大,對于不需要過于精確的場合,error_rate設置稍大一點也無傷大雅。比如在新聞去重上而言,誤判率高一點只會讓小部分文章不能讓合適的人看到,文章的整體閱讀量不會因為這點誤判率就帶來巨大的改變。
布隆過濾器的原理
學會了布隆過濾器的使用,下面有必要把原理解釋一下,不然讀者還會繼續(xù)蒙在鼓里

每個布隆過濾器對應到 Redis 的數(shù)據(jù)結構里面就是一個大型的位數(shù)組和幾個不一樣的無偏 hash 函數(shù)。所謂無偏就是能夠把元素的 hash 值算得比較均勻。
向布隆過濾器中添加 key 時,會使用多個 hash 函數(shù)對 key 進行 hash 算得一個整數(shù)索引值然后對位數(shù)組長度進行取模運算得到一個位置,每個 hash 函數(shù)都會算得一個不同的位置。再把位數(shù)組的這幾個位置都置為 1 就完成了 add 操作。
向布隆過濾器詢問 key 是否存在時,跟 add 一樣,也會把 hash 的幾個位置都算出來,看看位數(shù)組中這幾個位置是否都為 1,只要有一個位為 0,那么說明布隆過濾器中這個 key 不存在。如果都是 1,這并不能說明這個 key 就一定存在,只是極有可能存在,因為這些位被置為 1 可能是因為其它的 key 存在所致。如果這個位數(shù)組比較稀疏,判斷正確的概率就會很大,如果這個位數(shù)組比較擁擠,判斷正確的概率就會降低。具體的概率計算公式比較復雜,感興趣可以閱讀擴展閱讀,非常燒腦,不建議讀者細看。
使用時不要讓實際元素遠大于初始化大小,當實際元素開始超出初始化大小時,應該對布隆過濾器進行重建,重新分配一個 size 更大的過濾器,再將所有的歷史元素批量 add 進去 (這就要求我們在其它的存儲器中記錄所有的歷史元素)。因為 error_rate 不會因為數(shù)量超出就急劇增加,這就給我們重建過濾器提供了較為寬松的時間。
布隆過濾器的其它應用
在爬蟲系統(tǒng)中,我們需要對 URL 進行去重,已經(jīng)爬過的網(wǎng)頁就可以不用爬了。但是 URL 太多了,幾千萬幾個億,如果用一個集合裝下這些 URL 地址那是非常浪費空間的。這時候就可以考慮使用布隆過濾器。它可以大幅降低去重存儲消耗,只不過也會使得爬蟲系統(tǒng)錯過少量的頁面。
布隆過濾器在 NoSQL 數(shù)據(jù)庫領域使用非常廣泛,我們平時用到的 HBase、Cassandra 還有 LevelDB、RocksDB 內部都有布隆過濾器結構,布隆過濾器可以顯著降低數(shù)據(jù)庫的 IO 請求數(shù)量。當用戶來查詢某個 row 時,可以先通過內存中的布隆過濾器過濾掉大量不存在的 row 請求,然后再去磁盤進行查詢。
郵箱系統(tǒng)的垃圾郵件過濾功能也普遍用到了布隆過濾器,因為用了這個過濾器,所以平時也會遇到某些正常的郵件被放進了垃圾郵件目錄中,這個就是誤判所致,概率很低。