Redis Hyperloglog的原理及數學理論的通俗理解

tera發表於2022-01-24

redis中有一種資料格式,hyperloglog,本文就此資料結構的作用、redis的實現及其背後的數學原理作一個整理。當然本文不包含任何數學公式,而是希望用直觀的例子幫大家理解。
主要內容如下:
1.業務場景
2.使用效果
3.數學原理
4.redis的實現原理

1.業務場景

現在有這樣一個業務場景,統計某個頁面的uv。和pv不同,在統計uv的時候需要根據使用者id進行去重,因此就很難用一個簡單的累加計數器來累加pv。當使用者量達到千萬甚至更高階別的時候,去重所需要的額外儲存空間將是巨大的。而hyperloglog資料結構正是用來解決這類問題的,它用僅僅12kb的位元組,就能統計\(2^{64}\)數量級別的去重資料統計。當然這種統計是一種估計量,當數量足夠大的時候,誤差在1%左右。因此如果我們要求的統計結果不需要特別精確,那麼就可以使用這種資料結構節省大量儲存空間。

2.使用效果

我們先看下使用效果,分別記錄1000、10000、100000個不同的id,觀察統計資料 :

可以看到每次的統計結果都略有誤差,但在可接受範圍內。

3.數學原理

極大似然估計的直觀理解

其使用的數學原理是統計學中的極大似然估計。接下去我將用多個場景逐步深入解析。
場景1:現在有2個不透明的口袋,其中都裝有100個球,A口袋中是99個白球1個黑球,B口袋中是99個黑球1個白球。當我們隨機挑選一個口袋,然後從中拿出一個球。如果拿出的球是白色的,那麼我們可以說“大概率”我們取出的是A口袋。這種直覺的推測其實就包含了“極大似然估計”的思想。

場景2:我們只保留A口袋,其中99個白球,1個黑球。很容易我們就可以得出結論,從中取出任意一個球,是白球的概率為99%,是黑球的概率為1%。這是一種正向的推測
我們知道了條件(99個白球,1個黑球),從而推測出結果(取出任意一個球,是白球的概率為99%)
但這只是理論上的推測,如果實際取球100次,每次都放回,那麼取出黑球的次數並不一定是1次,可能是0次,也可能超過1次。我們取球的次數越多,實際情況將越符合理論情況。

場景3:還是A口袋,只不過此時其中白球和黑球的數量我們並不知曉。於是我們開始從中拿球,每拿出一個球都記錄下結果,並將其放回。如果我們取球100次,其中99次是白球,1次是黑球,我們可以說A口袋中可能是99個白球,但並不能非常肯定。當我們取球10000次的時候,其中9900次是白球,100次是黑球,此時我們就可以大概率確定A口袋中是99個白球,而這種確定程度隨著我們實際取球次數的增加也將不斷增加。這就是一種反向的推測
我們觀察了結果(取10000次球,9900次是白球,100次是黑球),可以推測出條件(A口袋中放了99個白球,1個黑球)
當然這種推測的結果並非是準確的,而是一種大概率的估計。
無論是正向推測或是反向推測,只有當實際執行操作的次數足夠多的時候,才能使得實際情況更接近理論推測。這就非常符合hyperloglog的特點,只有當資料量足夠大的時候,誤差才會足夠小。

因此極大似然估計的本質就是:當能觀察的結果數量足夠多時,我們就可以大概率確定產生相應結果所需要的條件的狀態。這種通過大量結果反向估計條件的數學方法就是極大似然估計。

伯努利實驗與極大似然估計

瞭解極大似然估計之後,我們就需要引入第二個數學概念,伯努利實驗。
不要被這個名字唬住,伯努利實驗其實就是扔硬幣,接下去我們就來了解下這枚硬幣要怎麼扔。下文所說的硬幣都是最普通的硬幣,只有正反兩面,且每一面朝上的概率都是50%。
場景1:我們隨機扔一次硬幣,那麼得到正面或反面的可能性是相同的。如果我們扔10000次硬幣,那麼可以估計到大概率是接近5000次正面,5000次反面。這是最簡單的正向推測。

