HyperLogLog 是最先由 Flajolet 及其同事在 2007 年提出的一种 估算基数的近似最优算法。但跟原版论文不一样的是,好像不少书包括 Redis 做者都把它称为一种 新的数据结构(new datastruct) html
基数统计(Cardinality Counting) 一般是用来统计一个集合中不重复的元素个数。java
思考这样的一个场景: 若是你负责开发维护一个大型的网站,有一天老板找产品经理要网站上每一个网页的 UV(独立访客,每一个用户天天只记录一次),而后让你来开发这个统计模块,你会如何实现?python
若是统计 PV(浏览量,用户没点一次记录一次),那很是好办,给每一个页面配置一个独立的 Redis 计数器就能够了,把这个计数器的 key 后缀加上当天的日期。这样每来一个请求,就执行 INCRBY
指令一次,最终就能够统计出全部的 PV 数据了。git
可是 UV 不一样,它要去重,同一个用户一天以内的屡次访问请求只能计数一次。这就要求了每个网页请求都须要带上用户的 ID,不管是登陆用户仍是未登陆的用户,都须要一个惟一 ID 来标识。程序员
你也许立刻就想到了一个 github
对于上述这样须要 基数统计 的事情,一般来讲有两种比 set 集合更好的解决方案:golang
B 树最大的优点就是插入和查找效率很高,若是用 B 树存储要统计的数据,能够快速判断新来的数据是否存在,并快速将元素插入 B 树。要计算基础值,只须要计算 B 树的节点个数就好了。redis
不过将 B 树结构维护到内存中,可以解决统计和计算的问题,可是 并无节省内存。算法
bitmap 能够理解为经过一个 bit 数组来存储特定数据的一种数据结构,每个 bit 位都能独立包含信息,bit 是数据的最小存储单位,所以能大量节省空间,也能够将整个 bit 数据一次性 load 到内存计算。若是定义一个很大的 bit 数组,基础统计中 每个元素对应到 bit 数组中的一位,例如:sql
bitmap 还有一个明显的优点是 能够轻松合并多个统计结果,只须要对多个结果求异或就能够了,也能够大大减小存储内存。能够简单作一个计算,若是要统计 1 亿 个数据的基数值,大约须要的内存:100_000_000/ 8/ 1024/ 1024 ≈ 12 M
,若是用 32 bit 的 int 表明 每个 统计的数据,大约须要内存:32 * 100_000_000/ 8/ 1024/ 1024 ≈ 381 M
能够看到 bitmap 对于内存的节省显而易见,但仍然不够。统计一个对象的基数值就须要 12 M
,若是统计 1 万个对象,就须要接近 120 G
,对于大数据的场景仍然不适用。
实际上目前尚未发现更好的在 大数据场景 中 准确计算 基数的高效算法,所以在不追求绝对精确的状况下,使用几率算法算是一个不错的解决方案。
几率算法 不直接存储 数据集合自己,经过必定的 几率统计方法预估基数值,这种方法能够大大节省内存,同时保证偏差控制在必定范围内。目前用于基数计数的几率算法包括:
其中,HyperLogLog 的表现是惊人的,上面咱们简单计算过用 bitmap 存储 1 个亿 统计数据大概须要 12 M
内存,而在 HyperLoglog 中,只须要不到 1 K 内存就可以作到!在 Redis 中实现的 HyperLoglog 也只须要 12 K 内存,在 标准偏差 0.81% 的前提下,可以统计 264 个数据!
这是怎么作到的?! 下面赶忙来了解一下!
咱们来思考一个抛硬币的游戏:你连续掷 n 次硬币,而后说出其中连续掷为正面的最大次数,我来猜你一共抛了多少次。
这很容易理解吧,例如:你说你这一次
这期间我可能会要求你重复实验,而后我获得了更多的数据以后就会估计得更准。咱们来把刚才的游戏换一种说法:
这张图的意思是,咱们给定一系列的随机整数,记录下低位连续零位的最大长度 K,即为图中的 maxbit
,经过这个 K 值咱们就能够估算出随机数的数量 N。
咱们能够简单编写代码作一个实验,来探究一下 K
和 N
之间的关系:
public class PfTest {
static class BitKeeper {
private int maxbit;
public void random() {
long value = ThreadLocalRandom.current().nextLong(2L << 32);
int bit = lowZeros(value);
if (bit > this.maxbit) {
this.maxbit = bit;
}
}
private int lowZeros(long value) {
int i = 0;
for (; i < 32; i++) {
if (value >> i << i != value) {
break;
}
}
return i - 1;
}
}
static class Experiment {
private int n;
private BitKeeper keeper;
public Experiment(int n) {
this.n = n;
this.keeper = new BitKeeper();
}
public void work() {
for (int i = 0; i < n; i++) {
this.keeper.random();
}
}
public void debug() {
System.out
.printf("%d %.2f %d\n", this.n, Math.log(this.n) / Math.log(2), this.keeper.maxbit);
}
}
public static void main(String[] args) {
for (int i = 1000; i < 100000; i += 100) {
Experiment exp = new Experiment(i);
exp.work();
exp.debug();
}
}
}
复制代码
跟上图中的过程是一致的,话说为啥叫 PfTest
呢,包括 Redis 中的命令也同样带有一个 PF
前缀,还记得嘛,由于 HyperLogLog 的提出者上文提到过的,叫 Philippe Flajolet
。
截取部分输出查看:
//n n/log2 maxbit
34000 15.05 13
35000 15.10 13
36000 15.14 16
37000 15.18 17
38000 15.21 14
39000 15.25 16
40000 15.29 14
41000 15.32 16
42000 15.36 18
复制代码
会发现 K
和 N
的对数之间存在显著的线性相关性:N 约等于 2k
若是 N
介于 2k 和 2k+1 之间,用这种方式估计的值都等于 2k,这明显是不合理的,因此咱们可使用多个 BitKeeper
进行加权估计,就能够获得一个比较准确的值了:
public class PfTest {
static class BitKeeper {
// 无变化, 代码省略
}
static class Experiment {
private int n;
private int k;
private BitKeeper[] keepers;
public Experiment(int n) {
this(n, 1024);
}
public Experiment(int n, int k) {
this.n = n;
this.k = k;
this.keepers = new BitKeeper[k];
for (int i = 0; i < k; i++) {
this.keepers[i] = new BitKeeper();
}
}
public void work() {
for (int i = 0; i < this.n; i++) {
long m = ThreadLocalRandom.current().nextLong(1L << 32);
BitKeeper keeper = keepers[(int) (((m & 0xfff0000) >> 16) % keepers.length)];
keeper.random();
}
}
public double estimate() {
double sumbitsInverse = 0.0;
for (BitKeeper keeper : keepers) {
sumbitsInverse += 1.0 / (float) keeper.maxbit;
}
double avgBits = (float) keepers.length / sumbitsInverse;
return Math.pow(2, avgBits) * this.k;
}
}
public static void main(String[] args) {
for (int i = 100000; i < 1000000; i += 100000) {
Experiment exp = new Experiment(i);
exp.work();
double est = exp.estimate();
System.out.printf("%d %.2f %.2f\n", i, est, Math.abs(est - i) / i);
}
}
}
复制代码
这个过程有点 相似于选秀节目里面的打分,一堆专业评委打分,可是有一些评委由于本身特别喜欢因此给高了,一些评委又打低了,因此通常都要 屏蔽最高分和最低分,而后 再计算平均值,这样的出来的分数就差很少是公平公正的了。
上述代码就有 1024 个 "评委",而且在计算平均值的时候,采用了 调和平均数,也就是倒数的平均值,它能有效地平滑离群值的影响:
avg = (3 + 4 + 5 + 104) / 4 = 29
avg = 4 / (1/3 + 1/4 + 1/5 + 1/104) = 5.044
复制代码
观察脚本的输出,偏差率百分比控制在个位数:
100000 94274.94 0.06
200000 194092.62 0.03
300000 277329.92 0.08
400000 373281.66 0.07
500000 501551.60 0.00
600000 596078.40 0.01
700000 687265.72 0.02
800000 828778.96 0.04
900000 944683.53 0.05
复制代码
真实的 HyperLogLog 要比上面的示例代码更加复杂一些,也更加精确一些。上面这个算法在随机次数不多的状况下会出现除零错误,由于 maxbit = 0
是不能够求倒数的。
有一个神奇的网站,能够动态地让你观察到 HyperLogLog 的算法究竟是怎么执行的:content.research.neustar.biz/blog/hll.ht…
其中的一些概念这里稍微解释一下,您就能够自行去点击 step
来观察了:
101110
实则表示二进制的
46
,因此该元素被统计在中间大表格
Register Values
中标红的第 46 个桶之中;
Register Values
第 46 个桶中写入 1;
由于第一个 1 出现的位置能够同咱们抛硬币的游戏中第一次抛到正面的抛掷次数对应起来,根据上面掷硬币实验的结论,记录每一个数据的第一个出现的位置 K
,就能够经过其中最大值 Kmax 来推导出数据集合中的基数:N = 2Kmax
咱们上面的算法中使用了 1024 个桶,网站演示也只有 64 个桶,不过在 Redis 的 HyperLogLog 实现中,用的是 16384 个桶,即:214,也就是说,就像上面网站中间那个 Register Values
大表格有 16384 格。
而Redis 最大可以统计的数据量是 264,即每一个桶的 maxbit
须要 6 个 bit 来存储,最大能够表示 maxbit = 63
,因而总共占用内存就是:(214) x 6 / 8
12 KB
。
从上面咱们算是对 HyperLogLog 的算法和思想有了必定的了解,而且知道了一个 HyperLogLog 实际占用的空间大约是 12 KB
,但 Redis 对于内存的优化很是变态,当 计数比较小 的时候,大多数桶的计数值都是 零,这个时候 Redis 就会适当节约空间,转换成另一种 稀疏存储方式,与之相对的,正常的存储模式叫作 密集存储,这种方式会恒定地占用 12 KB
。
密集型的存储结构很是简单,就是 16384 个 6 bit 连续串成 的字符串位图:
咱们都知道,一个字节是由 8 个 bit 组成的,这样 6 bit 排列的结构就会致使,有一些桶会 跨越字节边界,咱们须要 对这一个或者两个字节进行适当的移位拼接 才能够获得具体的计数值。
假设桶的编号为 index
,这个 6 bity 计数值的起始字节偏移用 offset_bytes
表示,它在这个字节的其实比特位置偏移用 offset_bits
表示,因而咱们有:
offset_bytes = (index * 6) / 8
offset_bits = (index * 6) % 8
复制代码
前者是商,后者是余数。好比 bucket 2
的字节偏移是 1,也就是第 2 个字节。它的位偏移是 4,也就是第 2 个字节的第 5 个位开始是 bucket 2 的计数值。须要注意的是 字节位序是左边低位右边高位,而一般咱们使用的字节都是左边高位右边低位。
这里就涉及到两种状况,若是 offset_bits
小于等于 2,说明这 6 bit 在一个字节的内部,能够直接使用下面的表达式获得计数值 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 的源码要晦涩一点,看形式它彷佛只考虑了跨越字节边界的状况。这是由于若是 6 bit 在单个字节内,上面代码中的 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
个零值计数器。
01xxxxxx yyyyyyyy
:6bit 最多只能表示连续
64
个零值计数器,这样扩展出的 14bit 能够表示最多连续
16384
个零值计数器。这意味着 HyperLogLog 数据结构中
16384
个桶的初始状态,全部的计数器都是零值,能够直接使用 2 个字节来表示。
1vvvvvxx
:中间 5bit 表示计数值,尾部 2bit 表示连续几个桶。它的意思是连续
(xx +1)
个计数值都是
(vvvvv + 1)
。好比
10101011
表示连续
4
个计数值都是
11
。
注意
32
,而 HyperLogLog 的密集存储单个计数值用 6bit 表示,最大能够表示到
63
。
当稀疏存储的某个计数值须要调整到大于 32
时,Redis 就会当即转换 HyperLogLog 的存储结构,将稀疏存储转换成密集存储。
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 对象当成普通的字符串来进行处理:
> PFADD codehole python java golang
(integer) 1
> GET codehole
"HYLL\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80C\x03\x84MK\x80P\xb8\x80^\xf3"
复制代码
可是 不能够 使用 HyperLogLog 指令来 操纵普通的字符串,由于它须要检查对象头魔术字符串是不是 "HYLL"。
HyperLogLog 提供了两个指令 PFADD
和 PFCOUNT
,字面意思就是一个是增长,另外一个是获取计数。PFADD
和 set
集合的 SADD
的用法是同样的,来一个用户 ID,就将用户 ID 塞进去就是,PFCOUNT
和 SCARD
的用法是一致的,直接获取计数值:
> PFADD codehole user1
(interger) 1
> PFCOUNT codehole
(integer) 1
> PFADD codehole user2
(integer) 1
> PFCOUNT codehole
(integer) 2
> PFADD codehole user3
(integer) 1
> PFCOUNT codehole
(integer) 3
> PFADD codehole user4 user 5
(integer) 1
> PFCOUNT codehole
(integer) 5
复制代码
咱们能够用 Java 编写一个脚原本试试 HyperLogLog 的准确性到底有多少:
public class JedisTest {
public static void main(String[] args) {
for (int i = 0; i < 100000; i++) {
jedis.pfadd("codehole", "user" + i);
}
long total = jedis.pfcount("codehole");
System.out.printf("%d %d\n", 100000, total);
jedis.close();
}
}
复制代码
结果输出以下:
100000 99723
复制代码
发现 10
万条数据只差了 277
,按照百分比偏差率是 0.277%
,对于巨量的 UV 需求来讲,这个偏差率真的不算高。
固然,除了上面的 PFADD
和 PFCOUNT
以外,还提供了第三个 PFMEGER
指令,用于将多个计数值累加在一块儿造成一个新的 pf
值:
> PFADD nosql "Redis" "MongoDB" "Memcached"
(integer) 1
> PFADD RDBMS "MySQL" "MSSQL" "PostgreSQL"
(integer) 1
> PFMERGE databases nosql RDBMS
OK
> PFCOUNT databases
(integer) 6
复制代码
本文已收录至个人 Github 程序员成长系列 【More Than Java】,学习,不止 Code,欢迎 star:github.com/wmyskxz/Mor… 我的公众号 :wmyskxz, 我的独立域名博客:wmyskxz.com,坚持原创输出,下方扫码关注,2020,与您共同成长!
很是感谢各位人才能 看到这里,若是以为本篇文章写得不错,以为 「我没有三颗心脏」有点东西 的话,求点赞,求关注,求分享,求留言!
创做不易,各位的支持和承认,就是我创做的最大动力,咱们下篇文章见!