走近源碼:神奇的HyperLogLog

HyperLogLog是Redis的高級數據結構,是統計基數的利器。前文咱們已經介紹過HyperLogLog的基本用法,若是隻求會用,只須要掌握HyperLogLog的三個命令便可,若是想要更進一步瞭解HyperLogLog的原理以及源碼實現,相信這篇文章會給你帶來一些啓發。html

基數

數學上,基數,即集合中包含的元素的「個數」(參見勢的比較),是平常交流中基數的概念在數學上的精確化(並使之再也不受限於有限情形)。有限集合的基數,其意義與平常用語中的「基數」相同,例如{\displaystyle {a,b,c}}java

\{a,b,c\}
的基數是3。 無限集合的基數,其意義在於比較兩個集的大小,例如整數集和有理數集的基數相同;整數集的基數比實數集的小。

在介紹HyperLogLog的原理以前,請你先來思考一下,若是讓你來統計基數,你會用什麼方法。git

Set

熟悉Redis數據結構的同窗必定首先會想到Set這個結構,咱們只須要把數據都存入Set,而後用scard命令就能夠獲得結果,這是一種思路,可是存在必定的問題。若是數據量很是大,那麼將會耗費很大的內存空間,若是這些數據僅僅是用來統計基數,那麼無疑是形成了巨大的浪費,所以,咱們須要找到一種佔用內存較小的方法。github

bitmap

bitmap一樣是一種能夠統計基數的方法,能夠理解爲用bit數組存儲元素,例如01101001,表示的是[1,2,4,8],bitmap中1的個數就是基數。bitmap也能夠輕鬆合併多個集合,只須要將多個數組進行異或操做就能夠了。bitmap相比於Set也大大節省了內存,咱們來粗略計算一下,統計1億個數據的基數,須要的內存是:100000000/8/1024/1024 ≈ 12M。算法

雖然bitmap在節省空間方面已經有了不錯的表現,可是若是須要統計1000個對象,就須要大約12G的內存,顯然這個結果仍然不能令咱們滿意。在這種狀況下,HyperLogLog將會出來拯救咱們。數組

HyperLogLog原理

HyperLogLog實際上不會存儲每一個元素的值,它使用的是機率算法,經過存儲元素的hash值的第一個1的位置,來計算元素數量。這麼說不太容易理解,容我先搬出來一個栗子。緩存

有一天Jack和丫丫玩拋硬幣的遊戲,規則是丫丫負責拋硬幣,每次拋到正面爲一回合,丫丫能夠本身決定進行幾個回合。最後須要告訴Jack最長的那個回合拋了多少次,再由Jack來猜丫丫一共進行了幾個回合。Jack心想:這可很差猜啊,我得算算機率了。因而在腦海中繪製這樣一張圖。數據結構

yb

k是每回合拋到1所用的次數,咱們已知的是最大的k值,能夠用kmax表示,因爲每次拋硬幣的結果只有0和1兩種狀況,所以,kmax在任意回合出現的機率即爲(1/2)kmax,所以能夠推測n=2kmax。機率學把這種問題叫作伯努利實驗。此時丫丫已經完成了n個回合,而且告訴Jack最長的一次拋了3次,Jack此時也成竹在胸,立刻說出他的答案8,最後的結果是:丫丫只拋了一回合,Jack輸了,要負責刷碗一個月。app

終於,咱們的Philippe Flajolet教授遇到了Jack同樣的問題,他決心吸收Jack的教訓,要讓這個算法更加準確,因而引入了桶的概念,計算m個桶的加權平均值,這樣就能獲得比較準確的答案了(實際上還要進行其餘修正)。最終的公式如圖ide

HyperLogLog公式

其中m是桶的數量,const是修正常數,它的取值會根據m而變化。p=log2m

switch (p) {
   case 4:
       constant = 0.673 * m * m;
   case 5:
       constant = 0.697 * m * m;
   case 6:
       constant = 0.709 * m * m;
   default:
       constant = (0.7213 / (1 + 1.079 / m)) * m * m;
}
複製代碼

咱們回到Redis,對於一個輸入的字符串,首先獲得64位的hash值,用前14位來定位桶的位置(共有214,即16384個桶)。後面50位即爲伯努利過程,每一個桶有6bit,記錄第一次出現1的位置count,若是count>oldcount,就用count替換oldcount。

