HyperLogLog 是一種機率數據結構,用來估算數據的基數。數據集能夠是網站訪客的 IP 地址,E-mail 郵箱或者用戶 ID。redis
基數就是指一個集合中不一樣值的數目,好比 a, b, c, d 的基數就是 4,a, b, c, d, a 的基數仍是 4。雖然 a 出現兩次,只會被計算一次。算法
精確的計算數據集的基數須要消耗大量的內存來存儲數據集。在遍歷數據集時,判斷當前遍歷值是否已經存在惟一方法就是將這個值與已經遍歷過的值進行一一對比。當數據集的數量愈來愈大,內存消耗就沒法忽視,甚至成了問題的關鍵。centos
使用 Redis 統計集合的基數通常有三種方法,分別是使用 Redis 的 HashMap,BitMap 和 HyperLogLog。前兩個數據結構在集合的數量級增加時,所消耗的內存會大大增長,可是 HyperLogLog 則不會。數組
Redis 的 HyperLogLog 經過犧牲準確率來減小內存空間的消耗,只須要12K內存,在標準偏差0.81%的前提下,可以統計2^64個數據。因此 HyperLogLog 是否適合在好比統計日活月活此類的對精度要不不高的場景。緩存
這是一個很驚人的結果,以如此小的內存來記錄如此大數量級的數據基數。下面咱們就帶你們來深刻了解一下 HyperLogLog 的使用,基礎原理,源碼實現和具體的試驗數據分析。數據結構
Redis 提供了 PFADD
、PFCOUNT
和 PFMERGE
三個命令來供用戶使用 HyperLogLog。分佈式
PFADD
用於向 HyperLogLog 添加元素。函數
> PFADD visitors alice bob carol (integer) 1 > PFCOUNT visitors (integer) 3
若是 HyperLogLog 估計的近似基數在 PFADD
命令執行以後出現了變化, 那麼命令返回 1 , 不然返回 0 。 若是命令執行時給定的鍵不存在, 那麼程序將先建立一個空的 HyperLogLog 結構, 而後再執行命令。源碼分析
PFCOUNT
命令會給出 HyperLogLog 包含的近似基數。在計算出基數後,PFCOUNT
會將值存儲在 HyperLogLog 中進行緩存,知道下次 PFADD
執行成功前,就都不須要再次進行基數的計算。post
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_n $, 其中這裏的最大值是k_max。
根據一頓數學推導,咱們能夠得出一個結論: $2^{k_ max}$ 來做爲n的估計值。也就是說你能夠根據最大投擲次數近似的推算出進行了幾回伯努利過程。
://upload-images.jianshu.io/upload_images/623378-48360099af56a3e9.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
下面,咱們就來說解一下 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 公式中,進行調和平均和結果修正,就能獲得估算的基數值。
咱們首先來看一下 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 對節省內存極致地追求。
咱們先看相對簡單的密集存儲結構,它也是十分的簡單明瞭,既然要有 2^14 個 6 bit的桶,那麼我就真使用足夠多的 uint8_t
字節去表示,只是此時會涉及到字節位置和桶的轉換,由於字節有 8 位,而桶只須要 6 位。
因此咱們須要將桶的序號轉換成對應的字節偏移量 offset_bytes 和其內部的位數偏移量 offset_bits。須要注意的是小端字節序,高位在右側,須要進行倒轉。
當 offset_bits 小於等於2時,說明一個桶就在該字節內,只須要進行倒轉就能獲得桶的值。
若是 offset_bits 大於 2 ,則說明一個桶分佈在兩個字節內,此時須要將兩個字節的內容都進行倒置,而後再進行拼接獲得桶的值,以下圖所示。
HyperLogLog 的稀疏存儲結構是爲了節約內存消耗,它不像密集存儲模式同樣,真正找了那麼多個字節數組來表示2^14 個桶,而是使用特殊的字節結構來表達。
Redis 爲了方便表達稀疏存儲,它將上面三種字節表示形式分別賦予了一條指令。
因此,一個初始狀態的 HyperLogLog 對象只須要2 字節,也就是一個 XZERO 來存儲其數據,而不須要消耗12K 內存。當 HyperLogLog 插入了少數元素時,能夠只使用少許的 XZERO、VAL 和 ZERO 進行表示,以下圖所示。
Redis從稀疏存儲轉換到密集存儲的條件是:
具體 Redis 中的 HyperLogLog 源碼因爲涉及較多的位運算,這裏就很少帶你們學習了。你們對 HyperLogLog 有什麼更好的理解或者問題都歡迎積極留言。
https://thoughtbot.com/blog/hyperloglogs-in-redis
http://www.javashuo.com/article/p-afamiehv-ey.html
http://www.javashuo.com/article/p-aozabdfr-mo.html