用最小的空间统计存储一亿用户的活跃度

1

前段时间,在网上看到一道面试题:git

如何用redis存储统计1亿用户一年的登录状况,并快速检索任意时间窗口内的活跃用户数量。面试

以为颇有意思,就仔细想了下 。并作了一系列实验,本身模拟了下 。仍是有点收获的,现整理下来。和你们一块儿分享。redis

Redis是一个内存数据库,采用单线程和事件驱动的机制来处理网络请求。实际生产的QPS和TPS单台都能达到3,4W,读写性能很是棒。用来存储一些对核心业务弱影响的用户状态信息仍是很是不错的。算法

对于这题,有2个重要的点须要考虑:spring

1.如何用合适的数据类型来存储1亿用户的数据,用普通的字符串来存储确定不行。通过查看一个最简单的kv(key为aaa,value为1)的内存占用,发现为48byte。数据库

1.png

假设每一个用户天天登录须要占据1对KV的话,那一亿就是(48*100000000)/1024/1024/1024=4.47G。这仍是一天的量。springboot

2.如何知足搜索,redis是一个键值对的内存结构,只能根据key来进行定位value值,没法作到像elastic search那样对文档进行倒排索引快速全文检索。网络

redis其实有这种数据结构的,能够以不多的空间来存储大量的信息。数据结构

2

在redis 2.2.0版本以后,新增了一个位图数据,其实它不是一种数据结构。实际上它就是一个一个字符串结构,只不过value是一个二进制数据,每一位只能是0或者1。redis单独对bitmap提供了一套命令。能够对任意一位进行设置和读取。工具

bitmap的核心命令:

SETBIT

语法:SETBIT key offset value

例如:

setbit abc 5 1 ----> 00001

setbit abc 2 1 ----> 00101

GETBIT

语法:GETBIT key offset

例如:

getbit abc 5 ----> 1

getbit abc 1 ----> 0

bitmap的其余命令还有bitcount,bitcount,bitpos,bitop等命令。都是对位的操做。

由于bitmap的每一位只占据1bit的空间 ,因此利用这个特性咱们能够把每一天做为key,value为1亿用户的活跃度状态。假设一个用户一天内只要登陆了一次就算活跃。活跃咱们就记为1,不活跃咱们就记为0。把用户Id做为偏移量(offset)。这样咱们一个key就能够存储1亿用户的活跃状态。

2.jpg

咱们再来算下,这样一个位图结构的值对象占据多少空间。每个位是1bit,一亿用户就是一亿bit。8bit=1Byte

100000000/8/1024/1024=11.92M

我用测试工程往一个key里经过lua塞进了1亿个bit,而后用rdb tools对内存进行统计,实测以下:

3.png

一天1亿用户也就消耗12M的内存空间。这彻底符合要求。1年的话也就4个G。几年下来的话,redis能够集群部署来进行扩容存储。咱们也能够用位图压缩算法对bitmap进行压缩存储。例如WAH,EWAH,Roaring Bitmaps。这个之后能够单独拉出来聊聊。

咱们把每一天1亿用户的登录状态都用bitmap的形式存进了redis,那要获取某一天id为88000的用户是否活跃,直接使用getbit命令:

getbit 2020-01-01 88000 [时间复杂度为O(1)]

若是要统计某一天的全部的活跃用户数,使用bitcount命令,bitcount能够统计1的个数,也就是活跃用户数:

bitcount 2019-01-01 [时间复杂度为O(N)]

若是要统计某一段时间内的活跃用户数,须要用到bitop命令。这个命令提供四种位运算,AND(与)(OR)或XOR(亦或)NOT(非)。咱们能够对某一段时间内的全部key进行OR(或)操做,或操做出来的位图是0的就表明这段时间内一次都没有登录的用户。那只要咱们求出1的个数就能够了。如下例子求出了2019-01-01到2019-01-05这段时间内的活跃用户数。

bitop or result 2019-01-01 2019-01-02 2019-01-03 2019-01-04 2019-01-05 [时间复杂度为O(N)]

bitcount result

从时间复杂度上说,不管是统计某一天,仍是统计一段时间。在实际测试时,基本上都是秒出的。符合咱们的预期。

3

bitmap能够很好的知足一些须要记录大量而简单信息的场景。所占空间十分小。一般来讲使用场景分2类:

1.某一业务对象的横向扩展,key为某一个业务对象的id,好比记录某一个终端的功能开关,1表明开,0表明关。基本能够无限扩展,能够记录2^32个位信息。不过这种用法因为key上带有了业务对象的id,致使了key的存储空间大于了value的存储空间,从空间使用角度上来看有必定的优化空间。

2.某一业务的纵向扩展,key为某一个业务,把每个业务对象的id做为偏移量记录到位上。这道面试题的例子就是用此法来进行解决。十分巧妙的利用了用户的id做为偏移量来找到相对应的值。当业务对象数量超过2^32时(约等于42亿),还能够分片存储。

看起来bitmap完美的解决了存储和统计的问题。那有没有比这个更加省空间的存储吗?

答案是有的。

4

redis从2.8.9以后增长了HyperLogLog数据结构。这个数据结构,根据redis的官网介绍,这是一个几率数据结构,用来估算数据的基数。能经过牺牲准确率来减小内存空间的消耗。

咱们先来看看HyperLogLog的方法

PFADD 添加一个元素,若是重复,只算做一个

PFCOUNT 返回元素数量的近似值

PFMERGE 将多个 HyperLogLog 合并为一个 HyperLogLog

这很好理解,是否是。那咱们就来看看一样是存储一亿用户的活跃度,HyperLogLog数据结构须要多少空间。是否是比bitmap更加省空间呢。

我经过测试工程往HyperLogLog里PFADD了一亿个元素。经过rdb tools工具统计了这个key的信息:

4.png

只须要14392 Bytes!也就是14KB的空间。对,你没看错。就是14K。bitmap存储一亿须要12M,而HyperLogLog只须要14K的空间。

这是一个很惊人的结果。我彷佛有点不敢相信使用如此小的空间竟能存储如此大的数据量。

接下来我又放了1000w数据,统计出来仍是14k。也就是说,不管你放多少数据进去,都是14K。

查了文档,发现HyperLogLog是一种几率性数据结构,在标准偏差0.81%的前提下,可以统计2^64个数据。因此 HyperLogLog 适合在好比统计日活月活此类的对精度要不不高的场景。

HyperLogLog使用几率算法来统计集合的近似基数。而它算法的最本源则是伯努利过程。

伯努利过程就是一个抛硬币实验的过程。抛一枚正常硬币,落地多是正面,也多是反面,两者的几率都是 1/2 。伯努利过程就是一直抛硬币,直到落地时出现正面位置,并记录下抛掷次数k。好比说,抛一次硬币就出现正面了,此时 k 为 1; 第一次抛硬币是反面,则继续抛,直到第三次才出现正面,此时 k 为 3。

对于 n 次伯努利过程,咱们会获得 n 个出现正面的投掷次数值 k1, k2 ... kn , 其中这里的最大值是k_max。

根据一顿数学推导,咱们能够得出一个结论: 2^{k_ max} 来做为n的估计值。也就是说你能够根据最大投掷次数近似的推算出进行了几回伯努利过程。

5

虽然HyperLogLog数据类型这么牛逼,但终究不是精确统计。只适用于对精度要求不高的场景。并且这种类型没法得出每一个用户的活跃度信息。毕竟只有14K嘛。也不可能存储下那么多数量的信息。

总结一下:对于文章开头所提到的面试题来讲,用bitmap和HyperLogLog均可以解决。

bitmap的优点是:很是均衡的特性,精准统计,能够获得每一个统计对象的状态,秒出。缺点是:当你的统计对象数量十分十分巨大时,可能会占用到一点存储空间,但也可在接受范围内。也能够经过分片,或者压缩的额外手段去解决。

HyperLogLog的优点是:能够统计夸张到没法想象的数量,而且占用小的夸张的内存。 缺点是:创建在牺牲准确率的基础上,并且没法获得每一个统计对象的状态。

我作了一个演示工程redis-bit,放在Gitee上,工程包括了初始化大容量的数据。和分别使用bitmap和HyperLogLog进行用户活跃度的统计。最后经过http的方式进行输出。

工程采用springboot+redisson客户端。全部的参数支持配置

5.png

联系做者

offIical-wx.jpg

相关文章
相关标签/搜索