玩轉Redis-HyperLogLog原理探索

  《玩轉Redis》系列文章主要講述Redis的基礎及中高級應用。本文是《玩轉Redis》系列第【10】篇,最新系列文章請前往公衆號“zxiaofan”查看,或百度搜索“玩轉Redis zxiaofan”即可。

本文關鍵字:玩轉Redis、HyperLogLog原理、基數緩存、密集存儲結構和稀疏存儲結構;

大綱

  • 伯努利試驗
  • HyperLogLog結構
  • HyperLogLog對象頭
  • pfcount及基數緩存
  • pfadd底層邏輯
  • 密集存儲結構和稀疏存儲結構
  • HyperLogLog引發的思考

名詞解釋:

1、基數:集合中不重複元素的個數;

2、HLL:HyperLogLog 的簡寫;

概要

  上文《玩轉Redis-HyperLogLog統計微博日活月活》介紹了牛逼哄哄的HyperLogLog,傳入元素數量或體積非常大時,HLL所需空間固定且很小。12kb內存可計算接近 2^64 個不同元素的基數。如此厲害,怎能不繼續深入探索呢?

PS:看完這篇文章,你會發現HyperLogLog能統計的基數值實際並不是 2^64 。

1. 伯努利試驗

  介紹HyperLogLog底層原理前,我們先了解下伯努利試驗(援引百度百科)。

  伯努利試驗(Bernoulli experiment)是在同樣的條件下重複地、相互獨立地進行的一種隨機試驗。

  其特點是該隨機試驗只有兩種可能結果:發生或者不發生。我們假設該項試驗獨立重複地進行了n次,那麼就稱這一系列重複獨立的隨機試驗爲n重伯努利試驗,或稱爲伯努利概型。

  以拋硬幣爲例,每次拋硬幣出現正面的概率都是50%。假設一直拋硬幣,直到出現正面,則一個伯努利試驗結束;

  假設第1次伯努利試驗拋硬幣的次數爲K1,第n次伯努利試驗拋硬幣的次數爲Kn。我們記錄這n次伯努利試驗的最大拋硬幣次數爲K_max;

  結合極大似然估算法,我們能得到估算關係 n = 2^(K_max)。當然,用這種方式計算的結果會有較大的偏差,HyperLogLog內部運用了 分桶平均、調和平均、偏差修正 等一系列數學優化方案,涉及較複雜的數學算法,此處暫不深入研究。

n重伯努利試驗

下圖的計算公式引自網絡: hhl計算公式

2. HyperLogLog結構

HyperLogLog總體分爲2大部分:對象頭、寄存器(桶)。

HYLL E N/U Cardin.
4 字節 1 字節 3 字節 8 字節

HLL源碼中結構體定義如下:

struct hllhdr {
    char magic[4];      /* "HYLL",對應源碼註釋中的HYLL*/
    uint8_t encoding;   /* HLL_DENSE or HLL_SPARSE.稀疏/密集存儲結構標記,對應源碼註釋中的E */
    uint8_t notused[3]; /* 保留3字節備用,目前未使用,值爲0,對應源碼註釋中的N/U */
    uint8_t card[8];    /* Cached cardinality(基數緩存), little endian. 對應源碼註釋中的Cardin,cardinality<基數> */
    uint8_t registers[]; /* Data bytes. */
};

  HyperLogLog底層結構有 dense(密集存儲結構) 和 sparse(稀疏存儲結構) 2種,無論哪種存儲結構,都有一個 16 byte 的對象頭(header);

HLL結構hlldhr

3. HyperLogLog對象頭(HLL header)

3.1. magic魔術字符串

  從定義看,magic佔用 4 byte ,存儲的是“HYLL”標記,那麼它究竟有什麼用呢?使用過HyperLogLog的同學肯定對下面這個異常不陌生;

127.0.0.1:6379&gt; set key1 e1
OK
127.0.0.1:6379&gt; pfadd key1
(error) WRONGTYPE Key is not a valid HyperLogLog string value.

127.0.0.1:6379&gt; pfadd hll1 a
(integer) 1
127.0.0.1:6379&gt; get hll1
"HYLL\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80q\xa6\x84NW"

  當我們對一個普通字符串使用pfadd指令時,會提示string value 不是HyperLogLog類型;

  由於HyperLogLog底層結構也是string,那麼Redis如何區分一個string僅僅是基礎的字符串還是HyperLogLog呢?

  原來在執行pf相關指令前,會調用方法 isHLLObjectOrReply() 檢查 value對象是否是 HyperLogLog 結構,如果不是則說明不是HyperLogLog結構,就會返回上述WRONGTYPE異常。

  isHLLObjectOrReply() 其中一項校驗就是檢查 value 的magic魔術字符串是否是"HYLL",不是則說明不是HyperLogLog結構。

  細心的同學一定發現了,使用get指令獲取HyperLogLog對象的值時,對象頭是以“HYLL”開頭的。

