一、HyperLogLog 概述
HyperLogLog 算法一種概率算法,用來實現大數據下的基數估計,即無法精確計算集合的基數,存在一定偏差。具體的算法實現可以參見我寫的另一篇文章:夜深人靜寫算法(十四)- 基數估計 (Cardinality Estimation)。
Redis 對這個算法進行了一些改進。主要有以下幾點:
a) 採用 64 位哈希函數,這樣基數數值就不用侷限在 10^9 了,當基數在 2^32 附近的時候,不需要額外進行修正;
b) 採用 16384 個 6比特的寄存器(桶)提高計算的精確度,每個集合的內存總數僅有 12K (16384 * 6 / 8 / 1024 = 12);
c) 存儲採用 Redis 自帶的 sds 字符串(Redis底層詳解(二) 字符串),並提供了兩種存儲方式:密集型存儲 和 稀疏型存儲;
1、算法概述
改進後算法的簡介如下:
給定一個集合 S,集合中的元素可以是 整數、指針、字符串 等等任意對象。給定一個 64 位的哈希函數 H,能夠將這些對象進行映射,映射後的值是一個 64 位的比特串(每位是 0 或 1),比特串的低 14 位用來表示寄存器(也稱爲桶)編號,剩下 50 位用來尋找“最長 0 尾綴”。一共 16384 (2 的 14次冪) 個寄存器,用來存儲 “最長 0 尾綴”(原算法用的是前綴,Redis爲了方便計算採用尾綴的形式,其實是一樣的)。然後計算這些 “最長 0 尾綴” 的調和平均數得到一個初始估值 E。然後根據 E 的相對大小再進行二次估值 得到最後的集合基數。其中 E 的計算公式如下:
m 即寄存器的數量,M[i] 代表第 i 個寄存器中的 “最長 0 尾綴” 的值,C 在 m 確定的情況下是個常量,E 就是調和平均數。
2、存儲優化
由於當集合元素較少時,大多數的寄存器的值爲 0,所以在存儲時,可以採用 Run Length Encoding 進行壓縮存儲,這種壓縮後的存儲方式就是稀疏型存儲(HLL_SPARSE),對應的,未壓縮前的存儲方式就是密集型存儲(HLL_DENSE)。兩種存儲方式對應的核心算法是一致的,只不過在持久化時採用不同的存儲方式,介於篇幅的關係,本文只介紹密集型存儲。
二、HyperLogLog 結構
1、HyperLogLog 頭
無論是密集型存儲還是稀疏型存儲,都有一個頭結構 hllhdr,定義如下:
struct hllhdr {
char magic[4];
uint8_t encoding;
uint8_t notused[3];
uint8_t card[8];
uint8_t registers[];
};
#define HLL_HDR_SIZE sizeof(struct hllhdr)
#define HLL_DENSE_SIZE (HLL_HDR_SIZE+((HLL_REGISTERS*HLL_BITS+7)/8))
#define HLL_DENSE 0
#define HLL_SPARSE 1
magic[4] 爲四個 “魔法” (固定不變的意思)字符,即 “HYLL”,代表這是一個 HyperLogLog 結構,區分於普通的 sds 字符串;
encoding 代表存儲的編碼方式,總共兩種:密集型編碼存儲 HLL_DENSE、稀疏型編碼存儲 HLL_SPARSE;
notused[3] 爲三個暫時不用到的保留字節,值爲0;
card[8] 以小端序存儲了一個 64 位的整數,代表上次計算出來的近似集合基數。這個是很有用的,因爲每次在執行集合插入的過程中,有很大概率是不會改變原有的數據結構的,這樣這個近似的基數值就可以直接拿過來用而不需要重新計算。
registers[] 存儲了寄存器,根據 encoding 來決定存儲的編碼方式。寄存器的作用會在下文中詳述。以下是 HyperLogLog 的內存結構的示意圖(可以理解成整個 HyperLogLog 結構就是一個以 "HYLL" 爲首的 sds 字符串):
2、HyperLogLog 寄存器
寄存器(register)又名 桶 (bucket),每個寄存器用來存儲分配給它的元素在進行哈希後的 “最長 0 尾綴” 的值,HyperLogLog 正是利用所有寄存器的值來計算調和平均數,最終得到集合的基數的初始估計值。一些寄存器常量定義如下:
#define HLL_P 14
#define HLL_REGISTERS (1<<HLL_P)
#define HLL_P_MASK (HLL_REGISTERS-1)
#define HLL_BITS 6
#define HLL_REGISTER_MAX ((1<<HLL_BITS)-1)
HLL_P 的值越大,計算得到的基數的誤差越小。HLL_REGISTERS 是寄存器的總數量,值爲 16384 。HLL_P_MASK 用來將取模運算轉化成 位與,下面會講到。
三、HyperLogLog 算法詳解
1、最長 0 尾綴
在分析 HyperLogLog 算法之前,先來看下之前提到的 “最長 0 尾綴” 的實現方式,hllPatLen 的作用是將傳入的字符串 ele,通過 MurmurHash64 哈希算法得到一個 64 比特的串,然後將這個串的低 14 位作爲寄存器編號,高 50 用來計算 “0” 尾綴。那麼顧名思義,“最長 0 尾綴” 就是在寄存器編號相同的情況下,“0” 尾綴的最大值。實現如下:
int hllPatLen(unsigned char *ele, size_t elesize, long *regp) {
uint64_t hash, bit, index;
int count;
hash = MurmurHash64A(ele,elesize,0xadc83b19ULL); /* a */
index = hash & HLL_P_MASK; /* b */
hash |= ((uint64_t)1<<63); /* c */
bit = HLL_REGISTERS;
count = 1;
while((hash & bit) == 0) {
count++;
bit <<= 1;
}
*regp = (int) index;
return count;
}
a、MurmurHash64A 是一個哈希算法:MurmurHash2。通過傳入的字符串計算出一個 64 位的整數 (其中 0xadc83b19ULL 是哈希計算時的種子 seed)。Redis 中的這個函數修改了一下,支持大端序和小端序兩種版本。
b、hash & HLL_P_MASK 的含義是 hash % HLL_REGISTERS,作用是通過取模找到對應的寄存器。用 & 有兩個好處:位運算速度遠快於取模;取模可能產生負數,而 & 不會。寄存器編號會存儲在 regp 並通過指針的方式返回出去。
c、從 hash 這個 64 位整數的低位開始第 14 位 (即 HLL_P,0 - based)開始往高位一位一位的找,找到第一個值爲 1 的位,中間如果有 k 個 0,則 “0” 尾綴的值 count = k + 1。爲了不在 while 時產生死循環,將 hash 的最高位 (第 63 位) 用位或設置爲 1。
如下圖所示,分別表示兩個 hash 值的 64 個比特位,內存從上往下遞增,每個字節的右側爲比特位的低位(LSB),左側爲高位(MSB)。第一個 hash 從 14 位開始遍歷 4 次找到第一個 1,所以“0” 尾綴的值爲4;第二個 hash 的 14 位已經是 1 了,所以“0” 尾綴的值爲 1 。
那麼,所有的集合元素計算完畢,每個寄存器的值(最長0尾綴的值)也就得到了。
2、密集型存儲
密集型存儲相對於稀疏型存儲來說比較簡單,採用緊湊型的編碼方式,每 HLL_BITS (默認爲 6)個比特位存儲一個寄存器,所以寄存器能夠存儲的最大值爲 63 (二進制表示爲 6 個 1),即 HLL_REGISTER_MAX 宏定義。
但是計算機存儲的基本單元是字節,一個字節 8 個比特位,所以爲了節約內存,某些寄存器是跨字節存儲的。
如下圖所示,紅色代表 0 號寄存器的 6個比特位,橙色代表 1 號寄存器,黃色代表 2 號,以此類推。6 和 8 的最小公倍數爲 24,所以 3個字節一個週期。每 3 個字節存儲 4 個寄存器,總共 16384 個寄存器,佔用字節數爲 16384 / 4 * 3 = 12288 = 12K。
3、寄存器值獲取
寄存器的獲取通過宏 HLL_DENSE_GET_REGISTER 實現。源碼實現如下:
#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; \
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)
target 用於存儲寄存器的值,當返回值用;p 是所有寄存器內存首地址;regnum 表示第幾個寄存器(0-based)。這個宏定義的含義就是獲取第 regnum 個寄存器存的值並且返回到 target 中。讓我們來看下這些位運算的含義。
首先,由於寄存器的長度(6個比特位)小於 1 個字節的比特位長度,所以每個寄存器最多跨越兩個字節,我們定義內存地址小的叫低字節,地址大的叫高字節。所以低字節的高 k 位 和 高字節的低 (6 - k) 位組成了一個完整的寄存器。如圖所示,淺藍色部分是一個完整的寄存器地址,它由低字節的高 2 位和高字節的低 4 位組成。
_byte 用來計算第 regnum 個寄存器的低字節相對於 p 的字節偏移量。那麼 p[_byte] 和 p[_byte+1] 就分別代表了上文提到的低字節和高字節的值。_fb 的 “位與 7 ” 相當於 “模 8 ”,再用 8 去減後得到 _fb8,這裏的 _fb8 就是上圖中得到的 k 。最後 target 的值分四步獲得:
(1) 低字節的值右移 ( 8 - k ) 位,得到 x;
(2) 高字節的值左移 k,得到 y;
(3) x 位或 y,得到 z;
(4) z 位與 HLL_REGISTER_MAX,截掉無用高位,得到最終值 target;
舉例說明,當 regnum = 5 時,則需要取到藍色寄存器的值,k 的值爲 8 - 5 * 6 % 8 = 2。計算 target 的過程如下:
4、寄存器值設置
寄存器的設置通過宏 HLL_DENSE_SET_REGISTER 實現。源碼實現如下:
#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&7; \
unsigned long _fb8 = 8 - _fb; \
unsigned long _v = val; \
_p[_byte] &= ~(HLL_REGISTER_MAX << _fb); \
_p[_byte] |= _v << _fb; \
_p[_byte+1] &= ~(HLL_REGISTER_MAX >> _fb8); \
_p[_byte+1] |= _v >> _fb8; \
} while(0)
這個宏定義的含義就是將第 regnum 個寄存器存的值設置爲 val 。和獲取一樣,同樣需要分成高低兩字節進行設置。假設低字節的高 k 位 和 高字節的低 (6 - k) 位組成了一個完整的寄存器。 那麼只要截取 val 的低 k 位 和 高 (6 - k) 位分別放到寄存器的對應位置就行了。
對於寄存器的低字節操作如下:
(1) HLL_REGISTER_MAX 左移 (8 - k) 位 , 然後取反得到 x ,使得 x 的高 k 位等於 0;
(2) 低字節位與上 x,目的是清空低字節的高 k 位的值;
(3) val 左移 (8 - k) 位得到 y ;
(4) 低字節 位或 y ,將 val 的低 k 位填充到 低字節的高 k 位上;
舉例說明,當 regnum = 5 時,則需要設置藍色寄存器的值,k 的值爲 8 - 5 * 6 % 8 = 2。低字節的計算過程如下:
對於寄存器的高字節操作如下:
(1) HLL_REGISTER_MAX 右移 k 位 , 然後取反得到 x ,使得 x 的低 (6 - k) 位等於 0;
(2) 高字節位與上 x,目的是清空高字節的低 (6 - k) 位的值;
(3) val 右移 k 位得到 y ;
(4) 高字節 位或 y ,將 val 的高 (6 - k) 位填充到 高字節的低 (6 - k) 位上;
舉例說明,同樣當 regnum = 5 時,則需要設置藍色寄存器的值,k 的值爲 8 - 5 * 6 % 8 = 2。高字節的計算過程如下:
結合以上兩種情況,我們就將 val 的值設置到藍色寄存器上了,如圖所示(val 前兩個比特位爲虛線是因爲 val 的值不會超過 2 的 6 次 減 1,即 63):
5、集合添加元素
集合增加元素調用 hllAdd 接口,這對應的是更新 "最長 0 尾綴" 的過程,實現如下:
int hllAdd(robj *o, unsigned char *ele, size_t elesize) {
struct hllhdr *hdr = o->ptr;
switch(hdr->encoding) {
case HLL_DENSE: return hllDenseAdd(hdr->registers,ele,elesize);
case HLL_SPARSE: return hllSparseAdd(o,ele,elesize);
default: return -1;
}
}
根據存儲方式選擇不同的接口,密集型存儲調用 hllDenseAdd,稀疏型存儲調用 hllSparseAdd。稀疏型存儲的實現相對較複雜,這裏以密集型存儲實現爲例來說明實現機理。添加元素的過程的並非真正的元素添加,只有當傳入的字符串算出來的 "最長 0 尾綴" 比對應寄存器中存的值大的時候才做更新,否則保持原樣。主要用到了上文提到的兩個宏定義:HLL_DENSE_GET_REGISTER 和 HLL_DENSE_SET_REGISTER。源碼在 hyperloglog.c 中,實現如下:
int hllDenseAdd(uint8_t *registers, unsigned char *ele, size_t elesize) {
uint8_t oldcount, count;
long index;
count = hllPatLen(ele,elesize,&index);
HLL_DENSE_GET_REGISTER(oldcount,registers,index);
if (count > oldcount) {
HLL_DENSE_SET_REGISTER(registers,index,count);
return 1;
} else {
return 0;
}
}
6、集合元素統計
統計就是利用 16384 個寄存器計算調和平均數的過程,調用 hllCount 接口完成統計工作,實現如下:
uint64_t hllCount(struct hllhdr *hdr, int *invalid) {
double m = HLL_REGISTERS;
double E, alpha = 0.7213/(1+1.079/m);
int j, ez;
... /* a */
if (hdr->encoding == HLL_DENSE) { /* b */
E = hllDenseSum(hdr->registers,PE,&ez);
} else if (hdr->encoding == HLL_SPARSE) {
E = hllSparseSum(hdr->registers,
sdslen((sds)hdr)-HLL_HDR_SIZE,PE,&ez,invalid);
} else if (hdr->encoding == HLL_RAW) {
E = hllRawSum(hdr->registers,PE,&ez);
} else {
serverPanic("Unknown HyperLogLog encoding in hllCount()");
}
E = (1/E)*alpha*m*m; /* c */
if (E < m*2.5 && ez != 0) {
E = m*log(m/ez); /* d */
} else if (m == 16384 && E < 72000) {
double bias = 5.9119*1.0e-18*(E*E*E*E) /* e */
-1.4253*1.0e-12*(E*E*E)+
1.2940*1.0e-7*(E*E)
-5.2921*1.0e-3*E+
83.3216;
E -= E*(bias/100);
}
return (uint64_t) E;
}
a) 初始化計算 PE 的過程,PE[i] = 2 ^ (-i),爲了避免重複計算,節省時間,所以預處理在數組 PE[] 中;
b) hllDenseSum、hllSparseSum 分別表示在不同存儲方式下計算調和平均數的分母(下文作詳細講解)。hllRawSum 是原生的存儲方式,用1個字節代表一個寄存器,不對外開放,用於加速計算;
c) 這個公式是本文開始提到的初始估值的計算公式;
d) 數據量比較小時採用線性估值(Linear Counting);
e) 誤差修正;
hllDenseSum 求的是如下式子,其中 r 爲寄存器數組,max 是寄存器數量:
先來看代碼如何實現的,同樣在 hyperloglog.c 中:
double hllDenseSum(uint8_t *registers, double *PE, int *ezp) {
double E = 0;
int j, ez = 0;
if (HLL_REGISTERS == 16384 && HLL_BITS == 6) {
uint8_t *r = registers;
unsigned long r0, r1, r2, r3, r4, r5, r6, r7, r8, r9,
r10, r11, r12, r13, r14, r15;
for (j = 0; j < 1024; j++) {
r0 = r[0] & 63; if (r0 == 0) ez++;
r1 = (r[0] >> 6 | r[1] << 2) & 63; if (r1 == 0) ez++;
....... 此處省略,節約篇幅
r14 = (r[10] >> 4 | r[11] << 4) & 63; if (r14 == 0) ez++;
r15 = (r[11] >> 2) & 63; if (r15 == 0) ez++;
E += (PE[r0] + PE[r1]) + (PE[r2] + PE[r3]) + (PE[r4] + PE[r5]) +
(PE[r6] + PE[r7]) + (PE[r8] + PE[r9]) + (PE[r10] + PE[r11]) +
(PE[r12] + PE[r13]) + (PE[r14] + PE[r15]);
r += 12;
}
} else {
for (j = 0; j < HLL_REGISTERS; j++) {
unsigned long reg;
HLL_DENSE_GET_REGISTER(reg,registers,j);
if (reg == 0) ez++;
else E += PE[reg];
}
E += ez;
}
*ezp = ez;
return E;
}
計算密集型存儲,各個寄存器的相反數作爲指數,且底數爲 2 的冪的和。其中 PE[i] = 2 ^ (-i),爲了避免重複計算,節省時間,所以預處理在數組 PE[] 中。*ezp 記錄了值爲 0 的寄存器數量。爲了提高效率,當 HLL_REGISTERS 等於 16384 且 HLL_BITS 等於 6 時,這種屬於默認情況,所以爲了提高效率寫了一堆位運算進行優化。如果已經理解寄存器的存儲結構,那麼這段代碼很容易理解,它的執行效果和 else 語句裏執行 HLL_REGISTERS 次 for 的效果是一樣的,都是爲了求 E = Sum(r, max)。
四、HyperLogLog 常用命令
1、pfadd 向集合中增加元素
pfadd key element [element ...]
127.0.0.1:6370> pfadd hyperloglog_key1 1 2 3 4 5 6
(integer) 1
127.0.0.1:6370> pfadd hyperloglog_key2 0 3 4 5
(integer) 1
2、pfcount 統計集合中元素個數
pfcount key [key ...]
127.0.0.1:6370> pfcount hyperloglog_key1
(integer) 6
127.0.0.1:6370> pfcount hyperloglog_key2
(integer) 4
127.0.0.1:6370> pfcount hyperloglog_key1 hyperloglog_key2
(integer) 7
3、pfmerge 對集合作合併操作
pfmerge destkey sourcekey [sourcekey ...]
127.0.0.1:6370[1]> pfmerge hyperloglog_keymerge hyperloglog_key1 hyperloglog_key2
OK
127.0.0.1:6370[1]> pfcount hyperloglog_keymerge
(integer) 7
由於 HyperLogLog 結構並沒有存儲實際的集合元素,所以無法輸出集合的成員。