Redis与HyperLogLog

今天,我有幸拜读了老钱的《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],则

废话不多说,码上见分晓:

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的相关源码。