Redis 集合統計(HyperLogLog)

統計功能是一類極爲常見的需求,好比下面這個場景:redis

爲了決定某個功能是否在下個迭代版本中保留,產品會要求統計頁面在上新先後的 UV 做爲決策依據。
簡單來講就是統計一天內,某個頁面的訪問用戶量,若是相同的用戶再次訪問,也只算記爲一次訪問。
下面咱們將從這個場景出發,討論如何選擇的合適的 Redis 數據結構實現統計功能。

Redis與統計

聚合統計

要完成這個統計任務,最直觀的方式是使用一個SET保存頁面在某天的訪問用戶 ID,而後經過對集合求差SDIFF和求交SINTER完成統計:數據結構

# 2020-01-01 當日的 UV
SADD page:uv:20200101 "Alice" "Bob" "Tom" "Jerry"

# 2020-01-02 當日的 UV
SADD page:uv:20200102 "Alice" "Bob" "Jerry" "Nancy"

# 2020-01-02 新增用戶
SDIFFSTORE page:new:20200102 page:uv:20200102 page:uv:20200101

# 2020-01-02 新增用戶數量
SCARD page:new:20200102

# 2020-01-02 留存用戶
SINTERSTORE page:rem:20200102 page:uv:20200102 page:uv:20200101

# 2020-01-02 留存用戶數量
SCARD page:rem:20200102

優勢:函數

  • 操做直觀易理解,能夠複用現有的數據集合
  • 保留了用戶的訪問細節,能夠作更細粒度的統計

缺點:大數據

  • 內存開銷大,假設每一個用戶ID長度均小於 44 字節(使用 embstr 編碼),記錄 1 億用戶也至少須要 6G 的內存
  • SUNIONSINTERSDIFF計算複雜度高,大數據量狀況下會致使 Redis 實例阻塞,可選的優化方式有:
    • 從集羣中選擇一個從庫專門負責聚合計算
    • 把數據讀取到客戶端,在客戶端來完成聚合統計

二值統計

當用戶 ID 是連續的整數時,可使用BITMAP實現二值統計:優化

# 2020-01-01 當日的 UV
SETBIT page:uv:20200101 0 1 # "Alice"
SETBIT page:uv:20200101 1 1 # "Bob"
SETBIT page:uv:20200101 2 1 # "Tom"
SETBIT page:uv:20200101 3 1 # "Jerry"

# 2020-01-02 當日的 UV
SETBIT page:uv:20200102 0 1 # "Alice"
SETBIT page:uv:20200102 1 1 # "Bob"
SETBIT page:uv:20200102 3 1 # "Jerry"
SETBIT page:uv:20200102 4 1 # "Nancy"

# 2020-01-02 新增用戶
BITOP NOT page:not:20200101 page:uv:20200101
BITOP AND page:new:20200102 page:uv:20200102 page:not:20200101 

# 2020-01-02 新增用戶數量
BITCOUNT page:new:20200102

# 2020-01-02 留存用戶
BITOP AND page:rem:20200102 page:uv:20200102 page:uv:20200101

# 2020-01-02 留存用戶數量
BITCOUNT page:new:20200102

優勢:編碼

  • 內存開銷低,記錄 1 億個用戶只須要 12MB 內存
  • 統計速度快,計算機對比特位的異或運算十分高效

缺點:spa

  • 對數據類型有要求,只能處理整數集合

基數統計

前面兩種方式都能提供準確的統計結果,可是也存在如下問題:code

  • 當統計集合變大時,所需的存儲內存也會線性增加
  • 當集合變大時,判斷其是否包含新加入元素的成本變大

考慮下面這一場景:blog

產品可能只關心 UV 增量,此時咱們最終要的結果是訪問用戶集合的數量,並不關心訪問集合裏面包含哪些訪問用戶
只統計一個集合中 不重複的元素個數,而並不關心集合元素內容的統計方式,咱們將其稱爲 基數計數 cardinality counting

針對這一特定的統計場景,Redis 提供了HyperLogLog類型支持基數統計:內存

# 2020-01-01 當日的 UV
PFADD page:uv:20200101 "Alice" "Bob" "Tom" "Jerry"
PFCOUNT page:uv:20200101

# 2020-01-02 當日的 UV
PFADD page:uv:20200102 "Alice" "Bob" "Tom" "Jerry" "Nancy"
PFCOUNT page:uv:20200102

# 2020-01-01 與 2020-01-02 的 UV 總和
PFMERGE page:uv:union page:uv:20200101 page:uv:20200102
PFCOUNT page:uv:union

優勢:
HyperLogLog計算基數所需的空間是固定的。只須要 12KB 內存就能夠計算接近 \(2^{64}\) 個元素的基數。

缺點:
HyperLogLog的統計是基於機率完成的,其統計結果是有必定偏差。不適用於精確統計的場景。

HyperLogLog 解析

機率估計

HyperLogLog是一種基於機率的統計方式,該如何理解?

