使用者日活月活怎麼統計 - Redis HyperLogLog 詳解

Young丶發表於2020-10-09

HyperLogLog 是一種概率資料結構,用來估算資料的基數。資料集可以是網站訪客的 IP 地址,E-mail 郵箱或者使用者 ID。

基數就是指一個集合中不同值的數目,比如 a, b, c, d 的基數就是 4,a, b, c, d, a 的基數還是 4。雖然 a 出現兩次,只會被計算一次。

精確的計算資料集的基數需要消耗大量的記憶體來儲存資料集。在遍歷資料集時,判斷當前遍歷值是否已經存在唯一方法就是將這個值與已經遍歷過的值進行一一對比。當資料集的數量越來越大,記憶體消耗就無法忽視,甚至成了問題的關鍵。

使用 Redis 統計集合的基數一般有三種方法,分別是使用 Redis 的 HashMap,BitMap 和 HyperLogLog。前兩個資料結構在集合的數量級增長時,所消耗的記憶體會大大增加,但是 HyperLogLog 則不會。

Redis 的 HyperLogLog 通過犧牲準確率來減少記憶體空間的消耗,只需要12K記憶體,在標準誤差0.81%的前提下,能夠統計2^64個資料。所以 HyperLogLog 是否適合在比如統計日活月活此類的對精度要不不高的場景。

這是一個很驚人的結果,以如此小的記憶體來記錄如此大數量級的資料基數。下面我們就帶大家來深入瞭解一下 HyperLogLog 的使用,基礎原理,原始碼實現和具體的試驗資料分析。

HyperLogLog 在 Redis 中的使用

Redis 提供了 PFADDPFCOUNTPFMERGE 三個命令來供使用者使用 HyperLogLog。

PFADD 用於向 HyperLogLog 新增元素。

> PFADD visitors alice bob carol
(integer) 1
> PFCOUNT visitors
(integer) 3

如果 HyperLogLog 估計的近似基數在 PFADD 命令執行之後出現了變化, 那麼命令返回 1 , 否則返回 0 。 如果命令執行時給定的鍵不存在, 那麼程式將先建立一個空的 HyperLogLog 結構, 然後再執行命令。

PFCOUNT 命令會給出 HyperLogLog 包含的近似基數。在計算出基數後,PFCOUNT 會將值儲存在 HyperLogLog 中進行快取,直到下次 PFADD 執行成功前,就都不需要再次進行基數的計算。

PFMERGE 將多個 HyperLogLog 合併為一個 HyperLogLog , 合併後的 HyperLogLog 的基數接近於所有輸入 HyperLogLog 的並集基數。

> PFADD customers alice dan
(integer) 1
> PFMERGE everyone visitors customers
OK
> PFCOUNT everyone
(integer) 4

記憶體消耗對比實驗

我們下面就來通過實驗真實對比一下下面三種資料結構的記憶體消耗,HashMap、BitMap 和 HyperLogLog。

我們首先使用 Lua 指令碼向 Redis 對應的資料結構中插入一定數量的數,然後執行
bgsave 命令,最後使用 redis-rdb-tools 的 rdb 的命令檢視各個鍵所佔的記憶體大小。

下面是 Lua 的指令碼,不瞭解 Redis 執行 Lua 指令碼的同學可以看一下我之前寫的文章《基於Redis和Lua的分散式限流》

local key = KEYS[1]
local size = tonumber(ARGV[1])
local method = tonumber(ARGV[2])

for i=1,size,1 do
  if (method == 0)
  then
    redis.call('hset',key,i,1)
  elseif (method == 1)
  then
    redis.call('pfadd',key, i)
  else
    redis.call('setbit', key, i, 1)
  end
end

我們在通過 redis-cli 的 script load 命令將 Lua 指令碼載入到 Redis 中,然後使用 evalsha 命令分別向 HashMap、HyperLogLog 和 BitMap 三種資料結構中插入了一千萬個數,然後使用 rdb 命令檢視各個結構記憶體消耗。

