前言
我們先思考一個常見的業務問題:如果你負責開發維護一個大型的網站,有一天老闆找產品經理要網站每個網頁每天的 UV 資料,然後讓你來開發這個統計模組,你會如何實現?
統計uv的常用方法以及優缺點
其實要是單純的統計pv是比較好辦的,直接用redis的incr就行,但是uv的話,它要去重,同一個使用者一天之內的多次訪問請求只能計數一次。這就要求每一個網頁請求都需要帶上使用者的 ID,無論是登陸使用者還是未登陸使用者都需要一個唯一 ID 來標識。
set
比較容易想到的是為每一個頁面一個獨立的 set 集合來儲存所有當天訪問過此頁面的使用者 ID。當一個請求過來時,我們使用 sadd 將使用者 ID 塞進去就可以了。通過 scard 可以取出這個集合的大小,這個數字就是這個頁面的 UV 資料。沒錯,這是一個非常簡單的方案。
但是,如果你的頁面訪問量非常大,比如一個爆款頁面幾千萬的 UV,你需要一個很大的 set 集合來統計,這就非常浪費空間。如果這樣的頁面很多,那所需要的儲存空間是驚人的。
hash
hash和set在處理uv的問題上其實類似,把使用者id作為hash的key的確可以去重,但是如果訪問量大了之後也會消耗很大的記憶體空間
bitmap
bitmap同樣是一種可以統計基數的方法,可以理解為用bit陣列儲存元素,例如01101001,表示的是[1,2,4,8],bitmap中1的個數就是基數。bitmap也可以輕鬆合併多個集合,只需要將多個陣列進行異或操作就可以了。bitmap相比於Set,Hash也大大節省了記憶體,我們來粗略計算一下,統計1億個資料的基數,需要的記憶體是:100000000/8/1024/1024 ≈ 12M。
雖然bitmap在節省空間方面已經有了不錯的表現,但是如果需要統計1000個物件,就需要大約12G的記憶體,顯然這個結果仍然不能令我們滿意。在這種情況下,HyperLogLog將會出來拯救我們。
HyperLogLog
這就是本節要引入的一個解決方案,Redis 提供了 HyperLogLog 資料結構就是用來解決這種統計問題的。HyperLogLog 提供不精確的去重計數方案,雖然不精確但是也不是非常不精確,標準誤差是 0.81%,這樣的精確度已經可以滿足上面的 UV 統計需求了。
HyperLogLog 資料結構是 Redis 的高階資料結構,它非常有用,但是令人感到意外的是,使用過它的人非常少。
使用方法
Redis 的位陣列是自動擴充套件,如果設定了某個偏移位置超出了現有的內容範圍,就會自動將位陣列進行零擴充。
命令
HyperLogLog 提供了兩個指令 pfadd 和 pfcount,根據字面意義很好理解,一個是增加計數,一個是獲取計數。
具體實現
pfadd 用法和 set 集合的 sadd 是一樣的,來一個使用者 ID,就將使用者 ID 塞進去就是。pfcount 和 scard 用法是一樣的,直接獲取計數值。關鍵是它非常省空間,載統計海量uv的時候,只佔用了12k的空間
127.0.0.1:6379> pfadd codehole user1
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 1
127.0.0.1:6379> pfadd codehole user2
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 2
127.0.0.1:6379> pfadd codehole user3
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 3
127.0.0.1:6379> pfadd codehole user4
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 4
127.0.0.1:6379> pfadd codehole user5
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 5
127.0.0.1:6379> pfadd codehole user6
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 6
127.0.0.1:6379> pfadd codehole user7 user8 user9 user10
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 10
簡單試了一下,發現還蠻精確的,一個沒多也一個沒少。接下來我們使用指令碼,往裡面灌更多的資料,看看它是否還可以繼續精確下去,如果不能精確,差距有多大。
我們將資料增加到 10w 個,看看總量差距有多大。
public class JedisTest {
public static void main(String[] args) {
Jedis jedis = new Jedis();
for (int i = 0; i < 100000; i++) {
jedis.pfadd("codehole", "user" + i);
}
long total = jedis.pfcount("codehole");
System.out.printf("%d %d\n", 100000, total);
jedis.close();
}
}
跑了約半分鐘,我們看輸出:
> python pftest.py
100000 99723
差了 277 個,按百分比是 0.277%,對於上面的 UV 統計需求來說,誤差率也不算高。然後我們把上面的指令碼再跑一邊,也就相當於將資料重複加入一邊,檢視輸出,可以發現,pfcount 的結果沒有任何改變,還是 99723,說明它確實具備去重功能。