咱們來作一個實驗:不停地拋一個均勻的雙面硬幣,直到結果是正面爲止
用 0 和 1 分別表示正面與反面,則實驗結果能夠表示爲以下二進制串:

+-+
第 1 次拋到正面   |1|
                +-+
                +--+
第 2 次拋到正面   |01|
                +--+
                +---+
第 3 次拋到正面   |001|
                +---+
                +---------+
第 k 次拋到正面   |000...001|  (總共 k-1 個 0)
                +---------+
因爲每次拋硬幣獲得正面的機率均爲$\frac{1}{2}$,所以實驗在第 k 次結束的可能性爲 $(\frac{1}{2})^k$(二進制串中首個 1 出如今第 k 位的機率)。

進行 n 實驗後,將每次實驗拋硬幣的次數記爲 \(k_1, k_3,\cdots,k_n\),其中的最大值記爲 \(k_{max}\)

理想狀況下有 \(k_{max} = log_2(n)\),反過來也能夠經過 \(k_{max}\) 來估計總的實驗次數 \(n = 2^{k_{max}}\)

處理極端狀況

實際進行實驗時,極端狀況總會出現,好比在第 1 次實驗時就連續拋出了 10 次反面。
若是按照前面的公式進行估計,會認爲已經進行了 1000 次實驗,這顯然與事實不符。

爲了提升估計的準確性,能夠同時使用 m 枚硬幣進行 分組實驗
而後計算這 m 組實驗的平均值 \(\hat{k}_{max} = \frac{\sum_{i=0}^{m}{k_{max}}}{m}\),此時能更準確的估計實際的實驗次數 \(\hat{n}=2^{\hat{k}_{max}}\)

基數統計

經過前面的分析,咱們能夠總結出如下經驗:

能夠經過二進制串中首個 1 出現的位置 \(k_{max}\) 來估計實際實驗發生的次數 \(n\)

HyperLogLog借鑑上述思想來統計集合中不重複元素的個數:

  • 使用 hash 函數集合中的每一個元素映射爲定長二進制串
  • 利用 分組統計 的方式提升準確性,將二進制串分到 \(m\) 個不一樣的桶bucket中分別統計
    • 二進制串的前 \(log_2{m}\) 位用於計算該元素所屬的桶
    • 剩餘二進制位中,首個 1 出現的比特位記爲 \(k\),每一個桶中的只保存最大值 \(k_{max}\)
  • 當須要估計集合中包含的元素個數時,使用公式 \(\hat{n}=2^{\hat{k}_{max}}\) 計算便可

下面來看一個例子:
某個 HyperLogLog實現,使用 8bit 輸出的 hash 函數並以 4 個桶進行分組統計
使用該 HLL 統計 Alice,Bob,Tom,Jerry,Nancy 這 5 個用戶訪問頁後的 UV
映射爲二進制串     分組    計算k
                      |            |       |
                      V            V       V
                 +---------+
hash("Alice") => |01|101000| => bucket=1, k=1
                 +---------+                                           分組統計 k_max
                 +---------+                 
hash("Bob")   => |11|010010| => bucket=3, k=2           +----------+----------+----------+----------+
                 +---------+                            | bucket_0 | bucket_1 | bucket_2 | bucket_3 |
                 +---------+                     ==>    +----------+----------+----------+----------+
hash("Tom")   => |10|001000| => bucket=2, k=3           | k_max= 1 | k_max= 2 | k_max= 3 | k_max= 2 |
                 +---------+                            +----------+----------+----------+----------+
                 +---------+                                         
hash("Jerry") => |00|111010| => bucket=0, k=1                
                 +---------+                                            
                 +---------+                               
hash("Nancy") => |01|010001| => bucket=1, k=2
                 +---------+

分組計數完成後,用以前的公式估計集合基數爲 \(2^{\hat{k}_{max}}= 2^{(\frac{1+2+3+2}{4})} = 4\)

偏差分析

在 Redis 的實現中,對於一個輸入的字符串,首先獲得 64 位的 hash 值:

  • 前 14 位來定位桶的位置(共有16384個桶)
  • 後 50 位用做元素對應的二進制串(用於更新首次出現 1 的比特位的最大值 \(k_{max}\)

因爲使用了 64 位輸出的 hash 函數,所以能夠計數的集合的基數沒有實際限制。

HyperLogLog的標準偏差計算公式爲 \(\frac{1.04}{\sqrt{m}}\)\(m\) 爲分組數量),據此計算 Redis 實現的標準偏差爲 \(0.81\%\)

下面這幅圖展現了統計偏差與基數大小的關係:

  • 紅線和綠線分別表明兩個不一樣分佈的數據集
  • x 軸表示集合實際基數
  • y 軸表示相對偏差(百分比)

分析該圖能夠得出如下結論:

  • 統計偏差與數據自己的分佈特徵無關
  • 集合基數越小,偏差越小(小基數時精度高)
  • 集合基數越大,偏差越大(大基數時省資源)

參考資料

相關文章
相關標籤/搜索