一、问题引入

大家在项目上可能会遇到过下面这些相同或者类似的需求:

  1. 统计一个APP的日活量(DAU)和月活量(MAU)。日活(月活)是指在一个统计日(统计月)之内,登录或者使用产品的不同用户数量,它是产品运营情况的重要指标之一,反映了用户的活跃度。

  2. 统计网站上各个网页的独立访客数,例如有些网页上面有阅读数。独立访客数需要对用户做去重,同一个用户多次访问只计算一次。

概括来说,这类需求就是去统计一个集合中不重复元素的个数。在数学上把一个集合不重复元素的个数称为集合的基数(cardinality,也有叫做势),这类问题可以称为基数统计问题。问题其实很简单,有很多常规的方法或数据结构去解决,例如:

  1. 集合Set
    将所有元素存储在一个Set(可以用哈希表或树实现)中,利用Set对元素进行去重。该方法可以精确的计算出不重复元素的数量,由于需要存储实际的数据,在数据量较少时可行,但是数据量达到百万、千万甚至上亿时,会占用大量的内存。假设有1亿个不重复的元素,每个数据大小是4字节,那么使用Set结构至少需要内存 100000000∗4B=400MB。

  2. 位图 Bitmap
    位图是一个大的bit数组,我们不需要去保存实际的元素,只需要用1bit来标识某个元素是否出现过,这样能够极大地节省内存。位图占用的内存和元素的值域有关,因为我们需要把值域映射到这个大的比特数组上。假设元素的值域是 [1,1亿],那么采用位图需要的内存就是 100000000∗1bit / 8 ≈12MB 。该方法也能够精确的计算出不重复元素的数量,比起Set来说,内存占用确实减少很多,但是如果需要统计上千个模块或者业务的数据,那么内存消耗依然很大。

上述这些方法在数据量较小的时候,都是可以有效的解决基数统计问题,但是当数据量较大的时候,这些方法占用的内存可能就无法接受了。

那么在海量数据的场景下,有什么方法去解决基数统计的问题呢?

二、大数据场景下的基数统计算法

基数统计是大数据场景中经常需要处理的问题,也有很多统计的算法,例如:

  • Linear Counting(LC)

  • LogLog Counting(LLC)

  • HyperLogLog Counting(HLLC)

  • ······

Linear算法和位图类似,但实际使用的不多,这里不多做介绍了,本文主要介绍HyperLogLog算法。"Hyper"是“超”的意思,和HTTP中的H是一个意思,从名字中也可以看出,HyperLogLog是LogLog算法的一个改进和优化,后面我们会一起讲解这两个算法,并学习HyperLogLog是如何在LogLog的基础上改进的。

需要明确的是,上述这些算法有个共同的特点,都不能精确计算集合的基数,而是概率计算,有一定的误差。因此在不追求绝对准确的情况下,可以考虑使用这些算法。下面我们就介绍一下LLC和HLLC算法的基本原理,以及如何一步一步的设计并优化的。

2.1 伯努利试验

提到概率就不得不提伯努利,伯努利在概率论中有着重要的地位,有个著名的实验叫做伯努利试验。LLC和HLLC算法底层的数学原理就是伯努利试验。所以我们需要去了解一下。

伯努利试验是指在同样的条件下重复地、相互独立地进行的一种随机试验,其特点是该随机试验只有两种可能结果:发生或者不发生。概念看起来比较复杂,其实很简单,就是抛硬币。我们假设抛硬币只会出现正面和反面两种情况,并且正反面的概率相等,都为 p=1/2,然后我们一直抛硬币,直到第一次出现正面为止,我们称为一次完整的伯努利试验。可能第一次抛就出现了正面,也可能连续抛了4次才出现正面,这都算作完整的伯努利试验。我们把一次完整的伯努利试验经历的抛硬币次数记为 k 。

我们用0表示抛到反面,用1表示抛到正面,假设我们做了 N 次伯努利试验,对于第≌次伯努利试验,所经历的抛硬币次数为 kiki  。在这 次伯努利试验中,最大的次数为 kmax。

kmax如下图所示:

那么如果我不告诉你 N  是多少,只告诉你kmax,你能否根据 N  去推算出呢?也就是估算我们做了多少次伯努利试验呢?要想解决这个问题,我们可以先考虑以下两个问题:

  1. 假设进行N 次伯努利试验,所有投掷次数都不大于kmax 的概率是多少?

  2. 假设进行N 次伯努利试验,至少有一次投掷次数大于或等于kmax 的概率是多少?

