見縫插針 —— 深入 Redis HyperLogLog 內部資料結構分析

老錢發表於2019-03-01

HyperLogLog演算法是一種非常巧妙的近似統計海量去重元素數量的演算法。它內部維護了 16384 個桶(bucket)來記錄各自桶的元素數量。當一個元素到來時,它會雜湊到其中一個桶,以一定的概率影響這個桶的計數值。因為是概率演算法,所以單個桶的計數值並不準確,但是將所有的桶計數值進行調合均值累加起來,結果就會非常接近真實的計數值。

見縫插針 —— 深入 Redis HyperLogLog 內部資料結構分析

為了便於理解HyperLogLog演算法,我們先簡化它的計數邏輯。因為是去重計數,如果是準確的去重,肯定需要用到 set 集合,使用集合來記錄所有的元素,然後使用 scard 指令來獲取集合大小就可以得到總的計數。因為元素特別多,單個集合會特別大,所以將集合打散成 16384 個小集合。當元素到來時,通過 hash 演算法將這個元素分派到其中的一個小集合儲存,同樣的元素總是會雜湊到同樣的小集合。這樣總的計數就是所有小集合大小的總和。使用這種方式精確計數除了可以增加元素外,還可以減少元素。

用 Python 程式碼描述如下

# coding:utf-8
import hashlib

class ExactlyCounter:

    def __init__(self):
        # 先分配16384個空集合
        self.buckets = []
        for i in range(16384):
            self.buckets.append(set([]))
        # 使用md5雜湊演算法
        self.hash = lambda x: int(hashlib.md5(x).hexdigest(), 16)
        self.count = 0

    def add(self, element):
        h = self.hash(element)
        idx = h % len(self.buckets)
        bucket = self.buckets[idx]
        old_len = len(bucket)
        bucket.add(element)
        if len(bucket) > old_len:
            # 如果數量變化了,總數就+1
            self.count += 1

    def remove(self, element):
        h = self.hash(element)
        idx = h % len(self.buckets)
        bucket = self.buckets[idx]
        old_len = len(bucket)
        bucket.remove(element)
        if len(bucket) < old_len:
            # 如果數量變化了,總數-1
            self.count -= 1


if __name__ == '__main__':
    c = ExactlyCounter()
    for i in range(100000):
        c.add("element_%d" % i)
    print c.count
    for i in range(100000):
        c.remove("element_%d" % i)
    print c.count
複製程式碼

集合打散並沒有什麼明顯好處,因為總的記憶體佔用並沒有減少。HyperLogLog肯定不是這個演算法,它需要對這個小集合進行優化,壓縮它的儲存空間,讓它的記憶體變得非常微小。HyperLogLog演算法中每個桶所佔用的空間實際上只有 6 個 bit,這 6 個 bit 自然是無法容納桶中所有元素的,它記錄的是桶中元素數量的對數值。

為了說明這個對數值具體是個什麼東西,我們先來考慮一個小問題。一個隨機的整數值,這個整數的尾部有一個 0 的概率是 50%,要麼是 0 要麼是 1。同樣,尾部有兩個 0 的概率是 25%,有三個零的概率是 12.5%,以此類推,有 k 個 0 的概率是 2^(-k)。如果我們隨機出了很多整數,整數的數量我們並不知道,但是我們記錄了整數尾部連續 0 的最大數量 K。我們就可以通過這個 K 來近似推斷出整數的數量,這個數量就是 2^K。

當然結果是非常不準確的,因為可能接下來你隨機了非常多的整數,但是末尾連續零的最大數量 K 沒有變化,但是估計值還是 2^K。你也許會想到要是這個 K 是個浮點數就好了,每次隨機一個新元素,它都可以稍微往上漲一點點,那麼估計值應該會準確很多。

HyperLogLog通過分配 16384 個桶,然後對所有的桶的最大數量 K 進行調合平均來得到一個平均的末尾零最大數量 K# ,K# 是一個浮點數,使用平均後的 2^K# 來估計元素的總量相對而言就會準確很多。不過這只是簡化演算法,真實的演算法還有很多修正因子,因為涉及到的數學理論知識過於繁多,這裡就不再精確描述。

