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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335