HyperLogLog
實現一個功能
統計網站的UV (user view),區別PV (page view)
- 數據去重
- 統計總數
同一個用戶的反覆點擊進入記爲 1 次
解決方案
最簡單的思路是記錄集合A中所有不重複元素的集合S,當新來一個元素x,若S中不包含元素x,則將x加入S,否則不加入,集合A的基數就是集合S中元素的數量
數據量大時存在的問題
- 存儲內存會線性增長
- 集合S中的元素數量增多時,需要用布隆過濾器(檢索一個元素是否在一個集合中)
hashmap、set
內存佔用大
假設定義
HashMap
中Key
爲string
類型,value
爲boolean
。key
表示用戶的Id
,value
表示是否點擊進入
。當百萬不同用戶訪問的時候。此HashMap
的內存佔用空間爲:
100萬 * (string + boolean)
B 樹
Bitmap 位圖
HyperLogLog (HLL)
HLL簡介
Redis new data structure: the HyperLogLog(redis 作者博客中所說)
也有說不是一種數據結構,只是一種算法
用來統計一個集合中不重複元素的個數,在不追求絕對準確的情況下,廣泛用於大數據場景計算基數
數據集 {1, 3, 5, 7, 5, 7, 8}, 那麼這個數據集的基數集爲 {1, 3, 5 ,7, 8}, 基數(不重複元素)爲5
特點
-
是一種概率算法,不直接存儲數據集合本身,通過一定的概率統計方法預估整體基數值,可以大大節省內存,同時保證誤差控制在一定範圍內
-
每個HLL類型的對象只佔12KB內存,可以統計2^64個數據的基數。
時間複雜度爲O(1)
-
計數存在一定的誤差,誤差率整體較低。官方給出的誤差率爲 0.81%
-
誤差可以被設置輔助計算因子進行降低
原理
伯努利試驗
是在同樣條件下重複地、相互獨立地進行的一種隨機試驗,其特點是該隨機試驗只有兩種可能結果:發生或者不發生。 我們假設該項試驗獨立重複地進行了n次,那麼就稱這一系列重複獨立的隨機試驗爲n重伯努利試驗
二項分佈
拋硬幣試驗
出現正反面的概率都是1/2,一直拋硬幣直到出現正面,記錄投擲次數k,這種多次拋硬幣直到出現正面的過程就是一次伯努利試驗
對於n次伯努利過程,我們會得到n個出現正面的投擲次數值k1,k2……kn,其中最大值記爲kmax,那麼可以得到下面結論:
- n 次伯努利過程的投擲次數都不大於 kmax。
- n 次伯努利過程,至少有一次投擲次數等於 kmax
對於第一個結論,n次伯努利過程的拋擲次數都不大於kmax的概率用數學公式表示爲:
一次過程大於kmax的概率爲1/2^kmax,即kmax次都是反面的概率。不大於kmax的概率即1-1/2^kmax
第二個結論,至少有一次等於kmax的概率用數學公式表示爲:
一次過程大於kmax-1的概率爲1/2^(kmax-1),即kmax-1次都是反面的概率。不大於kmax-1的概率即1-1/2^(kmax-1),大於的概率即1-(1-1/2^(kmax-1))
結合極大似然估算,一頓數學推導,得出在n
和k_max
中存在估算關係:n = 2^(k_max)
即n次拋硬幣試驗,記錄首次拋到正面的次數k,則可以用2^kmax 估算n的大小
極大似然估算
利用已知的樣本結果信息,反推最具有可能(最大概率)導致這些樣本結果出現的模型參數值
例子
假如有一個罐子,裏面有黑白兩種顏色的球,數目多少不知,兩種顏色的比例也不知。我們想知道罐中白球和黑球的比例,但我們不能把罐中的球全部拿出來數。現在我們可以每次任意從已經搖勻的罐中拿一個球出來,記錄球的顏色,然後把拿出來的球再放回罐中。重複這個過程,用記錄的球的顏色來估計罐中黑白球的比例。
假如在前面的一百次重複記錄中,有七十次是白球,請問罐中白球所佔的比例最有可能是多少?(70%)
假設罐中白球的比例是p,那麼黑球的比例就是1-p。因爲每抽一個球出來,在記錄顏色之後,我們把抽出的球放回了罐中並搖勻,所以每次抽出來的球的顏色服從同一獨立分佈。則題目所描述的事件出現的概率爲
P=p^70(1-p)^30。該模型中的參數p最有可能是多少?
隨機過程中,任何時刻的取值都爲隨機變量
例如隨機變量X1和X2獨立,是指X1的取值不影響X2的取值,X2的取值也不影響X1的取值且隨機變量X1和X2服從同一分佈,意味着X1和X2具有相同的分佈形狀和相同的分佈參數,對離散隨機變量具有相同的分佈律,對連續隨機變量具有相同的概率密度函數,有着相同的分佈函數,相同的期望、方差。
如實驗條件保持不變,一系列的拋硬幣的正反面結果是獨立同分布
我們把一次抽出來球的顏色稱爲一次抽樣。題目中在一百次抽樣中,七十次是白球的,三十次爲黑球事件的概率爲
求出該模型中的參數值p
p可以有很多種取值(分佈),不同的取值P的值也不同
p=50% => P=7.88*10^(-31)
p=60% => P=3.40*10^(-28)
p=70% => P=2.95*10^(-27) 最大
p=80% => P=1.76*10^(-28)
p=90% => P=6.26*10^(-34)
極大似然估計按照讓這個樣本結果出現的可能性最大的原則去選取分佈
即 p^70(1-p)^30 最大,導數爲0,可得p=70%
p取值範圍[0,100]
閉區間[a,b]上連續的函數f(x)在[a,b]上必有最大值與最小值
初等函數(基本函數)是由常函數、冪函數、指數函數、對數函數、三角函數和反三角函數經過有限次的有理運算(加、減、乘、除、有限次乘方、有限次開方)及有限次函數複合所產生、並且在定義域上能用一個方程式表示的函數
初等函數在其定義域內是連續的
HLL基數統計原理
統計一組數據中不重複元素的個數,HLL在添加元素時會通過內部的一個hash函數,將其轉換爲一個長度爲64的比特串,這些比特串就類似一次拋硬幣的過程(0表示反面,1表示正面)。統計比特串中首次出現1的位置,並以此預估整體基數
實現
偶然性
如果只有一組試驗,恰好第一次過程首次出現正面的次數就是10,,則以此估算試驗次數誤差很大2^10
如果同時進行100組試驗,用每組試驗估算出來的值求平均數,則會消減因偶然性帶來的誤差,提高預估的準確性
HLL
HLL 使用了分桶的概念,內部維護了一個長度爲2^14(16384)的數組S,每個下標代表一個桶,每個桶佔用6bit。
數據經過hash計算得出的64比特串,低14位用來計算桶的位置即數組S中的下標x(從0開始),高50位從右向左計算首次出現1的位置k(從1開始),將k與桶中舊值比較,大於則替換舊值,否則丟棄。在計算基數時,分別計算每個桶中的值,再帶入HLL公式中,得出最終估算的基數
-
DV(Distinct Value) :基數
-
m:伯努利試驗次數,即桶個數
-
Rj:表示每個桶的計數值
-
黃色框中的式子:表示 根據每個桶中的計數值 估算出的整體基數的 調和平均數
調和平均數(harmonic mean)又稱倒數平均數,是總體各統計變量倒數的算術平均數的倒數
不容易受極大值的影響,給予較小值更高的權重,強調了較小值的重要性
例如
- 並聯電阻
- 一艘輪船從A碼頭順流而下到C碼頭然後原路返回,順流而下去時速度30千米/時,逆流而上返回20千米/時。求往返平均速度。容易錯成(30+20)÷2=25(千米/時),因爲往返使用的時間是不同的。事實上結果應該是30和20的調和平均數。2/(1/20+1/30)=2×20×30/(20+30)=24(千米/時)
-
const(constant): 修正因子,用於校正,具體數值跟桶數目有關
HLL使用一種分階段修正算法。當HLL算法開始統計數據時,統計數組中大部分位置都是空數據,並且需要一段時間才能填滿數組,這種階段引入一種小範圍修正方法;當HLL算法中統計數組已滿的時候,需要統計的數據基數很大,這時候hash空間會出現很多碰撞情況,這種階段引入一種大範圍修正方法
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) alpha = constant ######
估算優化
HLL是LL的優化
LL的估算公式
-
m:伯努利試驗次數,即桶個數
-
const(constant): 修正因子,用於校正,具體數值跟桶數目有關
-
頭上有一橫的R是所有桶中數值的算數平均數
(k_max_1 + ... + k_max_m)/m
。當統計數據量較小時誤差較大
If M(j) is the (random) value of parameter R on bucket number j, then the
arithmetic mean
內部存儲
大小端模式
大端模式,是指數據的高字節保存在內存的低地址中,而數據的低字節保存在內存的高地址中
小端模式,是指數據的高字節保存在內存的高地址中,而數據的低字節保存在內存的低地址中
Intel X86結構是小端模式
例子
0x12345678
0x 16進制,1位16進制對應4位二進制數據,2位16進制佔用1字節
內存地址用字節數組buf表示,每個元素代表1字節
大端模式表示爲
高地址 -- buf[3] (0x78) -- 低位
buf[2] (0x56)
buf[1] (0x34)
低地址 -- buf[0] (0x12) -- 高位
小端模式表示爲
高地址 -- buf[3] (0x12) -- 高位
buf[2] (0x34)
buf[1] (0x56)
低地址 -- buf[0] (0x78) -- 低位
原因
在計算機存儲系統以字節爲單位,每個地址單元都對應着一個字節,一個字節爲 8bit。
編程語言中不同類型的數據佔用字節數不同,例如C語言中除了8bit的char之外,還有16bit的short型,32bit的long型(要看具體的編譯器)。因此存在對多字節的安排問題,導致了大小端兩種不同的存儲模式
例如一個16bit的short型x,在內存中的地址爲0x0010,x的值爲0x1122,那麼0x11爲高字節,0x22爲低字節。對於 大端模式,就將0x11放在低地址中,即0x0010中,0x22放在高地址中,即0x0011中。小端模式,剛好相反
分類
Redis 內部是使用字符串位圖來存儲 HyperLogLog 所有桶的計數值
密集存儲結構
使用連續的 16384個桶,組成字符串位圖
通過64比特串的後14位計算得出十進制桶編號爲idx,如果根據桶編號獲取其存儲的計數值?
因爲一個字節8bit,一個桶6bit,某個桶所佔用的地址可能在一個字節內,也可能跨字節
假設桶的編號爲idx,其佔用地址的起始字節位置偏移用 offset_bytes表示,它在這個字節的起始比特位置偏移用 offset_bits 表示。我們有
offset_bytes = (idx * 6) / 8 取商
offset_bits = (idx * 6) % 8 取餘
比如 bucket 2 的字節偏移是 1,也就是第 2 個字節。它的位偏移是4,也就是第 2 個字節的第 5 個位開始是 bucket 2 的計數值。
需要注意的是字節位序是左邊低位右邊高位(小端模式),而通常我們書寫二進制都是左邊高位右邊低位,因此這裏需要倒置
offset_bits 小於等於 2
此時,該桶表示的6bit在一個字節內,則表示的計數值爲
val = buffer[offset_bytes] >> offset_bits # 向右移位
右移運算符 >>
按二進制形式把所有的數字向右移動對應位移位數,低位移出(捨棄),高位的空位補符號位,即正數補零,負數補1
offset_bits大於2
此時該桶表示的6bit跨字節,需要拼接兩個字節的位片段
# 低位值
low_val = buffer[offset_bytes] >> offset_bits
# 低位個數
low_bits = 8 - offset_bits
# 拼接,保留低6位
val = (high_val << low_bits | low_val) & 0b111111
按位或 |
0|0=0; 0|1=1; 1|0=1; 1|1=1
按位與 &
0&0=0; 0&1=0; 1&0=0; 1&1=1
#define HLL_DENSE_GET_REGISTER(target,p,regnum) do { \ uint8_t *_p = (uint8_t*) p; \ unsigned long _byte = regnum*HLL_BITS/8; \ unsigned long _fb = regnum*HLL_BITS&7; \ # %8 = &7 unsigned long _fb8 = 8 - _fb; \ unsigned long b0 = _p[_byte]; \ unsigned long b1 = _p[_byte+1]; \ target = ((b0 >> _fb) | (b1 << _fb8)) & HLL_REGISTER_MAX; \ } while(0)
稀疏存儲結構
適用於很多計數值都是零的情況
HLL最開始是稀疏存儲
當多個連續桶的計數值都是零時,Redis使用一個字節來表示多少個桶的計數值都是零即00xxxxxx,前綴兩個零表示接下來的6bit整數值加1就是零值計數器的數量,比如 00010101表示連續 22 個零值計數器;6bit最多能表示連續64個零值計數器,如果大於64個,採用兩個個字節表示即01xxxxxx yyyyyyyy,後面的 14bit 可以表示最多連續 16384 個零值計數器。
如果連續幾個桶的計數值非零,那就使用形如 1vvvvvxx 這樣的一個字節來表示。中間 5bit 表示計數值(最大能表示32),尾部 2bit 表示連續幾個桶。它的意思是連續 (xx +1) 個桶的計數值都是 (vvvvv + 1)。比如 10101011 表示連續 4 個計數值都是 11。
注意這兩個值都需要加 1,因爲任意一個是零都意味着這個計數值爲零,那就應該使用零計數值的形式來表示。注意計數值最大隻能表示到32,而密集存儲單個計數值用 6bit 表示,最大可以表示到 63。當稀疏存儲的某個計數值需要調整到大於 32 時,Redis 就會立即轉換 HyperLogLog 的存儲結構,將稀疏存儲轉換成密集存儲
存儲轉換
當某個桶的計數值滿足以下條件
- 任意一個桶的計數值從 32 變成 33,因爲VAL指令已經無法容納,它能表示的計數值最大爲 32
- 稀疏存儲佔用的總字節數超過 3000 字節,這個閾值可以通過配置中 hll_sparse_max_bytes 參數進行調整
稀疏存儲將不可逆一次性轉換成密集型存儲
命令
-
pfadd
pfadd key value [value...]
- 想HyperLogLog 中添加元素
- 添加成功返回1
127.0.0.1:6379> pfadd pfkey u1 u2
(integer) 1 -
pfcount
pfcount key [key...]
-
計算一個或多個 HyperLogLog 的獨立總數
-
如果是多個 HyperLogLog 則返回所有獨立總數中的最大值
127.0.0.1:6379> pfadd pfkey u1 u2
(integer) 1
127.0.0.1:6379> pfcount pfkey
(integer) 2127.0.0.1:6379> pfadd pfkey u1 u3
(integer) 1
127.0.0.1:6379> pfcount pfkey
(integer) 3//
127.0.0.1:6379> pfadd pfkey2 u1 u2
(integer) 1127.0.0.1:6379> pfcount pfkey2
(integer) 2127.0.0.1:6379> pfcount pfkey pfkey2
(integer) 3 // 返回3
-
-
pfmerge
pfmerge destKey sourceKey [sourceKey...]
-
求多個HyperLogLog的並集並賦值給 destKey
-
返回多個HyperLogLog的並集
127.0.0.1:6379> pfadd pfkey2 u1 u2
(integer) 1127.0.0.1:6379> pfadd pfkey3 u1 u2 u3 u3 u4
(integer) 1
127.0.0.1:6379> pfmerge pfkey0 pfkey2 pfkey3
OK127.0.0.1:6379> pfcount pfkey0
(integer) 4
所用命令以pf開頭,致敬HLL算法的發明者 Philippe Flajolet
-
應用場景
統計某個網站的UV
用戶搜索網站的關鍵詞數量
數據分析
網絡監控
數據庫優化
佔用內存
HLL 中用2^14(16384)個桶,每個桶佔6bit,佔用內存爲
java中long 類型佔用8個字節64bit,最大值爲 2^63-1,那麼 2^63-1 個long類型的數據,佔用內存爲
long 64 位、有符號整數,數值範圍 (-2^63~2^63-1)
測試
50000條數據
//用HyperLogLog類型測試
插入之前
used_memory:604488 byte
used_memory_human:590.32K(604488 / 1024)
插入之後
used_memory:618872
used_memory_human:604.37K 增加 14KB
//用set類型測試
插入之前
used_memory:618872
used_memory_human:604.37K
插入之後
used_memory:3929664
used_memory_human:3.75M(3837.56K) 增加 3.16M
127.0.0.1:6379> pfcount test2
(integer) 49650 // 誤差率 50000-49650=350 350/50000=0.007 => 0.7%
127.0.0.1:6379> scard test3
(integer) 50000
1000000 條數據
//用HyperLogLog類型測試
插入之前
used_memory:3143640
used_memory_human:3.00M
插入之後
used_memory:3158024 增加14384 byte
used_memory_human:3.01M 增加 14KB
127.0.0.1:6379> pfcount pf1
(integer) 1000110 // 誤差率 110/1000000=0.00011 => 0.011%
//set 類型測試
插入之前
used_memory:3158024
used_memory_human:3.01M
插入之後
used_memory:51546704
used_memory_human:49.16M 增加 46.15M
127.0.0.1:6379> scard pf2
(integer) 1000000