背景
在工作当中我们经常会遇到一种场景就是统计一个网站的uv以及独立ip访问数等,常用的方法我们会用一个Set集合去储存对应的数据、或者从数据库distinct(这种方法不推荐),假如我们采用set集合去储存一天的uv量,那么set集合中我们储存用户id,假如一个用户id占用8bytes,一天的uv量为一千万,那么需要占据10000000*8/1024/1024大约八十兆,如果统计的维度增多以及时间跨度越来越大消耗的内存也会越来越大,那么有没有一种方法既不占内存又能把数据统计出来呢?redis的HyperLogLog就是专门做这个的。
简介
HyperLogLog是redis中非常特殊的一种计数器,HyperLogLog是基于基数统计来计算元素个数的,其优点在于在输入元素的数量或者体积非常非常大的时候,计算基数所需的空间总是固定的,并且是很小的。在Redis里面,每个Hyperloglog键只需要12Kb的大小就能计算接近2^64个不同元素的基数,但是hyperloglog只会根据输入元素来计算基数,而不会存储元素本身,所以不能像集合那样返回各个元素的信息,也不能判断某个元素是否存在,只适合数据统计。
使用方法
HyperLogLog 提供了两个指令 pfadd 和 pfcount以及pfmerge,一个是增加,一个是统计,一个是合并两个HyperLogLog计数。
127.0.0.1:6379> PFADD pf_user user1
(integer) 1
127.0.0.1:6379> PFADD pf_user user2
(integer) 1
127.0.0.1:6379> PFADD pf_user user3
(integer) 1
127.0.0.1:6379> PFCOUNT pf_user
(integer) 3
我们使用代码添加十万个元素看下结果是否准确。
public void redisPfTest() {
HyperLogLogOperations hyperLogLogOperations = redisTemplate.opsForHyperLogLog();
for (int i = 0; i < 100000; i++) {
hyperLogLogOperations.add("pf_user", "user" + i);
}
}
127.0.0.1:6379> PFCOUNT "\xac\xed\x00\x05t\x00\apf_user"
(integer) 100365
查看统计数,我们发现结果为100365,和100000差别只有365,也就是说准确率在1 00%-0.365%=99.635%,我们可以将代码再次运行一遍,你会发现计数不会变,并且数量越大准确度会越高。
HyperLogLog原理
基数统计
- bitmap基数计数
bitmap是一种以bit存储形式来进行计数的,bitmap自身具有去重的特点,一个基数值就代表bitmap中的一个bit,所以bitmap的大小和元素个数没有关系,但是与元素最大上限有关系。所以在数据量非常大的场景下bitmap会消耗很大一部分内存,例如一个十亿的数据量就需要消耗1000000000/8/1024/1024约等于120M。 - 概率基数计数
常见的概率基数基数有线性计数算法、对数计算方法、超对数计算方法、自适应计算方法,这里不对几种算法一一解释,redis采用的是超对数计算方法。这里我不对超对数计算方法的原理进行推导(有点复杂难得写公式😂),感兴趣的朋友可以参考Redis源码剖析–基数统计hyperloglog、神奇的HyperLogLog算法。
概率计数
如图所示,我们做一个随机数生成实验,每次生成的数我们都计算出该数字的最大连续0的个数我们称为k,随机实验次数我们记为n,那么n和k存在什么样的关系呢,这里我自己编写了一个测试方法做了一个实验,观察n和k的关系。该实验n从50000开始以10000为跨度做十次循环,每次循环都随机生成n个随机数,并且每个循环里面采用1024次二次循环,每次循环都用一个数组存储当前循环的maxZeros结果,内层循环结束后采用调和平均数得出最终的maxZeros值。
import java.util.concurrent.ThreadLocalRandom;
/**
* @author luojie
* @date 2019-11-18
*/
public class PfTest {
public static void main(String[] args) {
System.out.println("n log2(n) maxZeros");
for (int i = 50000; i < 150000; i += 10000) {
BitKeeper bitKeeper = add(i);
System.out.printf("%d %.2f %d\n", i, Math.log(i) / Math.log(2), bitKeeper.maxBits);
}
}
public static BitKeeper add(int n) {
BitKeeper bitKeeper = new BitKeeper();
//创建1024个桶
int[] bitAvg = new int[1024];
//循环1024次进行
for (int h = 0; h < 1024; h++) {
for (int j = 0; j < n; j++) {
//随机生成数字
bitKeeper.random(ThreadLocalRandom.current().nextInt());
}
bitAvg[h] = bitKeeper.maxBits;
bitKeeper.maxBits = 0;
}
float tempBit = 0;
//得出调和平均数
for (int i1 : bitAvg) {
tempBit += 1.0 / (float) i1;
}
bitKeeper.maxBits = (int) (bitAvg.length / tempBit);
return bitKeeper;
}
static class BitKeeper {
private int maxBits;
public void random(long value) {
int bits = lowZeros(value);
if (bits > this.maxBits) {
this.maxBits = bits;
}
}
private int lowZeros(long value) {
int i = 1;
for (; i < 32; i++) {
//取出数字i出现连续0的个数
if (value >> i << i != value) {
break;
}
}
return i - 1;
}
}
}
输出结果为:
由结果我们可以得出近似的公式:
n
≈
2
k
n≈2^k
n≈2k
当n越大的时候准确度会越高,redis也是采用类似的原理进行实现的,只不过redis内部实现比这更为复杂,redis内部采用的是16384个桶,每个桶都需要6bit来存储maxbits值,也就是maxbits最大能达到2的63次方。最终redis所需要的内存则为16384*6/8为12KB。
注意:HyperLogLog添加元素时并不会储存值,而是会将元素进行hash后进行转化为计数值,储存的是元素的计数值。假如添加元素a,a经过hash之后在第9527个桶,a的hash数最大连续0是8个,而9527桶当前的数值为6,对比之后则更新桶计数,将9527这个桶的计数值更新为8。
延展介绍-布隆过滤器
HyperLogLog对于估数是非常好的选择,比如在uv、每日ip访问数等场景,但是HyperLogLog也有其局限性,比如我们不能通过HyperLogLog判断某个元素是否存在,原因前面我们说过,因为HyperLogLog并不储存元素值而是储存hash之后的计数值,所以HyperLogLog是不能判断某个元素是否存在的,那么有没有一种既能像HyperLogLog一样节省内存又能判断元素是否存在呢?布隆过滤器(bloom filter)就可以做到。
简介
布隆过滤器我们可以认为他是一个不怎么精确的set结构,布隆过滤器的精度度是可以设置的,initial_size(预计放入的元素个数)和error_rate(误判率)两个参数,只要参数设置合理判断的结果还是比较准确的。布隆过滤器有个特性,就是当他判断某一个元素存在的时候,这个元素有可能会存在(存在误判情况),当他判断一个元素不存在的时候,那么这个元素一定不存在。
原理
布隆过滤器底层也是基于hash去做的,bloom添加元素的时候会使用多个hash函数去计算出元素的hash值,然后对底层位数组进行取模运算得到一个值,那么就将这个对应的位置置为1,每个hash值都做相同的操作,各个位置都置为1之后就算元素添加完成,那么判断一个元素是否存在也是一样的操作,先hash然后取模得出位置,在查看相应的位置是否为1,如果都满足则元素已经存在,否则元素不存在。
通过原理我们可以得出,bloom判断元素不存在那么这个元素一定是不存在的,如果判断存在可能会存在误判的情况。
bloom Filter的应用
bloom Filter对于不存在的元素判断是百分百准确的,所以利用这个功能我们可以减少磁盘io和网络请求,例如在某个拉新活动中,进来的都是很多新用户,由于缓存中不存在用户的数据大量的请求都会直接打到db层,这个时候就可以利用bloom Filter去过滤新用户,如果是新用户就避免请求去db查询,从而减少磁盘io。