如何用Redis統(tǒng)計海量UV?
前言
我們先思考一個常見的業(yè)務(wù)問題:如果你負(fù)責(zé)開發(fā)維護(hù)一個大型的網(wǎng)站,有一天老板找產(chǎn)品經(jīng)理要網(wǎng)站每個網(wǎng)頁每天的 UV 數(shù)據(jù),然后讓你來開發(fā)這個統(tǒng)計模塊,你會如何實現(xiàn)?
統(tǒng)計uv的常用方法以及優(yōu)缺點
其實要是單純的統(tǒng)計pv是比較好辦的,直接用redis的incr就行,但是uv的話,它要去重,同一個用戶一天之內(nèi)的多次訪問請求只能計數(shù)一次。這就要求每一個網(wǎng)頁請求都需要帶上用戶的 ID,無論是登陸用戶還是未登陸用戶都需要一個唯一 ID 來標(biāo)識。
set
比較容易想到的是為每一個頁面一個獨(dú)立的 set 集合來存儲所有當(dāng)天訪問過此頁面的用戶 ID。當(dāng)一個請求過來時,我們使用 sadd 將用戶 ID 塞進(jìn)去就可以了。通過 scard 可以取出這個集合的大小,這個數(shù)字就是這個頁面的 UV 數(shù)據(jù)。沒錯,這是一個非常簡單的方案。
但是,如果你的頁面訪問量非常大,比如一個爆款頁面幾千萬的 UV,你需要一個很大的 set 集合來統(tǒng)計,這就非常浪費(fèi)空間。如果這樣的頁面很多,那所需要的存儲空間是驚人的。
hash
hash和set在處理uv的問題上其實類似,把用戶id作為hash的key的確可以去重,但是如果訪問量大了之后也會消耗很大的內(nèi)存空間
bitmap
bitmap同樣是一種可以統(tǒng)計基數(shù)的方法,可以理解為用bit數(shù)組存儲元素,例如01101001,表示的是[1,2,4,8],bitmap中1的個數(shù)就是基數(shù)。bitmap也可以輕松合并多個集合,只需要將多個數(shù)組進(jìn)行異或操作就可以了。bitmap相比于Set,Hash也大大節(jié)省了內(nèi)存,我們來粗略計算一下,統(tǒng)計1億個數(shù)據(jù)的基數(shù),需要的內(nèi)存是:100000000/8/1024/1024 ≈ 12M。
雖然bitmap在節(jié)省空間方面已經(jīng)有了不錯的表現(xiàn),但是如果需要統(tǒng)計1000個對象,就需要大約12G的內(nèi)存,顯然這個結(jié)果仍然不能令我們滿意。在這種情況下,HyperLogLog將會出來拯救我們。
HyperLogLog
這就是本節(jié)要引入的一個解決方案,Redis 提供了 HyperLogLog 數(shù)據(jù)結(jié)構(gòu)就是用來解決這種統(tǒng)計問題的。HyperLogLog 提供不精確的去重計數(shù)方案,雖然不精確但是也不是非常不精確,標(biāo)準(zhǔn)誤差是 0.81%,這樣的精確度已經(jīng)可以滿足上面的 UV 統(tǒng)計需求了。
HyperLogLog 數(shù)據(jù)結(jié)構(gòu)是 Redis 的高級數(shù)據(jù)結(jié)構(gòu),它非常有用,但是令人感到意外的是,使用過它的人非常少。
使用方法
Redis 的位數(shù)組是自動擴(kuò)展,如果設(shè)置了某個偏移位置超出了現(xiàn)有的內(nèi)容范圍,就會自動將位數(shù)組進(jìn)行零擴(kuò)充。
命令
HyperLogLog 提供了兩個指令 pfadd 和 pfcount,根據(jù)字面意義很好理解,一個是增加計數(shù),一個是獲取計數(shù)。
具體實現(xiàn)
pfadd 用法和 set 集合的 sadd 是一樣的,來一個用戶 ID,就將用戶 ID 塞進(jìn)去就是。pfcount 和 scard 用法是一樣的,直接獲取計數(shù)值。關(guān)鍵是它非常省空間,載統(tǒng)計海量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
簡單試了一下,發(fā)現(xiàn)還蠻精確的,一個沒多也一個沒少。接下來我們使用腳本,往里面灌更多的數(shù)據(jù),看看它是否還可以繼續(xù)精確下去,如果不能精確,差距有多大。
我們將數(shù)據(jù)增加到 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 統(tǒng)計需求來說,誤差率也不算高。然后我們把上面的腳本再跑一邊,也就相當(dāng)于將數(shù)據(jù)重復(fù)加入一邊,查看輸出,可以發(fā)現(xiàn),pfcount 的結(jié)果沒有任何改變,還是 99723,說明它確實具備去重功能。