对于第一个问题,一次完整的伯努利试验投掷次数大于kmax 的概率等于 

即连续kmax 次反面的概率,因此一次投掷不大于 kmax  的概率为  ,

所以 次伯努利试验都不大于 kmax  的概率是

第二个问题,答案显然是

当  时,

而当时,

什么意思呢?就是说如果N 远大于,那么所有投掷次数都不大于kmax  的概率是0,这和kmax 是所有 k  的最大值矛盾;同样如果N 远小于,至少有一次投掷次数大于或等于kmax 的概率是0,也是矛盾的。因此可以考虑 为 N 的一个估计值。

我们举个例子:

(1)第一次伯努利试验,抛了5次才出现正面,

(2)第二次伯努利试验,抛了2次才出现正面, 

(3)第三次伯努利试验,抛了8次才出现正面, 

(4)第四次伯努利试验,抛了4次才出现正面,

kmax =8 ,因此估算  次,也就是说,按照概率来讲, 我们做256次试验,才会出现一次“抛了8次才出现正面”的情况。然而实际 N =4 。可以看出,只是一个估算,当试验次数N 很小的时候,误差会很大。

2.2 用伯努利试验类比

前面用了很大的篇幅去介绍伯努利试验,它和我们的基数统计到底有什么关系呢?其实我们可以通过把伯努利试验类比到基数统计上。

我们一次抛硬币的伯努利试验,可以看作是一串二进制串000···1。假设有一个哈希函数能够将集合的元素哈希成固定长度的二进制串,并且该哈希函数分布均匀,冲突较小,那么理论上来说,二进制串的每个比特是0还是1的概率应该是相同的,我们从该二进制串的低位向高位去寻找第一个1出现的位置  k  ,那么这个过程其实就是一个伯努利试验。既然这样,我们同样能够通过所有  k  的最大值 kmax  去估算集合的元素个数。如下图所示:

在这个例子中我们集合中的每个元素经过一个哈希函数计算,得到32位的二进制串,然后每个二进制串从低位向高位去寻找第一个1出现的位置 k  ,最大的为 kmax =3 ,则估算集合元素的个数是 。也就是说,理论上来说,有8个元素,才会出现一次二进制串末尾是"100"的情况。同样可以看出,这种估算的结果误差会很大。

2.3 分桶思想减小误差

我们做过物理化学实验都知道,减小误差的方法之一是多次实验取平均。假设我们做多轮伯努利试验,每轮都是 N 次,然后对每轮试验的 kmax  求平均进行估算,是不是就能够有效的减小误差呢?答案是肯定的。但是和抛硬币不同,抛硬币我们每一轮的结果都是随机的,所以可以取平均减小误差,但是集合中的数据通过哈希计算得到的值是固定的,无论你计算多少轮,结果也都不会改变。那么对于集合基数的估计,如何去减小误差呢?

我们只能够从数据本身入手。一种思路就是我们把数据平均分成多份,对每一份数据进行估算,然后多份数据来进行求平均减小误差。这其实就是分桶的思想,假设数据经过哈希函数计算,得到了一个32bit的二进制串,我们用低10位来表示分桶的位置(10位最多有1024个桶),然后高22位用于模拟伯努利过程,每个桶只需要保存当前出现过的最大的 kmax  。整个过程如下图所示:

最后我们得到1024个桶的 kmax 值,我们可以进行取平均并估算集合的基数值。

2.4 LogLog Counting 算法

LogLog Counting算法在分桶后,对这 mm 个桶的  kmax 值做一个算数平均数:

然后可以估算每个桶的基数值为 ,最后乘以桶数 m 和偏差修正因子 cc ,就得到了整个集合的基数值:

可以看出算法的计算并不复杂。那么LLC算法占用多少空间呢?回到上面分桶的例子,我们有1024个桶,高22位去模拟伯努利试验, kmax  的最大值也只能是22,因此每个桶只需要5bit就能保存,总共占用空间只需要 1024 ∗ 5bit= 640B1024∗5bit=640B 。不难理解,这个空间复杂度是的,所以这也是该算法叫做LogLog Counting的原因。LLC算法的标准误差约为:

2.5 HyperLogLog Counting 算法

前面说HLLC算法是对LLC算法的一个改进和优化,具体是在哪里呢?主要是在平均数的计算上。

