redis 应用 4: HyperLogLog

我们先思考一个常见的业务问题:如果你负责开发维护一个大型的网站,有一天老板找产品经理要网站每个网页每天的 UV 数据,然后让你来开发这个统计模块,你会如何实现?

img
img

如果统计 PV 那非常好办,给每个网页一个独立的 Redis 计数器就可以了,这个计数器的 key 后缀加上当天的日期。这样来一个请求,incrby 一次,最终就可以统计出所有的 PV 数据。

但是 UV 不一样,它要去重,同一个用户一天之内的多次访问请求只能计数一次。这就要求每一个网页请求都需要带上用户的 ID,无论是登陆用户还是未登陆用户都需要一个唯一 ID 来标识。

你也许已经想到了一个简单的方案,那就是为每一个页面一个独立的 set 集合来存储所有当天访问过此页面的用户 ID。当一个请求过来时,我们使用 sadd 将用户 ID 塞进去就可以了。通过 scard 可以取出这个集合的大小,这个数字就是这个页面的 UV 数据。没错,这是一个非常简单的方案。

但是,如果你的页面访问量非常大,比如一个爆款页面几千万的 UV,你需要一个很大的 set 集合来统计,这就非常浪费空间。如果这样的页面很多,那所需要的存储空间是惊人的。为这样一个去重功能就耗费这样多的存储空间,值得么?其实老板需要的数据又不需要太精确,105w 和 106w 这两个数字对于老板们来说并没有多大区别,So,有没有更好的解决方案呢?

这就是本节要引入的一个解决方案,Redis 提供了 HyperLogLog 数据结构就是用来解决这种统计问题的。HyperLogLog 提供不精确的去重计数方案,虽然不精确但是也不是非常不精确,标准误差是 0.81%,这样的精确度已经可以满足上面的 UV 统计需求了。

HyperLogLog 数据结构是 Redis 的高级数据结构,它非常有用,但是令人感到意外的是,使用过它的人非常少。

使用方法

HyperLogLog 提供了两个指令 pfadd 和 pfcount,根据字面意义很好理解,一个是增加计数,一个是获取计数。pfadd 用法和 set 集合的 sadd 是一样的,来一个用户 ID,就将用户 ID 塞进去就是。pfcount 和 scard 用法是一样的,直接获取计数值。

bash复制代码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
(integer) 1
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

简单试了一下,发现还蛮精确的,一个没多也一个没少。接下来我们使用脚本,往里面灌更多的数据,看看它是否还可以继续精确下去,如果不能精确,差距有多大。人生苦短,我用 Python!Python 脚本走起来!😄

py复制代码# coding: utf-8

import redis

client = redis.StrictRedis()
for i in range(1000):
    client.pfadd("codehole""user%d" % i)
    total = client.pfcount("codehole")
    if total != i+1:
        print total, i+1
        break

当然 Java 也不错,大同小异,下面是 Java 版本:

java复制代码public class PfTest {
  public static void main(String[] args) {
    Jedis jedis = new Jedis();
    for (int i = 0; i < 1000; i++) {
      jedis.pfadd("codehole""user" + i);
      long total = jedis.pfcount("codehole");
      if (total != i + 1) {
        System.out.printf("%d %d\n", total, i + 1);
        break;
      }
    }
    jedis.close();
  }
}

我们来看下输出:

markdown复制代码> python pftest.py
99 100

当我们加入第 100 个元素时,结果开始出现了不一致。接下来我们将数据增加到 10w 个,看看总量差距有多大。

css复制代码# codingutf-8

import redis

client = redis.StrictRedis()
for i in range(100000):
    client.pfadd("codehole", "user%d" % i)
print 100000, client.pfcount("codehole")

Java 版:

java复制代码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();
  }
}

跑了约半分钟,我们看输出:

markdown复制代码> python pftest.py
100000 99723

差了 277 个,按百分比是 0.277%,对于上面的 UV 统计需求来说,误差率也不算高。然后我们把上面的脚本再跑一边,也就相当于将数据重复加入一边,查看输出,可以发现,pfcount 的结果没有任何改变,还是 99723,说明它确实具备去重功能。

pfadd 这个 pf 是什么意思?

它是 HyperLogLog 这个数据结构的发明人 Philippe Flajolet 的首字母缩写,老师觉得他发型很酷,看起来是个佛系教授。

img
img

pfmerge 适合什么场合用?

HyperLogLog 除了上面的 pfadd 和 pfcount 之外,还提供了第三个指令 pfmerge,用于将多个 pf 计数值累加在一起形成一个新的 pf 值。

比如在网站中我们有两个内容差不多的页面,运营说需要这两个页面的数据进行合并。其中页面的 UV 访问量也需要合并,那这个时候 pfmerge 就可以派上用场了。

注意事项

