1. 程式人生 > >Redis原始碼剖析--基數統計hyperloglog

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的概率為

P(xk)=(11/2k)n

很顯然,第二個問題,n次伯努利過程,所有投擲次數都不小於k的概率為

P(xk)=(11/2k1)n

從上述公式中可得出結論:當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}\)個不同元素的基數。