注意:

  如果pfcount 一個 不存在的 key,返回結果是0;

3.2. encoding存儲結構標記

  encoding:單字節編碼,HyperLogLog數據類型時值爲0或1:

  • 1表示 DENSE 密集存儲結構;
  • 0表示 SPARSE 稀疏存儲結構;

  先前提到的isHLLObjectOrReply()方法在檢查value對象是否是 HyperLogLog 結構時,也會檢查 encoding值:

  • 檢查encoding的值是否是0或1,不是則說明不是HyperLogLog結構;
  • 如果是密集存儲結構,還需要校驗對象長度是否和密集計數器長度相同;
# HyperLogLog源碼-校驗encoding值
# HLL_MAX_ENCODING定義值爲1,有趣的是此處不是用的( encoding !=0 || encoding != 1),而是直接(encoding &gt; 1);
if (hdr-&gt;encoding &gt; HLL_MAX_ENCODING) goto invalid;

3.3. notused和Cardin

notused:未使用的3個字節,目前未使用,值爲0 。

Cardin:The "Cardin." field is a 64 bit integer。8字節的 基數緩存,正因爲有了基數緩存,才讓pfcount更加高效;

4. 基數緩存

  在執行pfcount指令時,會返回HLL對象的基數,那麼這個基數是如何存儲及返回的呢?讓我們一探究竟:

  HLL的對象頭中有個Cardin基數緩存,存儲着HLL的基數,便於快速返回基數值。

  HyperLogLog 的基數值是由 16384 個寄存器(寄存器又可叫做桶,每個桶6bit)的基數值進行調和平均並修正而來。如果有桶位基數值改變,則將基數緩存標記置爲過期,需要特別注意的是,此時並不會重新計算基數值,需等執行pfcount指令時才重新計算並刷新緩存;

// 1左移14位(2^14),值爲 16384;

#define HLL_P 14 /* The greater is P, the smaller the error. */

#define HLL_REGISTERS (1&lt;<hll_p) * with p="14," 16384 registers. ``` &emsp;&emsp;執行pfcount指令時,先判斷基數緩存是否過期,未過期則直接返回緩存值,已過期則重新計算並緩存後再返回; &emsp;&emsp;但是這個基數並不一定是最新的,如果card最高位是0,則說明緩存有效。card共8位即64字節,1bit標記緩存是否有效,63bit存儲基數值; pfcount 核心邏輯 [@zxiaofan](https://my.oschina.net/u/2602653) void pfcountcommand(client *c) { if (多key) 合併多個hll後計算基數並返回(注意:不是基數值之和); } (單key且value不存在) 單key且value不存在返回0; else 單key且value存在 (hll_valid_cache(hdr)) 緩存有效 直接返回基數緩存值; 緩存無效 計算基數值; 更新基數緩存標記及基數緩存值; hll_invalidate_cache:將基數緩存置爲失效狀態(最高位設置爲1); #define hll_invalidate_cache(hdr) (hdr)->card[7] |= (1&lt;&lt;7)


// HLL_VALID_CACHE:校驗緩存是否有效;
// 最高位如果爲0,表示緩存有效,pfcount查詢基數時直接返回緩存值;
#define HLL_VALID_CACHE(hdr) (((hdr)-&gt;card[7] &amp; (1&lt;&lt;7)) == 0)

5、pfadd底層邏輯

pfadd key value

  HLL有2^14 = 16384個桶,每個桶有 6 bit;

  pfadd時需要計算2個值:桶位(寄存器編號) regnum、桶值(寄存器計數值)count;

  • 使用MurmurHash64A算法對value進行hash,結果爲64bit的hash值(比特串);
  • 取hash值的前14位(低14位,從右邊開始計算)用於計算桶編號,將14位的二進制值轉爲10進制,這個值就是 regnum;
  • 從hash值第15位開始,統計第一個1出現的位置(從1開始計數)(源碼中寫的是連續0的個數+1),此值爲 count,count最大爲50;
  • 根據 regnum 查詢 對應桶位先前的值 oldcount;
  • 如果 count 值大於 oldcount,則更新值爲count;

讓我們看個實際的例子:

假設hash值是 :{此處省略45位}01100 00000000000101

  • 前14位的二進制轉爲10進制,值爲5(regnum),即我們把數據放在第5個桶;
  • 後50位第一個1的位置是3,即count值爲3;
  • registers[5]取出歷史值oldcount,
  • 如果count > oldcount,則更新 registers[5] = count;
  • 如果count <= oldcount,則不做任何處理;

pfadd

6、密集存儲結構和稀疏存儲結構

前文我們提到,HLL底層有2種存儲結構:稀疏和密集。

6.1、密集存儲結構

密集存儲結構相對很簡單,由連續的16384個寄存器(桶)拼接而成,每個桶6 bit。

dense密集存儲結構

但由於一個桶只有6 bit,在計算一個桶的計數值時,可能需要 2個桶拼接計算,即會涉及到2個字節。

需要注意的是,HHL的字節都是左低位右高位,我們平時計算使用的字節都是左高位右低位(如二進制 0101 表示 十進制 5),所以需要倒置後進行計算。我們來個簡單的圖理解下,寄存器registers表示HLL的存儲單元,字節buffer是底層存儲結構。

dense密集存儲結構寄存器示意圖

  以上圖例僅爲方便理解,HLL實際存儲計算方式更加複雜,有興趣的同學可以看看以下源碼:

/* Store the value of the register at position 'regnum' into variable 'target'.
 * 'p' is an array of unsigned bytes. */
 
/* 獲取密集存儲結構指定寄存器的值
target:變量,用戶存放指定寄存器編號regnum目前的計數值;
p:寄存器;
regnum:寄存器編號;
*/


#define HLL_BITS 6 /* Enough to count up to 63 leading zeroes. */
#define HLL_REGISTER_MAX ((1&lt;<hll_bits)-1) #define hll_dense_get_register(target,p,regnum) do { \ uint8_t *_p="(uint8_t*)" p; unsigned long _byte="regnum*HLL_BITS/8;" _fb="regnum*HLL_BITS&amp;7;" _fb8="8" - _fb; b0="_p[_byte];" b1="_p[_byte+1];" target="((b0">&gt; _fb) | (b1 &lt;&lt; _fb8)) &amp; HLL_REGISTER_MAX; \
} while(0)

/* Set the value of the register at position 'regnum' to 'val'.
 * 'p' is an array of unsigned bytes. */
 
/* 密集存儲結構 設置寄存器指定編號(桶位)的值
p:寄存器;
regnum:寄存器編號;
val:待設置的計數值;
*/

#define HLL_DENSE_SET_REGISTER(p,regnum,val) do { \
    uint8_t *_p = (uint8_t*) p; \
    unsigned long _byte = regnum*HLL_BITS/8; \
    unsigned long _fb = regnum*HLL_BITS&amp;7; \
    unsigned long _fb8 = 8 - _fb; \
    unsigned long _v = val; \
    _p[_byte] &amp;= ~(HLL_REGISTER_MAX &lt;&lt; _fb); \
    _p[_byte] |= _v &lt;&lt; _fb; \
    _p[_byte+1] &amp;= ~(HLL_REGISTER_MAX &gt;&gt; _fb8); \
    _p[_byte+1] |= _v &gt;&gt; _fb8; \
} while(0)

6.2、稀疏存儲結構

  爲什麼會有稀疏存儲結構呢?試想一下,在HLL初始化後,僅少量數據存入,此時大量的寄存器register(桶)的值都是0,大量的6bit寄存器空間也就完全浪費了。

Redis採用瞭如下opcode(指令)極致優化空間佔用:

  • ZERO:表示連續多少個桶計數爲0(桶數最大64);
    • 前2位:標誌位00;
    • 後6位:表示桶數;
    • 佔用1字節;
  • XZERO:表示連續多少個桶計數爲0(桶數最大16384);
    • 前2位:標誌位01;
    • 後14位:表示桶數;
    • 佔用2字節;
  • VAL:表示連續xx個計數爲v的桶;
    • 前1位:標誌位1;
    • 中間5位:表示值;
    • 最後2位:表示桶數;
    • 佔用1字節;
    • 示例:1vvvvvxx,xx值最大32,vvvvv數值+1;

SPARSE稀疏存儲結構指令

現在回過頭來看看先前的HLL結構圖是不是就更清晰了呢。

HLL結構hlldhr

6.3、稀疏存儲結構何時轉爲密集存儲結構

轉換條件有2個,滿足任意一個就會立即轉換:

  • 任意一個寄存器的值從32變成33(前面已經提到,稀疏存儲結構下val的最大值是 32,代碼中對應變量是 HLL_SPARSE_VAL_MAX_VALUE = 32);
  • 稀疏存儲佔用的總字節數超過 3000 字節(這個閾值可以通過redis.conf配置文件的 hll-sparse-max-bytes 進行調整)。