HyperLogLog 这个数据结构不是免费的,不是说使用这个数据结构要花钱,它需要占据一定 12k 的存储空间,所以它不适合统计单个用户相关的数据。如果你的用户上亿,可以算算,这个空间成本是非常惊人的。但是相比 set 存储方案,HyperLogLog 所使用的空间那真是可以使用千斤对比四两来形容了。

不过你也不必过于担心,因为 Redis 对 HyperLogLog 的存储进行了优化,在计数比较小时,它的存储空间采用稀疏矩阵存储,空间占用很小,仅仅在计数慢慢变大,稀疏矩阵占用空间渐渐超过了阈值时才会一次性转变成稠密矩阵,才会占用 12k 的空间。

HyperLogLog 实现原理

HyperLogLog 的使用非常简单,但是实现原理比较复杂,如果读者没有特别的兴趣,下面的内容暂时可以跳过不看。

为了方便理解 HyperLogLog 的内部实现原理,我画了下面这张图

img
img

这张图的意思是,给定一系列的随机整数,我们记录下低位连续零位的最大长度 k,通过这个 k 值可以估算出随机数的数量。 首先不问为什么,我们编写代码做一个实验,观察一下随机整数的数量和 k 值的关系。

py复制代码import math
import random

# 算低位零的个数
def low_zeros(value):
    for i in xrange(132):
        if value >> i << i != value:
            break
    return i - 1


# 通过随机数记录最大的低位零的个数
class BitKeeper(object):

    def __init__(self):
        self.maxbits = 0

    def random(self):
        value = random.randint(02**32-1)
        bits = low_zeros(value)
        if bits > self.maxbits:
            self.maxbits = bits


class Experiment(object):

    def __init__(self, n):
        self.n = n
        self.keeper = BitKeeper()

    def do(self):
        for i in range(self.n):
            self.keeper.random()

    def debug(self):
        print self.n, '%.2f' % math.log(self.n, 2), self.keeper.maxbits


for i in range(1000100000100):
    exp = Experiment(i)
    exp.do()
    exp.debug()

Java 版:

java复制代码public class PfTest {

  static class BitKeeper {
    private int maxbits;

    public void random() {
      long value = ThreadLocalRandom.current().nextLong(2L << 32);
      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 BitKeeper keeper;

    public Experiment(int n) {
      this.n = n;
      this.keeper = new BitKeeper();
    }

    public void work() {
      for (int i = 0; i < n; i++) {
        this.keeper.random();
      }
    }

    public void debug() {
      System.out.printf("%d %.2f %d\n"this.n, Math.log(this.n) / Math.log(2), this.keeper.maxbits);
    }
  }

  public static void main(String[] args) {
    for (int i = 1000; i < 100000; i += 100) {
      Experiment exp = new Experiment(i);
      exp.work();
      exp.debug();
    }
  }

}

运行观察输出:

复制代码36400 15.15 13
36500 15.16 16
36600 15.16 13
36700 15.16 14
36800 15.17 15
36900 15.17 18
37000 15.18 16
37100 15.18 15
37200 15.18 13
37300 15.19 14
37400 15.19 16
37500 15.19 14
37600 15.20 15

通过这实验可以发现 K 和 N 的对数之间存在显著的线性相关性:

ini
复制代码N=2^K  # 约等于

如果 N 介于 2^K 和 2^(K+1) 之间,用这种方式估计的值都等于 2^K,这明显是不合理的。这里可以采用多个 BitKeeper,然后进行加权估计,就可以得到一个比较准确的值。

py复制代码import math
import random

def low_zeros(value):
    for i in xrange(132):
        if value >> i << i != value:
            break
    return i - 1


class BitKeeper(object):

    def __init__(self):
        self.maxbits = 0

    def random(self, m):
        bits = low_zeros(m)
        if bits > self.maxbits:
            self.maxbits = bits


class Experiment(object):

    def __init__(self, n, k=1024):
        self.n = n
        self.k = k
        self.keepers = [BitKeeper() for i in range(k)]

    def do(self):
        for i in range(self.n):
            m = random.randint(01<<32-1)
            # 确保同一个整数被分配到同一个桶里面,摘取高位后取模
            keeper = self.keepers[((m & 0xfff0000) >> 16) % len(self.keepers)]
            keeper.random(m)

    def estimate(self):
        sumbits_inverse = 0  # 零位数倒数
        for keeper in self.keepers:
            sumbits_inverse += 1.0/float(keeper.maxbits)
        avgbits = float(self.k)/sumbits_inverse  # 平均零位数
        return 2**avgbits * self.k  # 根据桶的数量对估计值进行放大


for i in range(1000001000000100000):
    exp = Experiment(i)
    exp.do()
    est = exp.estimate()
    print i, '%.2f' % est, '%.2f' % (abs(est-i) / i)

下面是 Java 版:

java复制代码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);
    }
  }

}