場景2:如果我們扔2次硬幣,是否可能2次都是正面?當然有可能,並且概率為1/4。如果我們扔10次硬幣呢,是否可能10次都是正面?雖然概率很小,但依然是有可能的,概率為1/1024。同樣的,無論是100次、1000次,即使概率很小,也依然存在全部都是正面朝上的情況,假如扔了n次,那麼n次都是正面的概率為\(\frac{1}{2^n}\)。這也是正向的推測,只不過增加了全都是正面朝上的限定。

場景3:現在我們按下面這種規則扔硬幣:不斷扔硬幣,如果是正面朝上,那麼就繼續扔,直到出現反面朝上,此時記錄下扔硬幣的總次數。例如我們拋了5次硬幣,前4次都是正面朝上,第5次是反面朝上,我們就記錄下次數5。通過場景2,我們可以知道這種情況發生的概率為1/32。按我們的直覺可以推測,如果一個結果發生的概率是1/32,那麼我們大體上就需要做32次同樣的事情才能得到這個結果(當然從更嚴謹的數學角度,並不能這麼說,但本文不想涉及專業的數學描述,所以姑且這麼理解,其實也挺符合一般常識判斷的)。
那麼假如張三做了若干次這種實驗,我觀察結果,發現記錄下的總次數的最大值是5,那就說明在這若干次實驗中,至少發生了一次4次正面朝上,第5次反面朝上的情況,而這種情況發生的概率是1/32,於是我推測,張三大概率總共做了32次實驗。這就是一種反向推測:
即根據結果(發生了一次1/32概率才會出現的結果),推測條件(大概率做了32次實驗)
更通俗來說,如果一個結果出現的概率很小,但卻實際發生了了,就可以推測這件事情被重複執行了很多次。結果出現的概率越小,事情被重複執行的次數就應當越多。就像生活中中彩票的概率很低,普通人如果想中那可不就得買很多次嘛,中獎概率越低,一般需要購買彩票的次數就越多。相應的如果一個人中獎了,我們可以說這個人大概率上購買了非常多次彩票。這就是伯努利實驗與極大似然估計結合的通俗理解。

另外特別注意的,我們推測條件時,需要觀察的總次數的最大值,因為最大值代表了最小概率,而最小概率才是推測條件的依據。下文redis同理。

Redis中的實現

在redis中扔硬幣

redis實現本質也是利用了“扔硬幣”產生的“極大似然估計”原理,因此接下去我們就詳細看看redis是怎麼扔硬幣的。
在伯努利試驗的場景3中,我們做的實驗有3個特點:
1.硬幣只有正反兩面。
2.硬幣正反面出現的概率相同。
2.單次實驗需要投擲多次硬幣。

而計算機中的hash演算法正好可以滿足這3個條件:
1.hash結果的每一個bit只有0和1,代表硬幣的正反兩面。
2.如果hash演算法足夠好,得到的結果就足夠隨機,可以近似認為每一個bit的0和1產生的概率是相同的。
3.hash的結果如果是64個bit,正好代表投擲了64次硬幣。

因此執行一次hash,就相當於完整地進行了一次場景3中的投幣實驗。按照約定,實驗完成後,我們需要記錄硬幣投擲的結果。
假定現在有2個使用者id;user1、user2
先對user1進行hash,假定得到如下8個bit的結果:
10100100
此時從右到左,我們約定0表示反面,1表示正面,於是在這次實驗中,第一個為1的bit出現在第三位,相當於先投出了2次反面,然後投出1次正面,於是我們記錄下這次實驗的投擲次數為3。因為約定只要投出正面,當次實驗就結束,所以第一個1左邊的所有bit就不再考慮了。
再對user2進行hash,假定得到:
01101000
第一個為1的bit出現在第4位,於是記錄下4。
對於每個使用者的訪問請求,我們都可以對使用者的id進行hash(相當於場景3中進行一次實驗),並記錄下第一個為1的bit出現的位數(相當於場景3中記錄下硬幣的投擲次數),那麼通過記錄到的位數的最大值,我們就可以大概估計出一共進行了多少次實驗(相當於場景3中的反向推測),也就是有多少個不同的使用者發生了訪問。
例如某個頁面有若干個使用者進行了訪問,我們觀察記錄下的資料,發現記錄下的最大值是10,就意味著hash的結果至少出現了一次右邊9個bit都為0的情況。而這種情況發生的概率為1/1024,於是我們可以推測大概有1024個使用者訪問過該頁面,才有可能出現一次這種結果。

