基数统计算法--HyperLogLog

楔子

在我们实际开发的过程中,可能会遇到这样一个问题,当我们需要统计一个大型网站的独立访问次数时,该用什么的类型来统计?

如果我们使用 Redis 中的集合来统计,当它每天有数千万级别的访问时,将会是一个巨大的问题。因为这些访问量不能被清空,我们运营人员可能会随时查看这些信息,那么随着时间的推移,这些统计数据所占用的空间会越来越大,逐渐超出我们能承载最大空间。

例如,我们用 IP 来作为独立访问的判断依据,那么我们就要把每个独立 IP 进行存储,以 IP4 来计算,IP4 最多需要 15 个字节来存储信息,例如:110.110.110.110。当有一千万个独立 IP 时,所占用的空间就是 15 bit * 10000000 约定于143MB,但这只是一个页面的统计信息,假如我们有 1 万个这样的页面,那我们就需要 1T 以上的空间来存储这些数据。而且随着 IP6 的普及,这个存储数字会越来越大,那我们就不能用集合的方式来存储了,这个时候我们需要开发新的数据类型来做这件事了,而这个新的数据类型就是我们今天要介绍的HyperLogLog。在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。

方案 1 :使用有序集合

每当一个用户上线时, 我们就执行 ZADD 命令, 将这个用户以及它的在线时间添加到指定的有序集合中:

ZADD "online_users" <user_id> <current_timestamp>

通过使用 ZSCORE 命令检查指定的用户 ID 在有序集合中是否有相关联的分值, 我们可以知道该用户是否在线:

ZSCORE "online_users" <user_id>

而通过执行 ZCARD 命令, 我们可以知道总共有多用户在线:

ZCARD "online_users"

使用有序集合储存在线用户的强大之处在于, 它是本文介绍的所有方案当中, 能够执行最多聚合操作的一个方案, 原因在于, 这一方案既可以通过有序集合的成员(也即是用户的 ID)进行聚合操作, 也可以根据有序集合的分值(也即是用户的登录时间)进行聚合操作。

首先, 通过 ZINTERSTORE 和 ZUNIONSTORE 命令, 我们可以对多个记录了在线用户的有序集合进行聚合计算:

# 计算出 7 天之内都有上线的用户,并将它储存到 7_days_both_online_users 有序集合当中
ZINTERSTORE 7_days_both_online_users 7 "day_1_online_users" "day_2_online_users" ... "day_7_online_users"

# 计算出 7 天之内总共有多少人上线了
ZUNIONSTORE 7_days_total_online_users 7 "day_1_online_users" ... "day_7_online_users"

此外, 通过 ZCOUNT 命令, 我们可以统计出在指定的时间段之内有多少用户在线, 而 ZRANGEBYSCORE 命令则可以让我们获取到这些用户的名单:

# 统计指定时间段内上线的用户数量
ZCOUNT "online_users" <start_timestamp> <end_timestamp>

# 获取指定时间段内上线的用户名单
ZRANGEBYSCORE "online_users" <start_timestamp> <end_timestamp> WITHSCORES

通过这一方法, 我们可以知道网站在不同时间段的上线人数以及上线用户名单, 比如说, 我们可以用这个方法来分别获知网站在早晨、上午、中午、下午和夜晚的上线人数。

方案 2 :使用集合

正如上一节所说, 使用有序集合能够同时储存在线用户的名单以及各个用户的上线时间, 但如果我们只想要记录在线用户的名单, 而不想要储存用户的上线时间, 那么也可以使用集合来代替有序集合, 对在线的用户进行记录。

在这种情况下, 每当一个用户上线时, 我们就执行以下 SADD 命令, 将它添加到在线用户名单当中:

SADD "online_users" <user_id>

通过使用 SISMEMBER 命令, 我们可以检查一个指定的用户当前是否在线:

SISMEMBER "online_users" <user_id>

而统计在线人数的工作则可以通过执行 SCARD 命令来完成:

SCARD "online_users"

