欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Redis与HyperLogLog

程序员文章站 2022-07-11 15:34:35
...

今天,我有幸拜读了老钱的《Redis深度历险:核心原理与应用实践》,小册里面有一篇关于HyperLogLog的文章,其中算法部分对本菜鸟来说还是有些晦涩难懂。因此,我写下这篇博客,一是为了将我对Redis与HyperLogLog的理解记录下来;二是为了以更白话的方式来描述Redis与HyperLogLog之间的关系,让小白都能读懂。

一、Redis与HyperLogLog
Redis是什么我就不再详述了,不知道的人可自行谷歌(baidu)。而HyperLogLog则是一种算法,它提供了不精确的去重计数方案。

举个栗子:假如我要统计网页的UV(浏览用户数量,一天内同一个用户多次访问只能算一次),传统的解决方案是使用Set来保存用户id,然后统计Set中的元素数量来获取页面UV。但这种方案只能承载少量用户,一旦用户数量大起来就需要消耗大量的空间来存储用户id。我的目的是统计用户数量而不是保存用户,这简直是个吃力不讨好的方案!而使用Redis的HyperLogLog最多需要12k就可以统计大量的用户数,尽管它大概有0.81%的错误率,但对于统计UV这种不需要很精确的数据是可以忽略不计的。

那么,为什么HyperLogLog可以在不保存数据的情况下进行去重计数呢?让我们拭目以待!

二、HyperLogLog算法
在真正介绍HyperLogLog算法之前,我们来回顾一个简单的概率论知识:

抛一个硬币,抛一次为反面的概率是1/2,抛两次都为反面的概率是1/4,抛三次都为反面的概率是1/8…依次类推,连续抛k次都是反面的概率1为2^(-k)。 因此,从概率论上来说,假如每一轮抛硬币的结果都不一样,我们最多尝试2^k轮就能抛出连续反面的情况。

HyperLogLog算法也是基于上面这个概率论知识,他认为:给定一系列的随机整数,我们可以通过这些随机整数的低位连续零位的最大长度 k,估算出随机数的数量,估算的公式为:n=2^k(n为随机数数量)。

接下来我们用代码来验证这个结论:

public class HyperLogLog {

    /* 低位连续0的最大数量 */
    private int maxZero;
    /* 随机数数量 */
    private int count;

    public HyperLogLog(int count){
        this.count = count;
    }

    private void lowZero(long value){
        int i = 1;
        for ( ; i<32; i++){
            /* 如果一个数右移i位后再左移i位还是保持值不变,那么它的低i位都是0 */
            if (value >> i << i != value){
                break;
            }
        }
        /* 因为i是从1开始的,所以要减1 */
        i = i - 1;

        if (this.maxZero < i){
            this.maxZero = i;
        }
    }

    public void random(){
        for (int i=0; i<this.count; i++){
            long m = ThreadLocalRandom.current().nextLong(1L << 32);
            lowZero(m);
        }
    }

    public int getMaxZero(){
        return this.maxZero;
    }

    public static void main(String[] args) {
        for (int i=10000; i<=100000; i+=10000){
            HyperLogLog hll = new HyperLogLog(i);
            hll.random();
            System.out.printf("期待连续0的个数为:%.0f,统计连续0的个数为:%d" , Math.log(i)/Math.log(2),  hll.getMaxZero());
            System.out.println();
        }
    }
}

输出结果为:

期待连续0的个数为:13,统计连续0的个数为:14
期待连续0的个数为:14,统计连续0的个数为:15
期待连续0的个数为:15,统计连续0的个数为:15
期待连续0的个数为:15,统计连续0的个数为:16
期待连续0的个数为:16,统计连续0的个数为:14
期待连续0的个数为:16,统计连续0的个数为:15
期待连续0的个数为:16,统计连续0的个数为:16
期待连续0的个数为:16,统计连续0的个数为:16
期待连续0的个数为:16,统计连续0的个数为:14
期待连续0的个数为:17,统计连续0的个数为:17

由输出结果可以看出,随机数数量n大概介于2(k-1)到2(k+1)之间。

三、优化HyperLogLog算法
上述HyperLogLog算法误差还是比较大,个别案例统计与期待低位连续0个数相差了2。为了减少特例带来的影响,数学家们给这个算法引入了调和平均的概念。

所谓调和平均,其实就是倒数的平均:

普通平均:avg=(1+2+3+4)/4

调和平均:avg=4/(1/1+1/2+1/3+1/4)

为什么调和平均能降低特例带来的误差呢?我们举个栗子来说明:

假如小猿月薪1000大洋,他老板月薪10000大洋,如果按传统的方法来算平均工资,平均月薪=(1000+10000)/2=5500,这对小猿来说会很不公,“为什么我会这么严重地拉低平均工资? 不行,我要回农村”,小猿如是想。