[root@VM_0_11_centos ~]# redis-cli -a 082203 script load "$(cat HyperLogLog.lua)"
"6255c6d0a1f32349f59fd2c8711e93f2fbc7ecf8"
[root@VM_0_11_centos ~]# redis-cli -a 082203 evalsha 6255c6d0a1f32349f59fd2c8711e93f2fbc7ecf8 1 hashmap 10000000 0
(nil)
[root@VM_0_11_centos ~]# redis-cli -a 082203 evalsha 6255c6d0a1f32349f59fd2c8711e93f2fbc7ecf8 1 hyperloglog 10000000 1
(nil)
[root@VM_0_11_centos ~]# redis-cli -a 082203 evalsha 6255c6d0a1f32349f59fd2c8711e93f2fbc7ecf8 1 bitmap 10000000 2
(nil)


[root@VM_0_11_centos ~]# rdb -c memory dump.rdb 
database,type,key,size_in_bytes,encoding,num_elements,len_largest_element,expiry

0,string,bitmap,1310768,string,1250001,1250001,
0,string,hyperloglog,14392,string,12304,12304,
0,hash,hashmap,441326740,hashtable,10000000,8,

我們進行了兩輪實驗,分別插入一萬數字和一千萬數字,三種資料結構消耗的記憶體統計如下所示。

統計圖表

從表中可以明顯看出,一萬數量級時 BitMap 消耗記憶體最小, 一千萬數量級時 HyperLogLog 消耗記憶體最小,但是總體來看,HyperLogLog 消耗的記憶體都是 14392 位元組,可見 HyperLogLog 在記憶體消耗方面有自己的獨到之處。

基本原理

HyperLogLog 是一種概率資料結構,它使用概率演算法來統計集合的近似基數。而它演算法的最本源則是伯努利過程。

伯努利過程就是一個拋硬幣實驗的過程。拋一枚正常硬幣,落地可能是正面,也可能是反面,二者的概率都是 1/2 。伯努利過程就是一直拋硬幣,直到落地時出現正面位置,並記錄下拋擲次數k。比如說,拋一次硬幣就出現正面了,此時 k 為 1; 第一次拋硬幣是反面,則繼續拋,直到第三次才出現正面,此時 k 為 3。

對於 n 次伯努利過程,我們會得到 n 個出現正面的投擲次數值 k_1, k_2 … k_nk1,k2…k**n, 其中這裡的最大值是k_max。

根據一頓數學推導,我們可以得出一個結論: 2^{k_ max}2kma**x 來作為n的估計值。也就是說你可以根據最大投擲次數近似的推算出進行了幾次伯努利過程。

下面,我們就來講解一下 HyperLogLog 是如何模擬伯努利過程,並最終統計集合基數的。

HyperLogLog 在新增元素時,會通過Hash函式,將元素轉為64位位元串,例如輸入5,便轉為101(省略前面的0,下同)。這些位元串就類似於一次拋硬幣的伯努利過程。位元串中,0 代表了拋硬幣落地是反面,1 代表拋硬幣落地是正面,如果一個資料最終被轉化了 10010000,那麼從低位往高位看,我們可以認為,這串位元串可以代表一次伯努利過程,首次出現 1 的位數為5,就是拋了5次才出現正面。

所以 HyperLogLog 的基本思想是利用集合中數字的位元串第一個 1 出現位置的最大值來預估整體基數,但是這種預估方法存在較大誤差,為了改善誤差情況,HyperLogLog中引入分桶平均的概念,計算 m 個桶的調和平均值。

示意圖

Redis 中 HyperLogLog 一共分了 2^14 個桶,也就是 16384 個桶。每個桶中是一個 6 bit 的陣列,如下圖所示。

桶

HyperLogLog 將上文所說的 64 位位元串的低 14 位單獨拿出,它的值就對應桶的序號,然後將剩下 50 位中第一次出現 1 的位置值設定到桶中。50位中出現1的位置值最大為50,所以每個桶中的 6 位陣列正好可以表示該值。

在設定前,要設定進桶的值是否大於桶中的舊值,如果大於才進行設定,否則不進行設定。示例如下圖所示。

示例

