Redis實戰篇(三)基於HyperLogLog實現UV統計功能
如果現在要開發一個功能:
統計APP或網頁的一個頁面,每天有多少用戶點擊進入的次數。同一個用戶的反覆點擊進入記為 1 次,也就是統計 UV 數據。
讓你來開發這個統計模塊,你會如何實現?
如果統計 PV 數據,只要給網頁一個獨立的 Redis 計數器就可以了,這個計數器的 key 的格式為 puv:{pid}:{yyyyMMdd}
。每來一個請求就 incrby 一次,就可以統計出所有的 PV 數據。
但是 UV 不一樣,它要去重,同一個用戶一天之內的多次訪問請求只能計數一次。這就要求每一個網頁請求都需要帶上用戶的 ID,無論是登陸用戶還是未登陸用戶都需要一個唯一 ID 來標識。
你可能會馬上想到,用 Hash
數據類型就能滿足去重。這確實是一種解決方法,但是當這個頁面的日活達到百萬或千萬以上級別的話,Hash
的內存開銷就會非常大。
我們來估算一下採用 Hash
的內存空間是多大。假設 key
是 int
類型,對應的是用戶ID,value
是 bool
類型,表示已訪問,當有百萬級不同用戶訪問時,內存空間為:100萬 * (32+8)bit = 40MB
。
那有更好的方法嗎?有的,下面來介紹基於 HyperLogLog
的解決方案。首先我們先來了解一下 HyperLogLog
。
HyperLogLog
HyperLogLog
的作用是提供不精確的去重計數方案。雖然不精確,但也不是非常不精確,標準誤差是 0.81%
,這樣的精確度已經可以滿足上面的 UV 統計需求了。
它的優點是使用極少的內存就能統計大量的數據,Redis 實現的 HyperLogLog,只需要 12K
內存就能統計 $2^64$
個數據。遠比 Hash
的內存開銷要少。
HyperLogLog(HLL)
是一種用於基數計數的概率算法,是基於 LogLog(LLC)
算法的優化和改進,在同樣空間複雜度下,能夠比 LLC 的基數估計誤差更小。
HyperLogLog
算法的通俗說明:假設我們為一個數據集合生成一個8位的哈希串,那麼我們得到00000111的概率是很低的,也就是說,我們生成大量連續的0的概率是很低的。生成連續5個0的概率是1/32,那麼我們得到這個串時,可以估算,這個數據集的基數是32。
再深入的那就是數學公式,可參考本文最後的參考鏈接前往研究。
Redis 中 HLL 的使用
命令 | 說明 | 可用版本 | 時間複雜度 |
PFADD | 添加 | >= 2.8.9 | O(1) |
PFCOUNT | 獲得基數值 | >= 2.8.9 | O(1) |
PFMERGE | 合併多個key | >= 2.8.9 | O(N) |
示例代碼
using StackExchange.Redis; using System; public class PageUVDemo { private static IDatabase db; static void Main(string[] args) { ConnectionMultiplexer connection = ConnectionMultiplexer.Connect("192.168.0.104:7001,password=123456"); db = connection.GetDatabase(); Console.WriteLine("hll:"); HLLVisit(1000, 1000); HLLVisit(10000, 10000); HLLVisit(100000, 100000); Console.WriteLine("hash:"); HashVisit(1000, 1000); HashVisit(10000, 10000); HashVisit(100000, 100000); connection.Close(); } static void HLLVisit(int times, int pid) { string key = $"puv:hll:{pid}"; DateTime start = DateTime.Now; for (int i = 0; i < times; i++) { db.HyperLogLogAdd(key, i); } long total = db.HyperLogLogLength(key); DateTime end = DateTime.Now; Console.WriteLine("插入{0}次:", times); Console.WriteLine(" total:{0}", total); Console.WriteLine(" duration:{0:F2}s", (end - start).TotalSeconds); Console.WriteLine(); } static void HashVisit(int times, int pid) { string key = $"puv:hash:{pid}"; DateTime start = DateTime.Now; for (int i = 0; i < times; i++) { db.HashSet(key, i, true); } long total = db.HashLength(key); DateTime end = DateTime.Now; Console.WriteLine("插入{0}次:", times); Console.WriteLine(" total:{0}", total); Console.WriteLine(" duration:{0:F2}s", (end - start).TotalSeconds); Console.WriteLine(); } }
運行結果
結果對比
數據通過 redis-rdb-tools
導出,更多請查看。
數據類型 | 插入次數 | 內存開銷 | 時間開銷 | 誤差率 |
hash | 1000 | 35KB | 3.45s | 0% |
10000 | 426KB | 34.65s | 0% | |
100000 | 3880KB | 342.36s | 0% | |
hll | 1000 | 2KB | 3.57s | 0.1% |
10000 | 14KB | 33.25s | 0.13% | |
100000 | 14KB | 307.80s | 0.44% |
從上面的結果可以看出,10萬次級別下,HyperLogLog 的誤差率很低,0.44%,但內存開銷是 Hash 的0.3%,隨着數量級的提升,內存開銷差距也越大。
應用場景
- 統計註冊 IP 數
- 統計每日訪問 IP 數
- 統計頁面實時 UV 數
- 統計在線用戶數
- 統計用戶每天搜索不同詞條的個數
總結
不追求百分百的準確度時,使用 HyperLogLog 數據結構能減少內存開銷。