瞭解原理以後,咱們再來聊一下HyperLogLog的存儲。HyperLogLog的存儲結構分爲密集存儲結構和稀疏存儲結構兩種,默認爲稀疏存儲結構,而咱們常說的佔用12K內存的則是密集存儲結構。

密集存儲結構

密集存儲比較簡單,就是連續16384個6bit的串成的位圖。因爲每一個桶是6bit,所以對桶的定位要麻煩一些。

#define HLL_BITS 6 /* Enough to count up to 63 leading zeroes. */
#define HLL_REGISTER_MAX ((1<<HLL_BITS)-1)
/* Store the value of the register at position 'regnum' into variable 'target'. * 'p' is an array of unsigned bytes. */
#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)

/* Set the value of the register at position 'regnum' to 'val'. * 'p' is an array of unsigned bytes. */
#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,它的偏移字節量爲(regnum * 6) / 8,起始bit偏移爲(regnum * 6) % 8,例如,咱們要定位編號爲5的桶,字節偏移是3,位偏移也是6,也就是說,從第4個字節的第7位開始是編號爲3的桶。這裏須要注意,字節序和咱們平時的字節序相反,所以須要進行倒置。咱們用一張圖來講明Redis是如何定位桶而且獲得存儲的值(即HLL_DENSE_GET_REGISTER函數的解釋)。

桶定位

對於編號爲5的桶,咱們已經獲得了字節偏移_byte和爲偏移_fb,b0 >> _fb和b1 << _fb8操做是將字節倒置,而後進行拼接,而且保留最後6位。

稀疏存儲結構

你覺得Redis真的會用16384個6bit存儲每個HLL對象嗎,那就too naive了,雖然它只佔用了12K內存,可是Redis對於內存的節約已經到了喪心病狂的地步了。所以,若是比較多的計數值都是0,那麼就會採用稀疏存儲的結構。

對於連續多個計數值爲0的桶,Redis使用的存儲方式是:00xxxxxx,前綴兩個0,後面6位的值加1表示有連續多少個桶的計數值爲0,因爲6bit最大能表示64個桶,因此Redis又設計了另外一種表示方法:01xxxxxx yyyyyyyy,這樣後面14bit就能夠表示16384個桶了,而一個初始狀態的HyperLogLog對象只須要用2個字節來存儲。

若是連續的桶數都不是0,那麼Redis的表示方式爲1vvvvvxx,即爲連續(xx+1)個桶的計數值都是(vvvvv+1)。例如,10011110表示連續3個8。這裏使用5bit,最大隻能表示32。所以,當某個計數值大於32時,Redis會將這個HyperLogLog對象調整爲密集存儲。

Redis用三條指令來表達稀疏存儲的方式:

  1. ZERO:len 單個字節表示 00[len-1],連續最多64個零計數值
  2. VAL:value,len 單個字節表示 1[value-1][len-1],連續 len 個值爲 value 的計數值
  3. XZERO:len 雙字節表示 01[len-1],連續最多16384個零計數值

Redis從稀疏存儲轉換到密集存儲的條件是:

  1. 任意一個計數值從 32 變成 33,由於VAL指令已經沒法容納,它能表示的計數值最大爲 32
  2. 稀疏存儲佔用的總字節數超過 3000 字節,這個閾值能夠經過 hll_sparse_max_bytes 參數進行調整。

源碼解析

接下來經過源碼來看一下pfadd和pfcount兩個命令的具體流程。在這以前咱們首先要了解的是HyperLogLog的頭結構體和建立一個HyperLogLog對象的步驟。

HyperLogLog頭結構體
struct hllhdr {
    char magic[4];      /* "HYLL" */
    uint8_t encoding;   /* HLL_DENSE or HLL_SPARSE. */
    uint8_t notused[3]; /* Reserved for future use, must be zero. */
    uint8_t card[8];    /* Cached cardinality, little endian. */
    uint8_t registers[]; /* Data bytes. */
};
複製代碼
建立HyperLogLog對象
#define HLL_P 14 /* The greater is P, the smaller the error. */
#define HLL_REGISTERS (1<<HLL_P) /* With P=14, 16384 registers. */
#define HLL_SPARSE_XZERO_MAX_LEN 16384