此時為了效能考慮,是不會去統計當前的基數的,而是將 HyperLogLog 頭的 card 屬性中的標誌位置為 1,表示下次進行 pfcount 操作的時候,當前的快取值已經失效了,需要重新統計快取值。在後面 pfcount 流程的時候,發現這個標記為失效,就會去重新統計新的基數,放入基數快取。

在計算近似基數時,就分別計算每個桶中的值,帶入到上文將的 DV 公式中,進行調和平均和結果修正,就能得到估算的基數值。

Redis 原始碼分析

我們首先來看一下 HyperLogLog 物件的定義

struct hllhdr {
    char magic[4];      /* 魔法值 "HYLL" */
    uint8_t encoding;   /* 密集結構或者稀疏結構 HLL_DENSE or HLL_SPARSE. */
    uint8_t notused[3]; /* 保留位, 全為0. */
    uint8_t card[8];    /* 基數大小的快取 */
    uint8_t registers[]; /* 資料位元組陣列 */
};

HyperLogLog 物件中的 registers 陣列就是桶,它有兩種儲存結構,分別為密集儲存結構和稀疏儲存結構,兩種結構只涉及儲存和桶的表現形式,從中我們可以看到 Redis 對節省記憶體極致地追求。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-a9FEvWJ9-1602258562370)(https://segmentfault.com/img/bVbt0zP?w=1240&h=789)]

我們先看相對簡單的密集儲存結構,它也是十分的簡單明瞭,既然要有 2^14 個 6 bit的桶,那麼我就真使用足夠多的 uint8_t 位元組去表示,只是此時會涉及到位元組位置和桶的轉換,因為位元組有 8 位,而桶只需要 6 位。

所以我們需要將桶的序號轉換成對應的位元組偏移量 offset_bytes 和其內部的位數偏移量 offset_bits。需要注意的是小端位元組序,高位在右側,需要進行倒轉。

當 offset_bits 小於等於2時,說明一個桶就在該位元組內,只需要進行倒轉就能得到桶的值。

示意圖

如果 offset_bits 大於 2 ,則說明一個桶分佈在兩個位元組內,此時需要將兩個位元組的內容都進行倒置,然後再進行拼接得到桶的值,如下圖所示。

示意圖

HyperLogLog 的稀疏儲存結構是為了節約記憶體消耗,它不像密集儲存模式一樣,真正找了那麼多個位元組陣列來表示2^14 個桶,而是使用特殊的位元組結構來表達。

示意圖

Redis 為了方便表達稀疏儲存,它將上面三種位元組表示形式分別賦予了一條指令。

  • ZERO : 一位元組,表示連續多少個桶計數為0,前兩位為標誌00,後6位表示有多少個桶,最大為64。
  • XZERO : 兩個位元組,表示連續多少個桶計數為0,前兩位為標誌01,後14位表示有多少個桶,最大為16384。
  • VAL : 一位元組,表示連續多少個桶的計數為多少,前一位為標誌1,四位表示連桶內計數,所以最大表示桶的計數為32。後兩位表示連續多少個桶。

示意圖

所以,一個初始狀態的 HyperLogLog 物件只需要2 位元組,也就是一個 XZERO 來儲存其資料,而不需要消耗12K 記憶體。當 HyperLogLog 插入了少數元素時,可以只使用少量的 XZERO、VAL 和 ZERO 進行表示,如下圖所示。

示意圖

Redis從稀疏儲存轉換到密集儲存的條件是:

  • 任意一個計數值從 32 變成 33,因為 VAL 指令已經無法容納,它能表示的計數值最大為 32
  • 稀疏儲存佔用的總位元組數超過 3000 位元組,這個閾值可以通過 hll_sparse_max_bytes 引數進行調整。

具體 Redis 中的 HyperLogLog 原始碼由於涉及較多的位運算,這裡就不多帶大家學習了。大家對 HyperLogLog 有什麼更好的理解或者問題都歡迎積極留言。

參考

https://thoughtbot.com/blog/hyperloglogs-in-redis
https://juejin.im/post/5c7fe7525188251ba53b0623
https://juejin.im/post/5bef9c706fb9a049c23204a3

相關文章