通过集合运算操作, 我们可以像有序集合方案一样, 对不同时间段或者日期的在线用户名单进行聚合计算。 比如说, 通过 SINTER 或者 SINTERSTORE 命令, 我们可以计算出一周都有在线的用户:

SINTER "day_1_online_users" "day_2_online_users" ... "day_7_online_users"

此外, 通过 SUNION 命令或者 SUNIONSTORE 命令, 我们可以计算出一周内在线用户的总数量:

SUNION "day_1_online_users" "day_2_online_users" ... "day_7_online_users"

而通过执行 SDIFF 命令或者 SDIFFSTORE 命令, 我们可以知道哪些用户今天上线了, 但是昨天没有上线:

SDIFF "today_online_users" "yesterday_online_users"

又或者工作日上线了, 但是假日没有上线:

# 计算工作日上线名单
SINTERSTORE "weekday_online_users" "monday_online_users" "tuesday_online_users" ... "friday_online_users"
# 计算假日上线名单
SINTERSTORE "holiday_online_users" "saturday_online_users" "sunday_online_users"
# 计算工作日上线但是假日未上线的名单
SDIFF "weekday_online_users" "holiday_online_users"

诸如此类。

方案 3 :使用 HyperLogLog

虽然使用有序集合和集合能够很好地完成记录在线人数的工作, 但以上这两个方案都有一个明显的缺点, 那就是, 这两个方案耗费的内存会随着被统计用户数量的增多而增多: 如果你的网站用户数量比较多, 又或者你需要记录多天/多个时段的在线用户名单并进行聚合计算, 那么这两个方案可能会消耗你大量内存。

另一方面, 在有些情况下, 我们只想要知道在线用户的人数, 而不需要知道具体的在线用户名单, 这时有序集合和集合储存的信息就会显得多余了。

在需要尽可能地节约内存并且只需要知道在线用户数量的情况下, 我们可以使用 HyperLogLog 来对在线用户进行统计: HyperLogLog 是一个概率算法, 它可以对元素的基数进行估算, 并且每个 HyperLogLog 只需要耗费 12 KB 内存, 对于用户数量非常多但是内存却非常紧张的系统, 这一方案无疑是最佳之选。

在这一方案下, 我们使用 PFADD 命令去记录在线的用户:

PFADD "online_users" <user_id>

使用 PFCOUNT 命令获取在线人数:

PFCOUNT "online_users"

因为 HyperLogLog 也提供了计算交集的 PFMERGE 命令, 所以我们也可以用这个命令计算出多个给定时间段或日期之内, 上线的总人数:

# 统计 7 天之内总共有多少人上线了
PFMERGE "7_days_both_online_users" "day_1_online_users" "day_2_online_users" ... "day_7_online_users"
PFCOUNT "7_days_both_online_users"

方案 4 :使用位图(bitmap)

回顾上面介绍的三个方案, 我们可以得出以上结论:

  • 使用有序集合或者集合能够储存具体的在线用户名单, 但是却需要消耗大量的内存;

  • 而使用 HyperLogLog 虽然能够有效地减少统计在线用户所需的内存, 但是它却没办法准确地记录具体的在线用户名单。

那么是否存在一种既能够获得在线用户名单, 又可以尽量减少内存消耗的方法存在呢? 这种方法的确存在 —— 使用 Redis 的位图就可以办到。

Redis 的位图就是一个由二进制位组成的数组, 通过将数组中的每个二进制位与用户 ID 进行一一对应, 我们可以使用位图去记录每个用户是否在线。

当一个用户上线时, 我们就使用 SETBIT 命令, 将这个用户对应的二进制位设置为 1 :

# 此处的 user_id 必须为数字,因为它会被用作索引
SETBIT "online_users" <user_id> 1

通过使用 GETBIT 命令去检查一个二进制位的值是否为 1 , 我们可以知道指定的用户是否在线:

GETBIT "online_users" <user_id>

而通过 BITCOUNT 命令, 我们可以统计出位图中有多少个二进制位被设置成了 1 , 也即是有多少个用户在线:

BITCOUNT "online_users"

跟集合一样, 用户也能够对多个位图进行聚合计算 —— 通过 BITOP 命令, 用户可以对一个或多个位图执行逻辑并、逻辑或、逻辑异或或者逻辑非操作:

# 计算出 7 天都在线的用户
BITOP "AND" "7_days_both_online_users" "day_1_online_users" "day_2_online_users" ... "day_7_online_users"

# 计算出 7 在的在线用户总人数
BITOP "OR" "7_days_total_online_users" "day_1_online_users" "day_2_online_users" ... "day_7_online_users"

# 计算出两天当中只有其中一天在线的用户
BITOP "XOR" "only_one_day_online" "day_1_online_users" "day_2_online_users"

HyperLogLog 方案记录一个用户是否在线需要花费 1 个二进制位, 对于用户数为 100 万的网站来说, 使用这一方案只需要耗费 125 KB 内存, 而对于用户数为 1000 万的网站来说, 使用这一方案也只需要花费 1.25 MB 内存。

虽然位图节约内存的效果不及 HyperLogLog 那么显著, 但是使用位图可以准确地判断一个用户是否上线, 并且能够像集合和有序集合一样, 对在线用户名单进行聚合计算。 因此对于想要尽量节约内存, 但又需要准确地知道用户是否在线, 又或者需要对用户的在线名单进行聚合计算的应用来说, 使用位图可以说是最佳之选。

总结

以下表格总结了以上四个方案的特点:

方案

特点

有序集合

能够同时储存在线用户的名单以及用户的上线时间,能够执行非常多的聚合计算操作,但是耗费的内存也非常多。

集合

能够储存在线用户的名单,也能够执行聚合计算,消耗的内存比有序集合少,但是跟有序集合一样,这个方案消耗的内存也会随着用户数量的增多而增多。

HyperLogLog

无论需要统计的用户有多少,只需要耗费 12 KB 内存,但由于概率算法的特性,只能给出在线人数的估算值,并且也无法获取准确的在线用户名单。

位图

在尽可能节约内存的情况下,记录在线用户的名单,并且能够对这些名单执行聚合操作。

因为 Redis 同时支持多种数据结构, 所以一个问题常常可以在 Redis 里面找多种不同的解法, 并且每种解法都有各自的优点和缺点, 本文介绍的问题就是一个很好的例子。

关于统计在线用户的方法就介绍到这里, 希望这些方案会给大家带来帮助和启发。

HyperLogLog介绍与使用

HyperLogLog(下文简称为 HLL)是 Redis 2.8.9 版本添加的数据结构,它用于高性能的基数(去重)统计功能,它的缺点就是存在极低的误差率

HLL 具有以下几个特点:

  • 能够使用极少的内存来统计巨量的数据,它只需要 12K 空间就能统计 2^64 的数据;
  • 统计存在一定的误差,误差率整体较低,标准误差为 0.81%;
  • 误差可以被设置辅助计算因子进行降低。

HLL 的命令只有 3 个,但都非常的实用,下面分别来看。

添加元素

pfadd key element1 element2······,可以同时添加多个。

127.0.0.1:6379> pfadd hll1 mea
(integer) 1
127.0.0.1:6379> pfadd hll1 kano nana
(integer) 1
127.0.0.1:6379> pfadd hll1 mea
(integer) 0
127.0.0.1:6379> 

统计不重复的元素个数

pfcount key1 key2····,可以同时统计多个HHL结构。

127.0.0.1:6379> pfcount hll1
(integer) 3  # 不重复元素个数有3个
127.0.0.1:6379> 
 

将多个HLL结构中元素移动到新的HLL结构中

pfmerge key key1 key2····,将key1、key2····移动到key中

127.0.0.1:6379> pfadd hll1 mea kano nana
(integer) 1
127.0.0.1:6379> pfadd hll2 mea kano yume
(integer) 1
127.0.0.1:6379> pfmerge hll hll1 hll2
OK
127.0.0.1:6379> pfcount hll
(integer) 4
127.0.0.1:6379> 

