分散式快取Redis之HyperLogLog
寫在前面
基數估計演算法就是使用準確性換取空間。 為了說明這一點,我們用三種不同的計算方法統計所有莎士比亞作品中不同單詞的數量。請注意,我們的輸入資料集增加了額外的資料以致比問題的參考基數更高。 這三種技術是:Java HashSet、Linear Probabilistic Counter以及一個Hyper LogLog Counter。結果如下:
該表顯示,我們統計這些單詞只用了512 bytes,而誤差在3%以內。相比之下,HashMap的計數準確度最高,但需要近10MB的空間,你可以很容易地看到為什麼基數估計是有用的。在實際應用中準確性並不是很重要的,這是事實,在大多數網路規模和網路計算的情況下,用概率計數器會節省巨大的空間。
再者,如果我們要實現記錄網站每天訪問的獨立IP數量這樣的一個功能:
集合實現:
使用集合來儲存每個訪客的 IP ,通過集合性質(集合中的每個元素都各不相同)來得到多個獨立 IP,然後通過呼叫 SCARD 命令來得出獨立 IP 的數量。
舉個例子,程式可以使用以下程式碼來記錄 2017 年 12 月 5 日,每個網站訪客的 IP :
ip = get_vistor_ip()
SADD '2017.12.5::unique::ip' ip
然後使用以下程式碼來獲得當天的唯一 IP 數量:
SCARD '2017.12.5::unique::ip'
集合實現的問題
使用字串來儲存每個IPv4 地址最多需要耗費15 位元組(格式為 ‘XXX.XXX.XXX.XXX’,比如’202.189.128.186’)。
下表給出了使用集合記錄不同數量的獨立 IP 時,需要耗費的記憶體數量:
獨立IP數量 | 一天 | 一個月 | 一年 |
---|---|---|---|
一百萬 | 15 MB | 450 MB | 5.4 GB |
一千萬 | 150 MB | 4.5 GB | 54 GB |
一億 | 1.5 GB | 45 GB | 540 GB |
隨著集合記錄的 IP 越來越多,消耗的記憶體也會越來越多。另外如果要儲存 IPv6 地址的話,需要的記憶體還會更多一些。為了更好地解決像獨立 IP 地址計算這種問題,Redis 在 2.8.9 版本添加了 HyperLogLog 結構(一般12kb就夠了)。
Redis資料結構HyperLogLog
Redis HyperLogLog 是用來做基數統計的演算法,HyperLogLog 的優點是,在輸入元素的數量或者體積非常非常大時,計算基數所需的空間總是固定的、並且是很小的。在Redis 裡面,每個 HyperLogLog 鍵只需要花費 12 KB 記憶體,就可以計算接近 2^64 個不同元素的基數。這和使用集合計算基數時,元素越多耗費記憶體就越多的集合形成鮮明對比。但是,因為 HyperLogLog 只會根據輸入元素來計算基數,而不會儲存輸入元素本身,所以 HyperLogLog 不能像集合那樣,返回輸入的各個元素。
基於HyperLogLog演算法分析:
redis中統計陣列大小設定為m=16384,hash函式生成64位bit陣列,其中 log2(16834)=14位用來找到統計陣列的位置,剩下50位用來記錄第一個1出現的位置,最大位置為50,需要log2(50)=6位記錄。那麼統計陣列需要的最大記憶體大小為: 6bit∗16834≈12k (為什麼是12k???) 。基數估計的標準誤差為0.81%
什麼是基數?
比如資料集 {1, 3, 5, 7, 5, 7, 8}, 那麼這個資料集的基數集為 {1, 3, 5 ,7, 8}, 基數(不重複元素)為5。 基數估計就是在誤差可接受的範圍內,快速計算基數。
估算值:演算法給出的基數並不是精確的,可能會比實際稍微多一些或者稍微少一些,但會控制在合理的範圍之內。
幾個命令
將元素新增至 HyperLogLog
1、PFADD key element [element …]
將任意數量的元素新增到指定的 HyperLogLog 裡面。
這個命令可能會對 HyperLogLog的基數估算值進行修改,以便反映新的基數估算值,如果 HyperLogLog 的基數估算值在命令執行之後出現了變化,那麼命令返回1,否則返回0。
命令的複雜度為 O(N) ,N 為被新增元素的數量。
2、PFCOUNT key [key …]
返回給定 HyperLogLog 的基數估算值。
當只給定一個 HyperLogLog 時,命令返回給定 HyperLogLog 的基數估算值。
當給定多個 HyperLogLog 時,命令會先對給定的 HyperLogLog 進行並集計算,得出一個合併後的HyperLogLog ,然後返回這個合併 HyperLogLog 的基數估算值作為命令的結果(合併得出的HyperLogLog 不會被儲存,使用之後就會被刪掉)。
當命令作用於單個 HyperLogLog 時, 複雜度為 O(1) , 並且具有非常低的平均常數時間。
當命令作用於多個 HyperLogLog 時, 複雜度為 O(N) ,並且常數時間也比處理單個 HyperLogLog 時要大得多。
PFADD 和 PFCOUNT 的使用示例
redis> PFADD unique::ip::counter '192.168.0.1'
(integer) 1
redis> PFADD unique::ip::counter '127.0.0.1'
(integer) 1
redis> PFADD unique::ip::counter '255.255.255.255'
(integer) 1
redis> PFCOUNT unique::ip::counter
(integer) 3
合併多個 HyperLogLog
3、PFMERGE destkey sourcekey [sourcekey …]
將多個 HyperLogLog 合併為一個 HyperLogLog ,合併後的 HyperLogLog 的基數估算值是通過對所有給定 HyperLogLog 進行並集計算得出的。
PFMERGE 的使用示例
redis> PFADD str1 "apple" "banana" "cherry"
(integer) 1
redis> PFCOUNT str1
(integer) 3
redis> PFADD str2 "apple" "cherry" "durian" "mongo"
(integer) 1
redis> PFCOUNT str2
(integer) 4
redis> PFMERGE str1&2 str1 str2
OK
redis> PFCOUNT str1&2
(integer) 5
Hyperloglog(HLL)演算法淺說
1、通過hash函式計算輸入值對應的位元串;
2、位元串的低 t(t=log2(m))位對應的數字用來找到陣列S中對應的位置 i;
3、t+1位開始找到第一個1出現的位置 k,將 k記入陣列S_i位置;
4、基於陣列S記錄的所有資料的統計值,計算整體的基數值,計算公式可以簡單表示為:n^=f(S)n^=f(S)
原理理解
看到這裡心裡應該有無數個問號,這樣真的就能統計到上億條資料的基數了嗎?我總結一下,先丟擲三個疑問:
1、為什麼要記錄第一個1出現的位置? 2、為什麼要有分桶陣列 S? 3、通過分桶陣列 S計算基數的公式是什麼?
可以通過一組小實驗驗證一下這種估計方法是否基本合理。
回到基數統計的問題,我們需要統計一組資料中不重複元素的個數,集合中每個元素的經過hash函式後可以表示成0和1構成的二進位制數串,一個二進位制串可以類比為一次拋硬幣實驗,1是拋到正面,0是反面。二進位制串中從低位開始第一個1出現的位置可以理解為拋硬幣試驗中第一次出現正面的拋擲次數k,那麼基於上面的結論,我們可以通過多次拋硬幣實驗的最大拋到正面的次數來預估總共進行了多少次實驗,同樣可以可以通過第一個1出現位置的最大值k_max來預估總共有多少個不同的數字(整體基數)。
這種通過區域性資訊預估整體資料流特性的方法似乎有些超出我們的基本認知,需要用概率和統計的方法才能推導和驗證這種關聯關係。HyperLogLog核心在於觀察集合中每個數字對應的位元串,通過統計和記錄位元串中最大的出現1的位置來估計集合整體的基數,可以大大減少記憶體耗費。
現在回到關於HyperLogLog的第一個疑問,為什麼要統計hash值中第一個1出現的位置?第一個1出現的位置可以類比為拋硬幣實驗中第一次拋到正面的拋擲次數,根據拋硬幣實驗的結論,記錄每個資料的第一個出現的位置k,就可以通過其中最大值kmaxkmax推匯出資料集合的基數:n^=2kmaxn^=2kmax
HLL的基本思想是利用集合中數字的位元串第一個1出現位置的最大值來預估整體基數,但是這種預估方法存在較大誤差,為了改善誤差情況,HLL中引入分桶平均的概念。
同樣舉拋硬幣的例子,如果只有一組拋硬幣實驗,運氣較好,第一次實驗過程就拋了10次才第一次拋到正面,顯然根據公式推導得到的實驗次數的估計誤差較大;如果100個組同時進行拋硬幣實驗,同時運氣這麼好的概率就很低了,每組分別進行多次拋硬幣實驗,並上報各自實驗過程中拋到正面的拋擲次數的最大值,就能根據100組的平均值預估整體的實驗次數了。
分桶平均的基本原理是將統計資料劃分為m個桶,每個桶分別統計各自的kmaxkmax並能得到各自的基數預估值 n^n^,最終對這些 n^n^求平均得到整體的基數估計值。LLC中使用幾何平均數預估整體的基數值,但是當統計資料量較小時誤差較大;HLL在LLC基礎上做了改進,採用調和平均數,調和平均數的優點是可以過濾掉不健康的統計值。
雖然調和平均數能夠適當修正演算法誤差,但作者給出一種分階段修正演算法。當HLL演算法開始統計資料時,統計陣列中大部分位置都是空資料,並且需要一段時間才能填滿陣列,這種階段引入一種小範圍修正方法;當HLL演算法中統計陣列已滿的時候,需要統計的資料基數很大,這時候hash空間會出現很多碰撞情況,這種階段引入一種大範圍修正方法。
回到關於HLL的第二個疑問,為什麼要有分桶陣列 S?分桶陣列是為了消減因偶然性帶來的誤差,提高預估的準確性。
該演算法出自論文《HyperLogLog the analysis of a near-optimal cardinality estimation algorithm》,下載連結:http://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf。具體論文的理論推導不詳細介紹,簡述下其思想核心。
HLL中實際儲存的是一個長度為m的大陣列S,將待統計的資料集合劃分成m組(分桶陣列),每組根據演算法記錄一個統計值存入陣列中。陣列的大小m由演算法實現方自己確定,redis中這個陣列的大小是16834,m越大,基數統計的誤差越小,但需要的記憶體空間也越大。
演算法實現:
m = 2^b # with b in [4...16]
if m == 16:
alpha = 0.673
elif m == 32:
alpha = 0.697
elif m == 64:
alpha = 0.709
else:
alpha = 0.7213/(1 + 1.079/m)
registers = [0]*m # initialize m registers to 0
###########################################################################
# Construct the HLL structure
for h in hashed(data):
register_index = 1 + get_register_index( h,b ) # binary address of the rightmost b bits
run_length = run_of_zeros( h,b ) # length of the run of zeroes starting at bit b+1
registers[ register_index ] = max( registers[ register_index ], run_length )
##########################################################################
# Determine the cardinality
DV_est = alpha * m^2 * 1/sum( 2^ -register ) # the DV estimate
if DV_est < 5/2 * m: # small range correction
V = count_of_zero_registers( registers ) # the number of registers equal to zero
if V == 0: # if none of the registers are empty, use the HLL estimate
DV = DV_est
else:
DV = m * log(m/V) # i.e. balls and bins correction
if DV_est <= ( 1/30 * 2^32 ): # intermediate range, no correction
DV = DV_est
if DV_est > ( 1/30 * 2^32 ): # large range correction
DV = -2^32 * log( 1 - DV_est/2^32)
Java原始碼實現參考:
若想深入瞭解Hyper LogLog,還可以參考這篇論文:
--------------------- 本文來自 BugFree_張瑞 的CSDN 部落格 ,全文地址請點選:https://blog.csdn.net/u011489043/article/details/78727128?utm_source=copy