见缝插针 —— 深刻 Redis HyperLogLog 内部数据结构分析

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 为了方便表达稀疏存储,它将上面三种字节表示形式分别赋予了一条指令。

  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
复制代码

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

存储转换

当计数值达到必定程度后,稀疏存储将会不可逆一次性转换为密集存储。转换的条件有两个,任意一个知足就会当即发生转换 ,也就是任意一个计数值从 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 深度历险》吧 !

相关文章
相关标签/搜索