代码中分了 1024 个桶,计算平均数使用了调和平均 (倒数的平均)。普通的平均法可能因为个别离群值对平均结果产生较大的影响,调和平均可以有效平滑离群值的影响。

img
img

观察脚本的输出,误差率控制在百分比个位数:

复制代码100000 97287.38 0.03
200000 189369.02 0.05
300000 287770.04 0.04
400000 401233.52 0.00
500000 491704.97 0.02
600000 604233.92 0.01
700000 721127.67 0.03
800000 832308.12 0.04
900000 870954.86 0.03
1000000 1075497.64 0.08

真实的 HyperLogLog 要比上面的示例代码更加复杂一些,也更加精确一些。上面的这个算法在随机次数很少的情况下会出现除零错误,因为 maxbits=0 是不可以求倒数的。

pf 的内存占用为什么是 12k?

我们在上面的算法中使用了 1024 个桶进行独立计数,不过在 Redis 的 HyperLogLog 实现中用到的是 16384 个桶,也就是 2^14,每个桶的 maxbits 需要 6 个 bits 来存储,最大可以表示 maxbits=63,于是总共占用内存就是2^14 * 6 / 8 = 12k字节。

本文由 mdnice 多平台发布

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/60539.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

后台管理系统:项目路由搭建与品牌管理

路由的搭建 先删除一些不需要的界面 然后发现跑不起来&#xff0c;我们需要去配置 删减成这样&#xff0c;然后自己新建需要的路由组件 改成这样&#xff0c;这里要注意。我们是在layout这个大的组件下面的&#xff0c;meta 中的title就是我们侧边栏的标题&#xff0c;icon可…

Java eight 解读流(Stream)、文件(File)、IO和异常处理的使用方法

目录 Java 流(Stream)、文件(File)和IO读取控制台输入读写文件FileInputStreamFileOutputStream Java目录 Java 异常处理 Java 流(Stream)、文件(File)和IO java.io 包几乎包含了所有操作输入、输出需要的类。所有这些流类代表了输入源和输出目标。 Java.io 包中的流支持很多种…

51页企业数字化转型战略实践与启示PPT(附400份转型资料)

本资料来源公开网络&#xff0c;仅供个人学习&#xff0c;请勿商用&#xff0c;如有侵权请联系删除&#xff0c;更多内容浏览公众号&#xff1a;智慧方案文库 企业数字化转型之路.pptx企业数字化转型大数据湖一体化平台项目建设方案PPT.pptx企业数字化转型大数据湖一体化运营管…

Angular安全专辑之四 —— 避免服务端可能的资源耗尽(NodeJS)

express-rate-limit是一个简单实用的npm包,用于在Express应用程序中实现速率限制。它可以帮助防止DDoS攻击和暴力破解,同时还允许对API端点进行流控。 express-rate-limit及其主要功能 express-rate-limit是Express框架的一个流行中间件,它允许根据IP地址或其他标准轻松地对请求…

U盘文件恢复软件推荐,这几款高效恢复数据!

“我真的可以算得上是一个u盘杀手了&#xff0c;好多资料保存在u盘中&#xff0c;但经常都会由于粗心导致u盘中的数据丢失。大家有什么u盘文件恢复软件可以推荐吗&#xff1f;救救我的u盘吧&#xff01;” 在现代社会&#xff0c;人手一个u盘一点也不夸张。尤其是学生党和打工人…

阿里云架构

