Redis實戰篇(三)基於HyperLogLog實現UV統計功能

如果現在要開發一個功能:

統計APP或網頁的一個頁面,每天有多少用戶點擊進入的次數。同一個用戶的反覆點擊進入記為 1 次,也就是統計 UV 數據。

讓你來開發這個統計模塊,你會如何實現?

 

如果統計 PV 數據,只要給網頁一個獨立的 Redis 計數器就可以了,這個計數器的 key 的格式為 puv:{pid}:{yyyyMMdd}。每來一個請求就 incrby 一次,就可以統計出所有的 PV 數據。

 

但是 UV 不一樣,它要去重,同一個用戶一天之內的多次訪問請求只能計數一次。這就要求每一個網頁請求都需要帶上用戶的 ID,無論是登陸用戶還是未登陸用戶都需要一個唯一 ID 來標識。

 

你可能會馬上想到,用 Hash 數據類型就能滿足去重。這確實是一種解決方法,但是當這個頁面的日活達到百萬或千萬以上級別的話,Hash 的內存開銷就會非常大。

 

我們來估算一下採用 Hash 的內存空間是多大。假設 keyint 類型,對應的是用戶ID,valuebool 類型,表示已訪問,當有百萬級不同用戶訪問時,內存空間為: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();
    }
}

 

運行結果

image

 

結果對比

image

 

數據通過 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 數據結構能減少內存開銷。

 

參考資料