#define HLL_SPARSE_XZERO_SET(p,len) do { \ int _l = (len)-1; \ *(p) = (_l>>8) | HLL_SPARSE_XZERO_BIT; \ *((p)+1) = (_l&0xff); \ } while(0)

/* Create an HLL object. We always create the HLL using sparse encoding. * This will be upgraded to the dense representation as needed. */
robj *createHLLObject(void) {
    robj *o;
    struct hllhdr *hdr;
    sds s;
    uint8_t *p;
    int sparselen = HLL_HDR_SIZE +
                    (((HLL_REGISTERS+(HLL_SPARSE_XZERO_MAX_LEN-1)) /
                     HLL_SPARSE_XZERO_MAX_LEN)*2);
    int aux;

    /* Populate the sparse representation with as many XZERO opcodes as * needed to represent all the registers. */
    aux = HLL_REGISTERS;
    s = sdsnewlen(NULL,sparselen);
    p = (uint8_t*)s + HLL_HDR_SIZE;
    while(aux) {
        int xzero = HLL_SPARSE_XZERO_MAX_LEN;
        if (xzero > aux) xzero = aux;
        HLL_SPARSE_XZERO_SET(p,xzero);
        p += 2;
        aux -= xzero;
    }
    serverAssert((p-(uint8_t*)s) == sparselen);

    /* Create the actual object. */
    o = createObject(OBJ_STRING,s);
    hdr = o->ptr;
    memcpy(hdr->magic,"HYLL",4);
    hdr->encoding = HLL_SPARSE;
    return o;
}
複製代碼

這裏sparselen=HLL_HDR_SIZE+2,由於初始化時默認全部桶的計數值都是0。其餘過程不難理解,用的存儲方式是咱們前面提到過的稀疏存儲,建立的對象實質上是一個字符串對象,這也是字符串命令能夠操做HyperLogLog對象的緣由。

PFADD命令
/* PFADD var ele ele ele ... ele => :0 or :1 */
void pfaddCommand(client *c) {
    robj *o = lookupKeyWrite(c->db,c->argv[1]);
    struct hllhdr *hdr;
    int updated = 0, j;

    if (o == NULL) {
        /* Create the key with a string value of the exact length to * hold our HLL data structure. sdsnewlen() when NULL is passed * is guaranteed to return bytes initialized to zero. */
        o = createHLLObject();
        dbAdd(c->db,c->argv[1],o);
        updated++;
    } else {
        if (isHLLObjectOrReply(c,o) != C_OK) return;
        o = dbUnshareStringValue(c->db,c->argv[1],o);
    }
    /* Perform the low level ADD operation for every element. */
    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);
}
複製代碼

PFADD命令會先判斷key是否存在,若是不存在,則建立一個新的HyperLogLog對象;若是存在,會調用isHLLObjectOrReply()函數檢查這個對象是否是HyperLogLog對象,檢查方法主要是檢查魔數是否正確,存儲結構是否正確以及頭結構體的長度是否正確等。

一切就緒後,才能夠調用hllAdd()函數添加元素。hllAdd函數很簡單,只是根據存儲結構判斷須要調用hllDenseAdd()函數仍是hllSparseAdd()函數。

密集存儲結構只是比較新舊計數值,若是新計數值大於就計數值,就將其替代。

而稀疏存儲結構要複雜一些:

  1. 判斷是否須要調整爲密集存儲結構,若是不須要則繼續進行,不然就先調整爲密集存儲結構,而後執行添加操做
  2. 咱們須要先定位要修改的字節段,經過循環計算每一段表示的桶的範圍是否包括要修改的桶
  3. 定位到桶後,若是這個桶已是VAL,而且計數值大於當前要添加的計數值,則返回0,若是小於當前計數值,就進行更新
  4. 若是是ZERO,而且長度爲1,那麼能夠直接把它替換爲VAL,而且設置計數值
  5. 若是不是上述兩種狀況,則須要對現有的存儲進行拆分