LLC用的是算数平均数,求所有桶 kmax  的平均值。算数平均数有个缺点,它容易受到极值的影响。举个例子,我们可能都经历过“平均工资”,假设公司里小王的月薪是10000,老板的月薪是100000,那么采用算数平均值计算平均工资为 1/2(10000+100000)=55000  ,这个结果在小王看来,可能会很诧异,自己的工资什么时候这么高了?公司里大部分的人的薪资可能都和小王差不多,只有少数的人工资非常高,那么通过算数平均数得到的平均工资可能会因为这几个极大值而变得没有意义。同样,我们知道伯努利试验中 kk  越大的概率越低,也就是说可能大部分桶的 kmax  值都比较低,如果有几个桶的 kmax  值非常大,那么算数平均数就会被拉高很多,起不到减小误差的效果了。

更好的方法是采用调和平均数(倒数的平均数的倒数)。还是上面平均工资的例子,如果采用调和平均值计算,则平均工资为2/(1/10000+1/10000)≈181812110000+1100000≈18181,这个结果相比于算数平均数会好很多。调和平均数的好处是,它比较偏袒较小值,受极大值的影响较小。所以调和平均数应该更适应于这个场景。这就是HLLC算法的主要改进点。

首先它对每个桶的基数估计值来计算一个调和平均数,然后同样乘以桶数 mm 和修正因子 cc ,最后得到整个集合的基数估计值:

该算法的空间复杂度同样也是,误差为  。可以看出HLLC算法的误差比LLC要小一些。

三、Redis 中的 HyperLogLog

Redis在2.8.9版本中添加了HyperLogLog数据结构,常用的命令包括:

PFADD命令将元素添加到HLL结构中,PFCOUNT命令会返回指定HLL的基数估算值,PFMERGE命令可以将多个HLL结构进行合并。我们会发现,HLL结构的命令都是以“PF”为前缀,这是因为该算法的发明者叫做 Philippe Flajolet。

在Redis中,添加到HLL中的value值会被Hash函数计算得到一个64bit的值,低14位用于分桶,所以桶数为   个,高50位用于伯努利试验,需要6bit来存储。所以在Redis中,HLL仅用空间 16384∗6bit=12KB ,就可以统计多达个数,标准误差约为 。

算法的实现本身并不复杂,但是Redis还是对其内存占用作了一些优化。我们可以看出,无论基数多大,桶数都是16384个,所占用的内存都是12KB,当基数比较小时,还是会造成一些空间浪费。所以Redis采用稀疏存储结构和密集存储结构两种方式。

3.1 密集存储结构

密集存储结构类似于位图,是一个大小固定为12KB的数组,每6bit表示一个桶,存储该桶的 kmax值,共计16384个桶。如下图所示:

对每个桶的读写操作时,都需要一定的位运算,定位到桶的那6个比特并进行读写。

3.2 稀疏存储结构

稀疏存储结构并不真的使用12KB的数组来表示16384个桶,而是使用特殊的字节结构来表达,如下图所示:

  • ZERO:占用1个字节,表示连续多少个桶的计数都是0。前2位固定是00,后6位表示有多少个桶,最大为64。

  • XZERO:占用2个字节,表示连续多少个桶的计数都是0。前2位固定是01,后14位表示有多少个桶,最大为16384。

  • VAL:占用1个字节,表示连续多少个桶的计数为多少。前1位固定是1,接下来5位表示计数的值是多少,所以最大是32。最后2位表示连续多少个桶。

根据上面的定义,我们可以知道,一个初始状态的HLL结构,只需要2个字节,也就是一个XZERO来存储,而不需要12KB的空间;而在HLL插入了少量数据之后,可以用很少个ZERO,XZERO和VAL来进行表示,如下图所示:

Redis稀疏存储结构切换到密集存储结构的条件是:

  1. 当某个桶的计数值使用VAL无法表示时,也就是出现某个桶的计数值大于32,则会切换到密集存储结构。

  2. 当稀疏存储结构占用的空间大于3000字节时(可配置)。

参考资料

https://www.modb.pro/db/43389

HyperLogLog 算法的原理讲解以及 Redis 是如何应用它的

《Redis深度历险》

