見縫插針 —— 深入 Redis HyperLogLog 內部數據結構分析
HyperLogLog算法是一種非常巧妙的近似統計海量去重元素數量的算法。它內部維護了 16384 個桶(bucket)來記錄各自桶的元素數量。當一個元素到來時,它會散列到其中一個桶,以一定的概率影響這個桶的計數值。因為是概率算法,所以單個桶的計數值並不準確,但是將所有的桶計數值進行調合均值累加起來,結果就會非常接近真實的計數值。
為了便於理解HyperLogLog算法,我們先簡化它的計數邏輯。因為是去重計數,如果是準確的去重,肯定需要用到 set 集合,使用集合來記錄所有的元素,然後使用 scard 指令來獲取集合大小就可以得到總的計數。因為元素特別多,單個集合會特別大,所以將集合打散成 16384 個小集合。當元素到來時,通過 hash 算法將這個元素分派到其中的一個小集合存儲,同樣的元素總是會散列到同樣的小集合。這樣總的計數就是所有小集合大小的總和。使用這種方式精確計數除了可以增加元素外,還可以減少元素。
用 Python 代碼描述如下
集合打散並沒有什麽明顯好處,因為總的內存占用並沒有減少。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 串成的字符串位圖。
那麽給定一個桶編號,如何獲取它的 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 的計數值。需要註意的是字節位序是左邊低位右邊高位,而通常我們使用的字節都是左邊高位右邊低位,我們需要在腦海中進行倒置。
如果 offset_bits 小於等於 2,那麽這 6bit 在一個字節內部,可以直接使用下面的表達式得到計數值 val
val = buffer[offset_bytes] >> offset_bits# 向右移位
如果 offset_bits 大於 2,那麽就會跨越字節邊界,這時需要拼接兩個字節的位片段。
不過下面 Redis 的源碼要晦澀一點,看形式它似乎只考慮了跨越字節邊界的情況。這是因為如果 6bit 在單個字節內,上面代碼中的 high_val 的值是零,所以這一份代碼可以同時照顧單字節和雙字節。
稀疏存儲結構
稀疏存儲適用於很多計數值都是零的情況。下圖表示了一般稀疏存儲計數值的狀態。
當多個連續桶的計數值都是零時,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 為了方便表達稀疏存儲,它將上面三種字節表示形式分別賦予了一條指令。
ZERO:len 單個字節表示 00[len-1],連續最多64個零計數值
VAL:value,len 單個字節表示 1[value-1][len-1],連續 len 個值為 value 的計數值
XZERO:len 雙字節表示 01[len-1],連續最多16384個零計數值
上圖可以使用指令形式表示如下
存儲轉換
當計數值達到一定程度後,稀疏存儲將會不可逆一次性轉換為密集存儲。轉換的條件有兩個,任意一個滿足就會立即發生轉換
,也就是任意一個計數值從 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)。
對象頭
HyperLogLog 除了需要存儲 16384 個桶的計數值之外,它還有一些附加的字段需要存儲,比如總計數緩存、存儲類型。所以它使用了一個額外的對象頭來表示。
所以 HyperLogLog 整體的內部結構就是 HLL 對象頭 加上 16384 個桶的計數值位圖。它在 Redis 的內部結構表現就是一個字符串位圖。你可以把 HyperLogLog 對象當成普通的字符串來進行處理。
但是不可以使用 HyperLogLog 指令來操縱普通的字符串,因為它需要檢查對象頭魔術字符串是否是 "HYLL"。
但是如果字符串以 "HYLL\x00" 或者 "HYLL\x01" 開頭,那麽就可以使用 HyperLogLog 的指令。
也許你會感覺非常奇怪,這是因為 HyperLogLog 在執行指令前需要對內容進行格式檢查,這個檢查就是查看對象頭的 magic 魔術字符串是否是 "HYLL" 以及 encoding 字段是否是 HLL_SPARSE=0 或者 HLL_DENSE=1 來判斷當前的字符串是否是 HyperLogLog 計數器。如果是密集存儲,還需要判斷字符串的長度是否恰好等於密集計數器存儲的長度。
HyperLogLog 和 字符串的關系就好比 Geo 和 zset 的關系。你也可以使用任意 zset 的指令來訪問 Geo 數據結構,因為 Geo 內部存儲就是使用了一個純粹的 zset來記錄元素的地理位置。
見縫插針 —— 深入 Redis HyperLogLog 內部數據結構分析