下面我們看看Redis HyperLogLog 演算法的具體實現。我們知道一個HyperLogLog實際佔用的空間大約是 13684 * 6bit / 8 = 12k 位元組。但是在計數比較小的時候,大多數桶的計數值都是零。如果 12k 位元組裡面太多的位元組都是零,那麼這個空間是可以適當節約一下的。Redis 在計數值比較小的情況下采用了稀疏儲存,稀疏儲存的空間佔用遠遠小於 12k 位元組。相對於稀疏儲存的就是密集儲存,密集儲存會恆定佔用 12k 位元組。

密集儲存結構

不論是稀疏儲存還是密集儲存,Redis 內部都是使用字串點陣圖來儲存 HyperLogLog 所有桶的計數值。密集儲存的結構非常簡單,就是連續 16384 個 6bit 串成的字串點陣圖。

見縫插針 —— 深入 Redis HyperLogLog 內部資料結構分析

那麼給定一個桶編號,如何獲取它的 6bit 計數值呢?這 6bit 可能在一個位元組內部,也可能會跨越位元組邊界。我們需要對這一個或者兩個位元組進行適當的移位拼接才可以得到計數值。

假設桶的編號為idx,這個 6bit 計數值的起始位元組位置偏移用 offset_bytes表示,它在這個位元組的起始位元位置偏移用 offset_bits 表示。我們有

offset_bytes = (idx * 6) / 8
offset_bits = (idx * 6) % 8
複製程式碼

前者是商,後者是餘數。比如 bucket 2 的位元組偏移是 1,也就是第 2 個位元組。它的位偏移是4,也就是第 2 個位元組的第 5 個位開始是 bucket 2 的計數值。需要注意的是位元組位序是左邊低位右邊高位,而通常我們使用的位元組都是左邊高位右邊低位,我們需要在腦海中進行倒置。

見縫插針 —— 深入 Redis HyperLogLog 內部資料結構分析

如果 offset_bits 小於等於 2,那麼這 6bit 在一個位元組內部,可以直接使用下面的表示式得到計數值 val

見縫插針 —— 深入 Redis HyperLogLog 內部資料結構分析

val = buffer[offset_bytes] >> offset_bits  # 向右移位
複製程式碼

如果 offset_bits 大於 2,那麼就會跨越位元組邊界,這時需要拼接兩個位元組的位片段。

見縫插針 —— 深入 Redis HyperLogLog 內部資料結構分析

# 低位值
low_val = buffer[offset_bytes] >> offset_bits
# 低位個數
low_bits = 8 - offset_bits
# 拼接,保留低6位
val = (high_val << low_bits | low_val) & 0b111111
複製程式碼

不過下面 Redis 的原始碼要晦澀一點,看形式它似乎只考慮了跨越位元組邊界的情況。這是因為如果 6bit 在單個位元組內,上面程式碼中的 high_val 的值是零,所以這一份程式碼可以同時照顧單位元組和雙位元組。

// 獲取指定桶的計數值
#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; \  # %8 = &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)

// 設定指定桶的計數值
#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)
複製程式碼

稀疏儲存結構

稀疏儲存適用於很多計數值都是零的情況。下圖表示了一般稀疏儲存計數值的狀態。

見縫插針 —— 深入 Redis HyperLogLog 內部資料結構分析

當多個連續桶的計數值都是零時,Redis 使用了一個位元組來表示接下來有多少個桶的計數值都是零:00xxxxxx。字首兩個零表示接下來的 6bit 整數值加 1 就是零值計數器的數量,注意這裡要加 1 是因為數量如果為零是沒有意義的。比如 00010101表示連續 22 個零值計數器。6bit 最多隻能表示連續 64 個零值計數器,所以 Redis 又設計了連續多個多於 64 個的連續零值計數器,它使用兩個位元組來表示:01xxxxxx yyyyyyyy,後面的 14bit 可以表示最多連續 16384 個零值計數器。這意味著 HyperLogLog 資料結構中 16384 個桶的初始狀態,所有的計數器都是零值,可以直接使用 2 個位元組來表示。