HyperLogLog 算法原理及其在 Redis 中的实现相关推荐

  1. HyperLogLog原理与在Redis中的使用

    Redis-HyperLogLog 基于HyperLogLog算法,使用极小的空间完成巨量运算 Redis 中HyperLogLog 基本使用 常用命令 PFADD key element [elem ...

  2. java token redis生成算法_如何访问 Redis 中的海量数据,服务才不会挂掉?

    来源:www.toutiao.com/i6697540366528152077 一.前言 有时候我们需要知道线上的Redis的使用情况,尤其需要知道一些前缀的key值,让我们怎么去查看呢?并且通常情况 ...

  3. watershed 算法原理以及skimage/opencv中的使用

    watershed 参考 分水岭算法 代码 opencv scikit-image 参考 scikit_image中的watershed说明 OpenCV-图像分割中的分水岭算法原理与应用 图像分割的 ...

  4. 【承】Redis 原理篇——关于 Redis 中的事务

    前言 关于 Redis 的"起承转合",我前面已经用五个篇章的长度作了一个 Redis 基础篇--"起"篇的详细阐述,相信大家无论之前有没有接触过 Redis, ...

  5. LRU 算法在 MySQL 和 Redis 中的实现与优化

    本文转载于我的个人公众号"阿东编程之路" 一. 什么是LRU算法 LRU 算法全称:Least Recently Used,故名思义就是最近最少被使用的.一般会用 LRU 算法来实 ...

  6. EM算法原理及其在NLP中的应用

    EM算法是一种迭代算法,全称为期望极大算法(expectation maximization algorithm),用于含有隐变量(hidden variable)的概率模型参数的极大似然估计,或极大 ...

  7. 一致性哈希算法原理及其在分布式系统中的应用

    本文将会从实际应用场景出发,介绍一致性哈希算法(Consistent Hashing)及其在分布式系统中的应用.首先本文会描述一个在日常开发中经常会遇到的问题场景,借此介绍一致性哈希算法以及这个算法如 ...

  8. redis中的crc16算法

    使用的是循环冗余检验算法. 1.算法原理 假设数据传输过程中需要发送15位的二进制信息g=101001110100001,这串二进制码可表示为代数多项式g(x) = x^14 + x^12 + x^9 ...

  9. 论文|SDNE的算法原理、代码实现和在阿里凑单场景中的应用说明(附代码)

    1.概述 SDNE(Structural Deep Network Embedding)算法是发表在KDD-2016上的一篇文章,论文的下载地址为: https://www.kdd.org/kdd20 ...

最新文章

  1. Windows系统安装改磁盘格式
  2. Matplotlib 2016-04-15
  3. learning python学习小记(一)
  4. Windows环境下使用 Caffe在ImageNet上训练网络
  5. Python 连接redis密码中特殊字符问题
  6. 苹果双系统运行oracle失败,oracle 11gR2 RAC for linux x86_64 grid运行root.sh 失败问题处理...
  7. 重磅直播|结构光3D相机光机核心技术及3D成像性能分析
  8. (转自珊珊博客)甜甜蜜蜜走台湾 Day3
  9. Oracle学习资料分享(含教程、笔记、题库下载与学习方法)
  10. android模拟器对比,哪个安卓模拟器好用 电脑玩手游助手模拟器权威对比
  11. linux vnc共享时权限设置,用VNC实现远程桌面共享(支持Windows, Linux, ...) - 易水博客...
  12. 没看到能打的,遍历目录并读取目录下的文件列表。(C语言,SDK)
  13. 自己做量化交易软件(9通通量化框架的雏形建立
  14. skinsdog 狗网官网 可直接取回CSGO皮肤开箱网站
  15. 建立自己的图像数据集
  16. Android制作.9图
  17. .nc地形数据的python转换实现tiff
  18. C语言计算两纬线间子午线弧长1/2
  19. Day9、ListView常用事件、点击按钮和点击每行的背景转换器Selector
  20. Vuforia核心特征视频教程

热门文章

  1. 转 :原码,反码,补码
  2. UVa - 1617 - Laptop
  3. “Word自动更改后的内容保存到通用文档模板上。是否加载该模板?“的解决办法...
  4. iOS vuforia 学习钻研(一)
  5. Spring AOP源码分析(二)JDK动态代理和CGLIB介绍
  6. how to add user into sudoers file?
  7. Bing Maps进阶系列八:在Bing Maps中集成OpenStreetMap地图
  8. 【请教】服务器上出现的两个问题!
  9. 雷林鹏分享:YII 模型-视图-控制器(MVC)设计模式
  10. UITableview高度计算