Redis原始碼剖析--基數統計hyperloglog
Redis中hyperloglog是用來做基數統計的,其優點是:在輸入元素的數量或者體積非常非常大的時候,計算基數所需的空間總是固定的,並且是很小的。在Redis裡面,每個Hyperloglog鍵只需要12Kb的大小就能計算接近2^64個不同元素的基數,但是hyperloglog只會根據輸入元素來計算基數,而不會儲存元素本身,所以不能像集合那樣返回各個元素本身。
基數統計
什麼是基數呢?基數是指一個集合中不同元素的個數。假設有一組資料\(\{1,2,3,3,4,5,4,6\}\),除去重複的數字之後,該組資料中不同的數有6個,則該組資料的基數為6。
那什麼是基數統計呢?基數統計是指在誤差允許的情況下估算出一組資料的誤差。
從上述的概念中,我們可以很容易想到基數統計的用途,假設需要計算出某個網站一天中的獨立ip訪問量,相同ip訪問多次的話值算作一次。這個問題即可轉換成求一天內所有訪問該網站的ip陣列的基數。關鍵在於如何求這個基數?下面我就以最易懂的方法來給大家講一下。
演算法思路
伯努利過程
投擲一次硬幣出現正、反兩面的概率均為\(1/2\)。如果我們不斷的投擲硬幣,直到出現一次正面,在這樣的一個過程中,投擲一次得到正面的概率為\(1/2\),投擲兩次才得到正面的概率為\(1/2^{2}\)….依次類推,投擲k次才得到一次正面的概率為\(1/2^{k}\)。這個過程在統計學上稱為伯努利問題。
有了以上的分析後,我們繼續來思考下面兩個問題:
- 進行n次伯努利過程,所有投擲次數都小於k的概率
- 進行n次伯努利過程,所有投擲次數都大於k的概率
針對第一個問題,在一次伯努利過程中,投擲次數大於k的概率為\(1/2^{k}\),也就是投了k次反面的概率。因此,在一次過程中投擲次數不大於k的概率為\(1-1/2^{k}\)。因此n次伯努利過程所有投擲次數都不大於k的概率為
很顯然,第二個問題,n次伯努利過程,所有投擲次數都不小於k的概率為
從上述公式中可得出結論:當n遠小於\(2^{k}\)時,\(P(x \geq k)\)幾乎為0,即所有投擲次數都小於k;當n遠大於\(2^{k}\)時,\(P(x \leq k)\)幾乎為0,即所有投擲次數都大於k。因此,當x=k的情況下,我們可以把\(2^{k}\)當成n的一個粗糙估計。
基數統計
將上述伯努利過程轉換到位元位串上,假設我們有8位位元位串,每一位上出現0或者1的概率均為\(1/2\),投擲k次才得到一次正面的過程可以理解為第k位上出現第一個1的過程。
那麼針對一個數據集來說,我們用某種變換將其轉換成一個位元子串,就可以根據上述理論來估算出該資料集的技術。例如資料集轉換成00001111,第一次出現1的位置為4,那麼該資料集的基數為16。
於是現在的問題就是如何將資料集轉換成一個位元位串?很明顯,雜湊變換可以幫助我們解決這個問題。
選取一個雜湊函式,該函式滿足一下條件:
- 具有很好的均勻性,無論原始資料集分佈如何,其雜湊值幾乎服從均勻分佈。這就保證了伯努利過程中的概率均為1/2
- 碰撞幾乎忽略不計,也就是說,對於不同的原始值,其雜湊結果相同的概率幾乎為0
- 雜湊得出的結果位元位數是固定的。
有了以上這些條件,就可以保證”伯努利過程“的隨機性和均勻分佈了。
接下來,對於某個資料集,其基數為n,將其中的每一個元素都進行上述的雜湊變換,這樣就得到了一組固定長度的位元位串,設\(f(i)\)為第i個元素位元位上第一次出現”1“的位置,簡單的取其最大值\(f_{max}\)為\(f(i)\)的最大值,這樣,我們就可以得出以下結論:
- 當n遠小於\(2^{f_{max}}\)時,\(f{max}\)為當前值的概率為0
- 當n遠大於\(2^{f_{max}}\)時,\(f{max}\)為當前值的概率為0
這樣一來,我們就可以將\(2^{f_{max}}\)作為n的一個粗糙估計。當然,在實際應用中,由於資料存在偶然性,會導致估計量誤差較大,這時候需要採用分組估計來消除誤差,並且進行偏差修正。
所謂分組估計就是,每一個數據進行hash之後存放在不同的桶中,然後計算每一個桶的\(f_{max}\),最後對這些值求一個平均favg,即可得到基數的粗糙估計\(2^{favg}\)。
hyperloglog實現
資料結構
每個hyperloglog鍵由一下結構體組成:
struct hllhdr {
char magic[4]; // 固定‘HYLL’,用於標識hyperloglog鍵
uint8_t encoding; // 編碼模式,有密集標識Dence和稀疏模式sparse
uint8_t notused[3]; // 未使用欄位,留著日後用
uint8_t card[8]; // 基數快取,儲存上一次計算的基數
uint8_t registers[]; // 桶個數,用來存放資料,Redis中大小為16384
};
新增元素
Redis提供一下命令來向hyperloglog鍵中新增資料。
PFADD key element [element ...]
其原始碼實現如下:
void pfaddCommand(client *c) {
robj *o = lookupKeyWrite(c->db,c->argv[1]);
struct hllhdr *hdr;
int updated = 0, j;
// 客戶端互動部分,此處可以放著以後理解
if (o == NULL) {
// 建立一個hyperloglog鍵
o = createHLLObject();
dbAdd(c->db,c->argv[1],o);
updated++;
} else {
// 判斷是否是一個hyperloglog鍵,判斷前四個位元組是否為'HYLL'
if (isHLLObjectOrReply(c,o) != C_OK) return;
o = dbUnshareStringValue(c->db,c->argv[1],o);
}
// 呼叫hllAdd函式來新增元素
for (j = 2; j < c->argc; j++) {
int retval = hllAdd(o, (unsigned char*)c->argv[j]->ptr,
sdslen(c->argv[j]->ptr));
switch(retval) {
case 1:
updated++;
break;
case -1:
addReplySds(c,sdsnew(invalid_hll_err));
return;
}
}
hdr = o->ptr;
if (updated) {
signalModifiedKey(c->db,c->argv[1]);
notifyKeyspaceEvent(NOTIFY_STRING,"pfadd",c->argv[1],c->db->id);
server.dirty++;
HLL_INVALIDATE_CACHE(hdr);
}
// 客戶端互動部分,此處可以放著以後理解
addReply(c, updated ? shared.cone : shared.czero);
}
上述程式碼包含了很多與客戶端互動的部分,此處可以先不看,新增元素主要由hllAdd函式實現。
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為例,首先計算待新增元素的第一次出現“1”的位置count和該元素新增到第index個桶內,然後檢視registers部分,第index個桶內此時存放的第一次出現“1”的最大的位數oldcount,比較oldcount和count,如果前者大,則不處理;如果後者大,更新oldcount為count。
// 密集模式新增元素
int hllDenseAdd(uint8_t *registers, unsigned char *ele, size_t elesize) {
uint8_t oldcount, count;
long index;
// 計算該元素第一個1出現的位置
count = hllPatLen(ele,elesize,&index);
// 得到第index個桶內的count值
HLL_DENSE_GET_REGISTER(oldcount,registers,index);
if (count > oldcount) {
// 如果比現有的最大值還大,則新增該值到資料部分
HLL_DENSE_SET_REGISTER(registers,index,count);
return 1;
} else {
// 如果小於現有的最大值,則不做處理,因為不影響基數
return 0;
}
}
// 用於計算hash後的值中,第一個出現1的位置
int hllPatLen(unsigned char *ele, size_t elesize, long *regp) {
uint64_t hash, bit, index;
int count;
// 利用MurmurHash64A雜湊函式來計算該元素的hash值
hash = MurmurHash64A(ele,elesize,0xadc83b19ULL);
// 計算應該放在哪個桶
index = hash & HLL_P_MASK;
// 為了保證迴圈能夠終止
hash |= ((uint64_t)1<<63);
bit = HLL_REGISTERS;
// 儲存第一個1出現的位置
count = 1;
// 計算count
while((hash & bit) == 0) {
count++;
bit <<= 1;
}
*regp = (int) index;
return count;
}
計算基數
Redis提供了下面的命令來計算資料集的基數。
PFCOUNT key [key ...]
如果只有一個key則計算其基數即可;如果存在多個鍵,則需要合併所有的鍵(求並集),然後計算其基數。
合併hyperloglog鍵
Redis提供了下面的命令來合併多個hyperloglog鍵。(原始碼部分就省略了)
PFMERGE destkey sourcekey [sourcekey ...]
hyperloglog小結
Update 2016-12-9
* 為什麼一個hyperloglog鍵的大小固定為12KB?*
Redis的hyperloglog結構中,card陣列為64位bit,理論上可以儲存近\(2^{64}\)個值。以密集表示模式為例:
Redis規定分桶個數為16384個,每一個桶內的資料採用6bit大小來存放
注意:Redis此處存放的不是該元素,而是存放第一次出現“1”的位置的值,6bit可以表示0~64位,即可以支援\(2^{64}\)個基數。
registers部分佔用記憶體為(16384*6+7)/8 = 12288個位元組,另外加上HLL結構的頭佔用了16個位元組,加起來一共12304個位元組,也就是說一個hyperloglog鍵佔用了12KB左右的大小,最多可以計算\(2^{64}\)個不同元素的基數。