当我们需要合并两个或多个同类页面的访问数据时,我们可以使用 pfmerge 来操作。

Python实现HLL相关操Python实现HLL相关操作

import redis

client = redis.Redis(host="47.94.174.89", decode_responses="utf-8")

# 1. pfadd key1 key2···
client.pfadd("HLL1", "a", "b", "c")
client.pfadd("HLL2", "b", "c", "d")

# 2. pfcount key1 key2···
print(client.pfcount("HLL1", "HLL2"))  # 4

# 3. pfmerge key key1 key2···
client.pfmerge("HLL", "HLL1", "HLL2")
print(client.pfcount("HLL"))  # 4

HyperLogLog算法原理

HyperLogLog 算法来源于论文HyperLogLog the analysis of a near-optimal cardinality estimation algorithm,想要了解 HLL 的原理,先要从伯努利试验说起,伯努利实验指的是在同样的条件下重复地、相互独立地进行的一种随机试验,其特点是该随机试验只有两种可能结果:发生或者不发生。我们假设该项试验独立重复地进行了n次,那么就称这一系列重复独立的随机试验为n重伯努利试验,或称为伯努利概型。比如最经典、也是最好理解的抛硬币,每一次抛出的硬币都是各自独立的,当前抛出的硬币不受上一次的影响。

注意:单个伯努利试验是没有多大意义的,然而,当我们反复进行伯努利试验,去观察这些试验有多少是成功的,多少是失败的,事情就变得有意义了,这些累计记录包含了很多潜在的非常有用的信息。

并且根据大数定理我们知道,如果一个事件发生的概率是恒定的,那么随着试验次数的增加,那么该事件的频率越接近概率。还拿抛硬币举例,假设你抛硬币抛了四次,全是正面(这种情况是可能出现的),难道我们就说抛出一枚硬币,正面朝上的概率是百分之百吗?显然不能,而大数定理会告诉我们,只要你抛出硬币的次数足够多,你会发现正面出现的次数除以抛出的总次数会无限接近二分之一。

之所以说这些,是因为Redis采用的算法不是按照类似我们上面说的方式,因为大数定理对于数据量小的时候,会有很大的误差。而为了解决这个问题,HLL 引入了分桶算法调和平均数来使这个算法更接近真实情况。

分桶算法是指把原来的数据平均分为 m 份,在每段中求平均数在乘以 m,以此来消减因偶然性带来的误差,提高预估的准确性,简单来说就是把一份数据分为多份,把一轮计算,分为多轮计算。

调和平均数指的是使用平均数的优化算法,而非直接使用平均数。

例如小明的月工资是 1000 元,而小王的月工资是 100000 元,如果直接取平均数,那小明的平均工资就变成了 (1000+100000)/2=50500‬ 元,这显然是不准确的,而使用调和平均数算法计算的结果是 2/(1/1000+1/100000)≈1998 元,显然此算法更符合实际平均数。

所以综合以上情况,在 Redis 中使用 HLL 插入数据,相当于把存储的值经过 hash 之后,再将 hash 值转换为二进制,存入到不同的桶中,这样就可以用很小的空间存储很多的数据,统计时再去相应的位置进行对比很快就能得出结论,这就是 HLL 算法的基本原理,想要更深入的了解算法及其推理过程,可以看去原版的论文,链接地址:http://algo.inria.fr/flajolet/Publications/FlFuGaMe07.pdf

小结

当需要做大量数据统计时,普通的集合类型已经不能满足我们的需求了,这个时候我们可以借助 Redis 2.8.9 中提供的 HyperLogLog 来统计,它的优点是只需要使用 12k 的空间就能统计 2^64 的数据,但它的缺点是存在 0.81% 的误差,HyperLogLog 提供了三个操作方法:pfadd 添加元素、pfcount 统计元素和 pfmerge 合并元素。

https://www.cnblogs.com/traditional/p/13326391.html

猜你喜欢

转载自blog.csdn.net/qq_22473611/article/details/108216910
今日推荐