背景
实现一个统计页面的UV数据,每个网页的用户访问量(同一个用户多次请求只算一次)。那这个功能我们怎么去实现呢?
也许有同学就会说了,我们都用的是growingIO,不用自己实现成本太高,直接用别人的。这样也挺好。
今天我们来看一下我们自己如何实现这个需求并且可以抗击较高的TPS服务呢?
设计方案
- 既然是页面用户量的统计是不重复的那我们选择一个数据结构那就是SET集合进行存储。将用户的ID进行存储,如果是没有登录的用户随机生成一个(使用时间戳等),存入set。为了快那就基于内存来搞?但是不可能用自身服务的内存吧,那就借助与第三方服务的内存,那就使用redis进行且自身带有set集合数据结构。
- 选定好了方向和中间件,那就得考虑一下量的问题,统计的可都是热点页面,一天有个几千万的UV,那你就得有很大的浪费很大的空间进行存储,那我们这样做值得吗?还有就是只是为了大概过一下请求量不需要太精确的数据,那我们还有没有更佳的数据结构呢?
- Redis 提供了 HyperLogLog 数据结构就是用来解决
这种统计问题的
HyPerLogLog
简介
HyperLogLog 提供不精确的去重计数方案,虽然不精确但是也不是非常不
精确,标准误差是 0.81%,这样的精确度已经可以满足上面的 UV 统计需求了。
HyperLogLog 数据结构是 Redis 的高级数据结构,它非常有用,今天我们就来“深入”的探究一下(九浅一深的那种)。
使用
- HyperLogLog提供了三个指令pfadd和pfcount ,pfmerge 根据字面意义很好理解,一个是增加计数一个是获取计数,pfadd的用法和set集合的sadd是一样的,来一个用户ID,就将用户ID 赛进去就是。pfcount和scard的用法是一样的。直接获取计数值。还有pfmerge字面意思是合并,那就是当两个key的统计合为一个key的统计(将两个页面合在一起的统计数量)
- 但是有人会问为什么又个pf呢?咋不安常规套路出牌,HL不是更好吗?这个PF是这个数据结构发明人的拼写。
- 可以对应一下上面的业务需求,将页面作为key 将用户iD放入HyPerLogLog进行统计。
127.0.0.1:6379> pfadd codehole user1
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 1
127.0.0.1:6379> pfadd codehole user2
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 2
127.0.0.1:6379> pfadd codehole user3
127.0.0.1:6379> pfcount codehole
(integer) 3
127.0.0.1:6379> pfadd codehole user4
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 4
127.0.0.1:6379> pfadd codehole user5
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 5
127.0.0.1:6379> pfadd codehole user6
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 6
127.0.0.1:6379> pfadd codehole user7 user8 user9 user10
(integer) 1
127.0.0.1:6379> pfcount codehole
(integer) 10
进行测试一下,感觉还是挺准确的。凭感觉这回事,往往都是错误的。
我们通过脚本进行大量的测试一下:
public class JedisTest {
public static void main(String[] args) {
Jedis jedis = new Jedis();
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
再跑一遍:100000 99723
没有变说明确实是去重了。
差了 277 个,按百分比是 0.277%
实现原理
几个重要的概念:
-
HyperLogLog实际上不会存储每个元素的值,它使用的是概率算法,通过存储元素的hash值的第一个1的位置,来计算元素数量。
-
伯努利实验: 大概意思就是 进行N撩妹,无非就是撩到妹子和没撩到妹子(成功和不成功都为50%,没有其他因素)。撩到妹子就算一回合(比如说你撩妹100次都没成功底101次成功了那这101次才算一回合)。于是进行了N回合,把最长的那次给哥们说了,让你哥们猜测一共进行了多少回合?(也就是猜这个N是多少)。有一个大佬也遇到了此问题通过大量实验得到了一个公式,就可以计算出来,具体了解可以点击(https://zhuanlan.zhihu.com/p/58519480)
-
下面图的意思是,给定一系列的随机整数,我们记录下低位连续零位的最大长度 k,通
过这个 k 值可以估算出随机数的数量。 (这个随机数类比为我们的用户ID,最大长度K也就是这个随机数通过hash计算出来放在桶上的重复次数(个人理解))
可以和上面的伯努利实验对比一下。
4.总而言之 我们可以在不存储值的情况下,通过概率计算公式可以得到相差不是很大的统计结果。
代码简单的实现
- 实现的一个简单的计算公式(帮助理解一下):
import java.util.concurrent.ThreadLocalRandom;
public class PfTest {
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++) {
if (value >> i << i != value) {
break;
}
}
return i - 1;
}
}
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(m);
}
}
public double estimate() {
double sumbitsInverse = 0.0;
for (BitKeeper keeper : keepers) {
sumbitsInverse += 1.0 / (float) keeper.maxbits;
}
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);
}
}
}
总结
- HyperLogLog可以高性能实现UV统计,但是会有百分比的误差
- HyperLogLog的三个命令 pfadd pfcount pfmerge
- HyperLogLog的大概原理通过概率统计(伯努利实验)得出结果,不用记录具体的值
- 使用Java简单实现概率统计,计算随机数的量。
更加牛皮的文章
https://zhuanlan.zhihu.com/p/58519480
https://en.wikipedia.org/wiki/HyperLogLog
《redis深度历险》–书