如果連續幾個桶的計數值非零,那就使用形如 1vvvvvxx 這樣的一個位元組來表示。中間 5bit 表示計數值,尾部 2bit 表示連續幾個桶。它的意思是連續 (xx +1) 個計數值都是 (vvvvv + 1)。比如 10101011 表示連續 4 個計數值都是 11。注意這兩個值都需要加 1,因為任意一個是零都意味著這個計數值為零,那就應該使用零計數值的形式來表示。注意計數值最大隻能表示到32,而 HyperLogLog 的密集儲存單個計數值用 6bit 表示,最大可以表示到 63。當稀疏儲存的某個計數值需要調整到大於 32 時,Redis 就會立即轉換 HyperLogLog 的儲存結構,將稀疏儲存轉換成密集儲存。

見縫插針 —— 深入 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個零計數值
#define HLL_SPARSE_XZERO_BIT 0x40 /* 01xxxxxx */
#define HLL_SPARSE_VAL_BIT 0x80 /* 1vvvvvxx */
#define HLL_SPARSE_IS_ZERO(p) (((*(p)) & 0xc0) == 0) /* 00xxxxxx */
#define HLL_SPARSE_IS_XZERO(p) (((*(p)) & 0xc0) == HLL_SPARSE_XZERO_BIT)
#define HLL_SPARSE_IS_VAL(p) ((*(p)) & HLL_SPARSE_VAL_BIT)
#define HLL_SPARSE_ZERO_LEN(p) (((*(p)) & 0x3f)+1)
#define HLL_SPARSE_XZERO_LEN(p) (((((*(p)) & 0x3f) << 8) | (*((p)+1)))+1)
#define HLL_SPARSE_VAL_VALUE(p) ((((*(p)) >> 2) & 0x1f)+1)
#define HLL_SPARSE_VAL_LEN(p) (((*(p)) & 0x3)+1)
#define HLL_SPARSE_VAL_MAX_VALUE 32
#define HLL_SPARSE_VAL_MAX_LEN 4
#define HLL_SPARSE_ZERO_MAX_LEN 64
#define HLL_SPARSE_XZERO_MAX_LEN 16384
複製程式碼

上圖可以使用指令形式表示如下

見縫插針 —— 深入 Redis HyperLogLog 內部資料結構分析

儲存轉換

當計數值達到一定程度後,稀疏儲存將會不可逆一次性轉換為密集儲存。轉換的條件有兩個,任意一個滿足就會立即發生轉換 ,也就是任意一個計數值從 32 變成 33,因為VAL指令已經無法容納,它能表示的計數值最大為 32 稀疏儲存佔用的總位元組數超過 3000 位元組,這個閾值可以通過 hll_sparse_max_bytes 引數進行調整。

計數快取

前面提到 HyperLogLog 表示的總計數值是由 16384 個桶的計數值進行調和平均後再基於因子修正公式計算得出來的。它需要遍歷所有的桶進行計算才可以得到這個值,中間還涉及到很多浮點運算。這個計算量相對來說還是比較大的。

所以 Redis 使用了一個額外的欄位來快取總計數值,這個欄位有 64bit,最高位如果為 1 表示該值是否已經過期,如果為 0, 那麼剩下的 63bit 就是計數值。

當 HyperLogLog 中任意一個桶的計數值發生變化時,就會將計數快取設為過期,但是不會立即觸發計算。而是要等到使用者顯示呼叫 pfcount 指令時才會觸發重新計算重新整理快取。快取重新整理在密集儲存時需要遍歷 16384 個桶的計數值進行調和平均,但是稀疏儲存時沒有這麼大的計算量。也就是說只有當計數值比較大時才可能產生較大的計算量。另一方面如果計數值比較大,那麼大部分 pfadd 操作根本不會導致桶中的計數值發生變化。