负载均衡slb 分类以及应用场景 负载均衡slb clb 传统的负载均衡(原slb) 支持4层和7层(仅支持对uri(location),域名进行转发) 一般使用slb(clb) alb 应用负载均衡 只支持7层,整合了nginx负载均衡的各种功能,可以根据用户请求头,响应头 如果需要详细处理用户请求(浏…

华为数通方向HCIP-DataCom H12-821题库(单选题:141-160)

第141题 Router-LSA 能够描述不同的链路类型&#xff0c;不属于Router LSA 链路类型的是以下哪一项? A、Link Type 可以用来描述到末梢网络的连接&#xff0c;即 SubNet B、Link Type 可以用来描述到中转网络的连接&#xff0c;即 TranNet C、Link Type 可以用来描述到另一…

2023京东口腔护理赛道行业数据分析(京东销售数据分析)

近年来&#xff0c;口腔护理逐渐成为年轻人重视的健康领域&#xff0c;从口腔护理整体市场来看&#xff0c;牙膏和牙刷等基础口腔护理产品仍占据主导地位。不过&#xff0c;随着口腔护理市场逐步朝向精致化、专业化、多元化等方向发展&#xff0c;不少新兴口腔护理产品受到消费…

算法通关村第8关【白银】| 二叉树的深度和高度问题

1.最大深度问题 思路&#xff1a;递归三部曲 第一步&#xff1a;确定参数和返回值 题目要求求二叉树的深度&#xff0c;也就是有多少层&#xff0c;需要传递一个root从底层向上统计 int maxDepth(TreeNode root) 第二步&#xff1a;确定终止条件 当递归到null时就说明到底了…

SpringMVC中Controller层获取前端请求参数的几种方式

SpringMVC中Controller层获取前端请求参数的几种方式 1、SpringMVC自动绑定2、使用RequestParam 注解进行接收3、RequestBody注解&#xff08;1&#xff09; 使用实体来接收JSON&#xff08;2&#xff09;使用 Map 集合接收JSON&#xff08;3&#xff09; 使用 List集合接收JSO…

持续性能优化:确保应用保持高性能

在当今数字化时代&#xff0c;应用程序的性能已经成为用户体验和业务成功的关键因素之一。无论是Web应用、移动应用还是企业级软件&#xff0c;用户对于速度和响应性的要求越来越高。因此&#xff0c;持续性能优化已经成为保证应用在竞争激烈的市场中脱颖而出的重要策略。 什么…

线性代数的学习和整理15:线性代数的快速方法

1 数学/线性代数里&#xff0c;其实很多东西的求得都有多种解决办法 很多概念&#xff0c;界定狠清晰&#xff0c;但是不好求 多种方法&#xff0c;拓宽思维 方法1&#xff1a;按定义直接去求解 方法2&#xff1a;按 2 比如求逆矩阵 概念方法&#xff0c;线性变化 增广矩阵…

JS 常见的 6 种继承方式

原型链继承 原型链继承是比较常见的继承方式之一&#xff0c;其中涉及的构造函数、原型和实例&#xff0c;三者之间存在着一定的关系&#xff0c;即每一个构造函数都有一个原型对象&#xff0c;原型对象又包含一个指向构造函数的指针&#xff0c;而实例则包含一个原型对象的指…

【遮天】李小曼回归,新形象无差云曦,短板竟是身材?

Hello,小伙伴们&#xff0c;我是小郑继续为大家深度解析遮天 最新一集《遮天》已经更新&#xff0c;在成功卖掉段德之后&#xff0c;叶凡便离开妖帝坟冢&#xff0c;毕竟他身上拥有庞博从妖帝坟冢带出来的道经和被誉为中州至宝的绿铜 虽然这两样物品都在叶凡的苦海中&#xff0…

【Linux】序列化与反序列化

目录 前言 什么是应用层&#xff1f; 再谈"协议" 什么是序列化和反序列化 网络版计算器 整体流程实现 Sock.hpp的实现 TcpServer.hpp的实现 Protocol.hpp的实现 CalServer.cc的编写 CalClient.cc的编写 整体代码 前言 本章是属于TCP/UDP四层模型中的第一层…

VSCode连接服务器

Pycharm连接服务器参考我的另一篇文章Pycharm远程连接服务器_pycharm进入服务器虚拟环境终端_Jumbo星的博客-CSDN博客 本质上Pycharm和VSCode都只是IDE&#xff0c;没有什么好坏之分。但是因为Pycharm连接服务器&#xff08;准确来说是部署&#xff09;需要买professional。而…

Shell 脚本入门

目录 一、Shell是什么 1.1 我们为什么要学习Shell和使用Shell&#xff1f; 1.2 Shell的分类有哪些&#xff1f; 二、Shell脚本入门知识 2.1 Shell文件命名规范 2.2 Shell解析器 2.3 用Shell 编写hello World 三、Shell的四种变量类型 3.1 系统预定义变量 3.2 自定义变…

JavaWeb 速通Ajax

目录 一、Ajax快速入门 1.基本介绍 : 2.使用原理 : 二、Ajax经典入门案例 1.需求 : 2.前端页面实现 : 3. 处理HTTP请求的servlet实现 4.引入jar包及druid配置文件、工具类 : 5.Domain层实现 : 6.DAO层实现 : 7.Service层实现 : 8.运行测试 : 三、JQuery操作Ajax 1 …

kvm虚拟机开启VNC功能

停止kvm虚拟机 virsh shutdown 虚拟机名称 在kvm虚拟机配置文件里面添加如下内容 <graphics typevnc port-1 autoportyes listen0.0.0.0 keymapen-us passwd123456> 启动kvm虚拟机 virsh start 虚拟机名称 得到虚拟机进程id ps -ef|grep 虚拟机名称 得到虚拟机vnc…

kubernetes deploy standalone mysql demo

kubernetes 集群内部署 单节点 mysql ansible all -m shell -a "mkdir -p /mnt/mysql/data"cat mysql-pv-pvc.yaml apiVersion: v1 kind: PersistentVolume metadata:name: mysql-pv-volumelabels:type: local spec:storageClassName: manualcapacity:storage: 5Gi…