注意:

  • 稀疏轉爲密集存儲結構是不可逆的;
  • hll-sparse-max-bytes 配置超過16000就沒有意義了,如果CPU資源足夠,但內存資源緊張時,建議設置成10000;
# this limit, it is converted into the dense representation.
#
# A value greater than 16000 is totally useless, since at that point the
# dense representation is more memory efficient.
#
# The suggested value is ~ 3000 in order to have the benefits of
# the space efficient encoding without slowing down too much PFADD,
# which is O(N) with the sparse encoding. The value can be raised to
# ~ 10000 when CPU is not a concern, but space is, and the data set is
# composed of many HyperLogLogs with cardinality in the 0 - 15000 range.

hll-sparse-max-bytes 3000

7、HyperLogLog引發的思考

  首先補充一個非常實用的網站,可以在線動態觀察HyperLogLog算法。 http://content.research.neustar.biz/blog/hll.html HyperLogLog算法動態觀察 需要注意的是,此網站 value hash後的值是24位,不是HyperLogLog MurmurHash64A()後的64位,不過也不影響觀察學習其原理了。

7.1、pf 的內存佔用爲什麼是 12k?

HyperLogLog實際使用了 2^14 = 16384 個桶,每個桶 6bit,最大佔用內存即 2^14 * 6 / 8 = 12288 Byte = 12 KB(不多不少剛好12KB)。

補充1:HyperLogLog實際最大存儲空間

HyperLogLog實際佔用的空間爲 【header】 + 【寄存器所佔用的空間】,所以HyperLogLog實際佔用空間會比12KB略多一點兒。

補充2:HyperLogLog實際最小存儲空間

  HyperLogLog最小存儲空間是多少呢?當HHL基數爲1時,1字節表示基數,XZERO佔用2字節表示餘下所有的零值計數器,所以 HyperLogLog最小存儲空間是3字節略多一點兒(算上header佔用)。

7.2、HyperLogLog最大統計的數量是 2^64 嗎?

  MurmurHash64A算法對value進行hash,結果爲64bit的hash值,64位二進制最大表示的十進制是 2^64 ,所以最大可統計基數值就是 2^64 了嗎?

  但還需要注意的是,header中8字節的 基數緩存Cardin,1位表示緩存是否有效,63位表示基數值,所以HyperLogLog實際能統計的最大數量是 2^63

7.3、稀疏存儲結構爲什麼既有ZERO又有XZERO呢?

  ZERO:表示連續多少個桶計數爲0(桶數最大64);

  XZERO:表示連續多少個桶計數爲0(桶數最大16384);

  因爲1個寄存器6bit,最多可表示 2^6 = 64 個 零值計數器,所以需要XZERO來表示更多的零值計數器。

7.4、我們能從HyperLogLog中學到什麼?

【需要才計算】

  在計算基數緩存時,HLL並沒有在每次寄存器更新時就計算,而是執行pfcount指令時才計算基數值並緩存。

  HLL不立即計算本質上是因爲pfadd的執行頻率遠高於pfcount,所以我們也不能一味的採用此種思想。

  適合的纔是最好的。

【存儲結構轉換】

  爲了最大化節省內存空間,也算是煞費苦心了。

7.5、HyperLogLog怎麼讀?

  學原汁原味的讀音,可前往YouTube的 Redis University 頻道拜聽《Redis HyperLogLog Explained》

讀音:[ˈhaɪpərlɔːɡlɔːɡ]。

後記

  恭喜你讀完本篇文章了,給你贊一個,HyperLogLog的底層原理你get到了嗎。

  HyperLogLog爲什麼有如此大的魅力,本質是數學的魅力。

  基礎科學任重而道遠。

> 搞芯片光砸錢不行還要砸數學家物理學家化學家。(任正非)

【玩轉Redis系列文章 近期精選 @zxiaofan】
《玩轉Redis-HyperLogLog統計微博日活月活》

《玩轉Redis-京東簽到領京豆如何實現》

《玩轉Redis-老闆帶你深入理解分佈式鎖》

《玩轉Redis-如何高效訪問Redis中的海量數據》

《玩轉Redis-高級程序員必知的Key命令》

《玩轉Redis-研發也應該知道的Connection命令》

《玩轉Redis-Redis高級數據結構及核心命令-ZSet》


>祝君好運!
Life is all about choices!
將來的你一定會感激現在拼命的自己!
CSDN】【GitHub】【OSCHINA】【掘金】【語雀】【微信公衆號】 ---</hll_bits)-1)></hll_p)></基數>

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章