這意味著在一個極具變化的 HLL 計數器中頻繁呼叫 pfcount 指令可能會有少許效能問題。關於這個效能方面的擔憂在 Redis 作者 antirez 的部落格中也提到了。不過作者做了仔細的壓力的測試,發現這是無需擔心的,pfcount 指令的平均時間複雜度就是 O(1)。

After this change even trying to add elements at maximum speed using a pipeline of 32 elements with 50 simultaneous clients, PFCOUNT was able to perform as well as any other O(1) command with very small constant times.

物件頭

HyperLogLog 除了需要儲存 16384 個桶的計數值之外,它還有一些附加的欄位需要儲存,比如總計數快取、儲存型別。所以它使用了一個額外的物件頭來表示。

struct hllhdr {
    char magic[4];      /* 魔術字串"HYLL" */
    uint8_t encoding;   /* 儲存型別 HLL_DENSE or HLL_SPARSE. */
    uint8_t notused[3]; /* 保留三個位元組未來可能會使用 */
    uint8_t card[8];    /* 總計數快取 */
    uint8_t registers[]; /* 所有桶的計數器 */
};
複製程式碼

所以 HyperLogLog 整體的內部結構就是 HLL 物件頭 加上 16384 個桶的計數值點陣圖。它在 Redis 的內部結構表現就是一個字串點陣圖。你可以把 HyperLogLog 物件當成普通的字串來進行處理。

127.0.0.1:6379> pfadd codehole python java golang
(integer) 1
127.0.0.1:6379> get codehole
"HYLL\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80C\x03\x84MK\x80P\xb8\x80^\xf3"
複製程式碼

但是不可以使用 HyperLogLog 指令來操縱普通的字串,因為它需要檢查物件頭魔術字串是否是 "HYLL"。

127.0.0.1:6379> set codehole python
OK
127.0.0.1:6379> pfadd codehole java golang
(error) WRONGTYPE Key is not a valid HyperLogLog string value.
複製程式碼

但是如果字串以 "HYLL\x00" 或者 "HYLL\x01" 開頭,那麼就可以使用 HyperLogLog 的指令。

127.0.0.1:6379> set codehole "HYLL\x01whatmagicthing"
OK
127.0.0.1:6379> get codehole
"HYLL\x01whatmagicthing"
127.0.0.1:6379> pfadd codehole python java golang
(integer) 1
複製程式碼

也許你會感覺非常奇怪,這是因為 HyperLogLog 在執行指令前需要對內容進行格式檢查,這個檢查就是檢視物件頭的 magic 魔術字串是否是 "HYLL" 以及 encoding 欄位是否是 HLL_SPARSE=0 或者 HLL_DENSE=1 來判斷當前的字串是否是 HyperLogLog 計數器。如果是密集儲存,還需要判斷字串的長度是否恰好等於密集計數器儲存的長度。

int isHLLObjectOrReply(client *c, robj *o) {
    ...
    /* Magic should be "HYLL". */
    if (hdr->magic[0] != 'H' || hdr->magic[1] != 'Y' ||
        hdr->magic[2] != 'L' || hdr->magic[3] != 'L') goto invalid;

    if (hdr->encoding > HLL_MAX_ENCODING) goto invalid;

    if (hdr->encoding == HLL_DENSE &&
        stringObjectLen(o) != HLL_DENSE_SIZE) goto invalid;

    return C_OK;

invalid:
    addReplySds(c,
        sdsnew("-WRONGTYPE Key is not a valid "
               "HyperLogLog string value.\r\n"));
    return C_ERR;
}
複製程式碼

HyperLogLog 和 字串的關係就好比 Geo 和 zset 的關係。你也可以使用任意 zset 的指令來訪問 Geo 資料結構,因為 Geo 內部儲存就是使用了一個純粹的 zset來記錄元素的地理位置。

見縫插針 —— 深入 Redis HyperLogLog 內部資料結構分析

本文節選之線上技術小冊《Redis 深度歷險》,現在就開始閱讀《Redis 深度歷險》吧 !

相關文章