PFCOUNT命令
/* PFCOUNT var -> approximated cardinality of set. */
void pfcountCommand(client *c) {
    robj *o;
    struct hllhdr *hdr;
    uint64_t card;

    /* Case 1: multi-key keys, cardinality of the union. * * When multiple keys are specified, PFCOUNT actually computes * the cardinality of the merge of the N HLLs specified. */
    if (c->argc > 2) {
        uint8_t max[HLL_HDR_SIZE+HLL_REGISTERS], *registers;
        int j;

        /* Compute an HLL with M[i] = MAX(M[i]_j). */
        memset(max,0,sizeof(max));
        hdr = (struct hllhdr*) max;
        hdr->encoding = HLL_RAW; /* Special internal-only encoding. */
        registers = max + HLL_HDR_SIZE;
        for (j = 1; j < c->argc; j++) {
            /* Check type and size. */
            robj *o = lookupKeyRead(c->db,c->argv[j]);
            if (o == NULL) continue; /* Assume empty HLL for non existing var.*/
            if (isHLLObjectOrReply(c,o) != C_OK) return;

            /* Merge with this HLL with our 'max' HHL by setting max[i] * to MAX(max[i],hll[i]). */
            if (hllMerge(registers,o) == C_ERR) {
                addReplySds(c,sdsnew(invalid_hll_err));
                return;
            }
        }

        /* Compute cardinality of the resulting set. */
        addReplyLongLong(c,hllCount(hdr,NULL));
        return;
    }

    /* Case 2: cardinality of the single HLL. * * The user specified a single key. Either return the cached value * or compute one and update the cache. */
    o = lookupKeyWrite(c->db,c->argv[1]);
    if (o == NULL) {
        /* No key? Cardinality is zero since no element was added, otherwise * we would have a key as HLLADD creates it as a side effect. */
        addReply(c,shared.czero);
    } else {
        if (isHLLObjectOrReply(c,o) != C_OK) return;
        o = dbUnshareStringValue(c->db,c->argv[1],o);

        /* Check if the cached cardinality is valid. */
        hdr = o->ptr;
        if (HLL_VALID_CACHE(hdr)) {
            /* Just return the cached value. */
            card = (uint64_t)hdr->card[0];
            card |= (uint64_t)hdr->card[1] << 8;
            card |= (uint64_t)hdr->card[2] << 16;
            card |= (uint64_t)hdr->card[3] << 24;
            card |= (uint64_t)hdr->card[4] << 32;
            card |= (uint64_t)hdr->card[5] << 40;
            card |= (uint64_t)hdr->card[6] << 48;
            card |= (uint64_t)hdr->card[7] << 56;
        } else {
            int invalid = 0;
            /* Recompute it and update the cached value. */
            card = hllCount(hdr,&invalid);
            if (invalid) {
                addReplySds(c,sdsnew(invalid_hll_err));
                return;
            }
            hdr->card[0] = card & 0xff;
            hdr->card[1] = (card >> 8) & 0xff;
            hdr->card[2] = (card >> 16) & 0xff;
            hdr->card[3] = (card >> 24) & 0xff;
            hdr->card[4] = (card >> 32) & 0xff;
            hdr->card[5] = (card >> 40) & 0xff;
            hdr->card[6] = (card >> 48) & 0xff;
            hdr->card[7] = (card >> 56) & 0xff;
            /* This is not considered a read-only command even if the * data structure is not modified, since the cached value * may be modified and given that the HLL is a Redis string * we need to propagate the change. */
            signalModifiedKey(c->db,c->argv[1]);
            server.dirty++;
        }
        addReplyLongLong(c,card);
    }
}
複製代碼

若是要計算多個HyperLogLog的基數,則須要將多個HyperLogLog對象合併,這裏合併方法是將全部的HyperLogLog對象合併到一個名爲max的對象中,max採用的是密集存儲結構,若是被合併的對象也是密集存儲結構,則循環比較每個計數值,將大的那個存入max。若是被合併的是稀疏存儲,則只須要比較VAL便可。

若是計算單個HyperLogLog對象的基數,則先判斷對象頭結構體中的基數緩存是否有效,若是有效,可直接返回。若是已經失效,則須要從新計算基數,並修改原有緩存,這也是PFCOUNT命令不被當作只讀命令的緣由。

結語

最後,給你們推薦一個幫助理解HyperLogLog原理的工具:http://content.research.neustar.biz/blog/hll.html,有興趣的話能夠去學習一下。

HLL原理工具

參考閱讀

Redis new data structure: the HyperLogLog

探索HyperLogLog算法(含Java實現)

Redis 深度歷險:核心原理與應用實踐

相關文章
相關標籤/搜索