HyperLogLog算法是一種很是巧妙的近似統計海量去重元素數量的算法。它內部維護了 16384 個桶(bucket)來記錄各自桶的元素數量。當一個元素到來時,它會散列到其中一個桶,以必定的機率影響這個桶的計數值。由於是機率算法,因此單個桶的計數值並不許確,可是將全部的桶計數值進行調合均值累加起來,結果就會很是接近真實的計數值。java
爲了便於理解HyperLogLog算法,咱們先簡化它的計數邏輯。由於是去重計數,若是是準確的去重,確定須要用到 set 集合,使用集合來記錄全部的元素,而後使用 scard 指令來獲取集合大小就能夠獲得總的計數。由於元素特別多,單個集合會特別大,因此將集合打散成 16384 個小集合。當元素到來時,經過 hash 算法將這個元素分派到其中的一個小集合存儲,一樣的元素老是會散列到一樣的小集合。這樣總的計數就是全部小集合大小的總和。使用這種方式精確計數除了能夠增長元素外,還能夠減小元素。python
用 Python 代碼描述以下golang
# 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 是個浮點數就行了,每次隨機一個新元素,它均可以稍微往上漲一點點,那麼估計值應該會準確不少。bash
HyperLogLog經過分配 16384 個桶,而後對全部的桶的最大數量 K 進行調合平均來獲得一個平均的末尾零最大數量 K# ,K# 是一個浮點數,使用平均後的 2^K# 來估計元素的總量相對而言就會準確不少。不過這只是簡化算法,真實的算法還有不少修正因子,由於涉及到的數學理論知識過於繁多,這裏就再也不精確描述。數據結構
下面咱們看看Redis HyperLogLog 算法的具體實現。咱們知道一個HyperLogLog實際佔用的空間大約是 13684 * 6bit / 8 = 12k 字節。可是在計數比較小的時候,大多數桶的計數值都是零。若是 12k 字節裏面太多的字節都是零,那麼這個空間是能夠適當節約一下的。Redis 在計數值比較小的狀況下采用了稀疏存儲,稀疏存儲的空間佔用遠遠小於 12k 字節。相對於稀疏存儲的就是密集存儲,密集存儲會恆定佔用 12k 字節。app
不管是稀疏存儲仍是密集存儲,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,那麼就會跨越字節邊界,這時須要拼接兩個字節的位片斷。
# 低位值
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 使用了一個字節來表示接下來有多少個桶的計數值都是零: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 爲了方便表達稀疏存儲,它將上面三種字節表示形式分別賦予了一條指令。
#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
複製代碼
上圖可使用指令形式表示以下
當計數值達到必定程度後,稀疏存儲將會不可逆一次性轉換爲密集存儲。轉換的條件有兩個,任意一個知足就會當即發生轉換 ,也就是任意一個計數值從 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 深度歷險》,如今就開始閱讀《Redis 深度歷險》吧 !