如果用调和平均来算,平均月薪=2/(1/1000+1/10000)=1818.18,小猿会觉得他也没拉低平均工资多少,努力一把就上去了,这样他会继续留在城里打拼,走上人生巅峰,迎娶白富美。

从上述例子可以看出:调和平均算法总是偏向低的一方。这对HyperLogLog算法是很有帮助的:假如我只尝试了两个不同的数,它们的二进制分别为“00010”和“10000”,用普通求平均数算法估算出我们出尝试了2^4 =16个不同的数,但用调和平均算法估算出我们尝试了2^(2/(1/1+1/4)) =2^1.6=3个不同的数,因此使用调和平均的算法更贴近实际值。

为了使用调和平均,Redis引入了桶的概念,上述算法可以演变成以下公式:假设不同随机数数量为n,桶树为k,第i个桶的低位连续0最大个数为z[i],则
Redis与HyperLogLog
废话不多说,码上见分晓:

class Experiment{
    /* 桶数 */
    private int bucketCount;
    /* 随机值数量 */
    private int valueCount;
    private Bucket[] buckets;

    public Experiment(int bucketCount,  int valueCount){
        this.bucketCount = bucketCount;
        this.valueCount = valueCount;
        this.buckets = new Bucket[bucketCount];
        for (int i=0; i<bucketCount; i++){
            buckets[i] = new Bucket(32);
        }
    }

    /**
     * 将valueCount个随机值散列到各个桶中
     */
    public void work() {
        for (int i = 0; i < this.valueCount; i++) {
            long m = ThreadLocalRandom.current().nextLong(1L << 32);
            Bucket bucket = buckets[(int) (((m & 0xfff0000) >> 16) % bucketCount)];
            bucket.lowZero(m);
        }
    }

    /**
     * 使用调和平均计算低位连续0的最大数量的平均值
     * @return
     */
    public double caculate(){
        double totalBit = 0.0;
        for (int i=0; i<bucketCount; i++){
            totalBit += 1.0 / (double)this.buckets[i].getMaxZero();
        }
        double averageBit = (double)bucketCount / totalBit;

        return Math.pow(2, averageBit) * bucketCount;
    }

    public static void main(String[] args) {
        for (int i = 100000; i < 1000000; i += 100000){
            Experiment e = new Experiment(1024, i);
            e.work();

            double num = e.caculate();
            System.out.printf("实际数量:%d,计算数量:%.2f,错误率:%.2f", i, num, Math.abs(num-i)/i);
            System.out.println();
        }
    }
}

/**
 * 桶
 */
class Bucket{
    private int maxZero;
    private int bit;

    public Bucket(int bit){
        this.bit = bit;
    }

    /**
     * 计算低位连续0的最大数量
     */
    public void lowZero(long value){
        int i = 1;
        for (; i<bit; i++){
            if (value >> i << i != value){
                break;
            }
        }
        maxZero = maxZero>i-1 ? maxZero:i-1;
    }

    public int getMaxZero() {
        return maxZero;
    }
}

输出结果如下:

实际数量:100000,计算数量:92466.71,错误率:0.08
实际数量:200000,计算数量:193525.45,错误率:0.03
实际数量:300000,计算数量:291640.53,错误率:0.03
实际数量:400000,计算数量:383235.77,错误率:0.04
实际数量:500000,计算数量:464098.22,错误率:0.07
实际数量:600000,计算数量:651661.94,错误率:0.09
实际数量:700000,计算数量:694357.90,错误率:0.01
实际数量:800000,计算数量:837294.56,错误率:0.05
实际数量:900000,计算数量:872591.90,错误率:0.03

错误率比不使用调和平均党的方式低了好多。

四、再谈Redis与HyperLogLog
Redis在去重计算时怎么和HyperLogLog算法联系起来呢?这个问题我(小菜鸟)思考了好久。

当在Redis中添加(pfadd命令)一个计数元素时,这个元素会被散列到某个桶中,它就相当于上述代码中的随机数,最终我们可以通过pfcount命令获取去重之后的随机数个数。

当统计元素很少时,使用12k太浪费空间了,Redis针对这个问题做了一些优化。在计数比较小时,Redis使用稀疏矩阵存储,空间占用很小,仅仅在计数慢慢变大后,稀疏矩阵占用空间渐渐超过了阈值时才会一次性转变成稠密矩阵,占用 12k的空间。这就是为什么说Redis最多占用12的内存了。

那么这12k的空间是怎么分配给每个桶的呢?Redis的HyperLogLog实现中用到了2^14 =16384个桶,每个桶占6bit,因此总共占用内存就是2^14*6/8=12k字节。

五、结尾
本文只是浅析了一下Redis底层的HyperLogLog算法,实际上Redis里的HyperLogLog还做了很多优化,感兴趣的小伙伴可以阅读Redis的相关源码。

相关标签: Redis redis