4.redis中的具體資料結構

在本文開頭,有說到redis使用了12kb的儲存空間來儲存hyperloglog的結果,那這12kb是如何具體分配的呢?接下去就來討論這個部分。

redis的分桶

要使用極大似然估計,需要可觀察的結果足夠多,但這個“足夠多”其實並沒有嚴謹的規定,和100比1萬也挺多了,但和100萬比較又顯得少了,況且觀察結果再多,誤差總是有的,一些極端情況也是有可能發生的(就像有的人可能買一次彩票就中獎了,有的人可能買一輩子也沒有中過)。為了減小這種誤差,redis將統計結果分散到了總計16384個桶中,在最終計算總的結果的時候,再將這每一個桶的統計結果再做一次調和平均,使得各種極端情況的影響降到最低。

資料儲存結構

redis採用的hash演算法能得到一個64bit的結果,前面講到redis進行了分桶,於是為了確定這個hash的結果需要放到哪個桶中,就需要拿出14個bit來計算桶的序號,2的14次方正好是16384。
確定好放入哪個桶後,剩下的50個bit就作為扔硬幣的實驗結果,而最壞的實驗結果是最左邊的bit為1,其他bit都為0:10000....0000,此時我們需要記錄的可能的最大數字就是50(即第一個為1的bit出現在第50位),而50的二進位制是110010,需要6個bit存放。因此對於任意的hash結果,一個桶最多最多隻需要6個bit就能存放下所有可能結果了
redis總共分了16384個桶,每個桶需要6bit,於是總計:$$16384\times6\div8\div1024=12kb$$
如下圖:

稀疏結構與密集結構

當redis剛建立完一個hyperloglog結構的時候,其中的所有bit都為0。為了避免重複資料對儲存空間的浪費,redis使用了幾種特殊的資料結構來表示重複資料:
ZERO : 一位元組,表示連續多少個桶計數為0,前兩位為標誌00,後6位表示有多少個桶,最大為64。
XZERO : 兩個位元組,表示連續多少個桶計數為0,前兩位為標誌01,後14位表示有多少個桶,最大為16384
VAL : 一位元組,表示連續多少個桶的計數為多少,前一位為標誌1,四位表示連桶內計數,所以最大表示桶的計數為32。後兩位表示連續多少個桶。
(ZERO和XZERO的區別在於如果連續為0的桶數量小於64個的時候,就沒必要用14個bit來表示數量,進一步節約空間)

當redis建立完一個新的hyperloglog結構時,因為其中的所有bit都為0,所以並不需要實際使用12kb的空間存放16384個0,而是用2個位元組的XZERO來表示:

經過使用者的少數幾次訪問後,redis可能用如下結構儲存:

當滿足如下條件時,就會從稀疏結構不可逆地變成密集結構:
1.任意一個val結構儲存的值達到33,超出了能儲存的最大值
2.稀疏結構的總位元組數超過3000位元組

最後回顧和總結一下本文的內容
1.hyperloglog適用於大資料量的去重統計
2.極大似然估計:當可觀察的結果足夠多時,我們可以“大概率”地推測出條件的狀態。
3.伯努利實驗:扔硬幣
4.伯努利實驗的極大似然估計:通過觀察“最小概率”出現的實驗結果,推測出實驗進行的“大概率”次數。
5.redis通過hash演算法,模擬伯努利實驗,從而“大概率”推測出進行hash的次數。
6.為了減少誤差,redis進行了分桶和調和平均
7.為了優化儲存,redis引入了稀疏結構

相關文章