使用Redis的bitmaps统计用户留存率、活跃用户

1,504 阅读8分钟

本文转载自 itopic.org/liucunlv-sh…

统计留存率之前先弄清一下留存率的概念,百度百科中是这么说的:

用户在某段时间内开始使用应用,经过一段时间后,仍然继续使用应用的被认作是留存;这部分用户占当时新增用户的比例即是留存率,会按照每隔1单位时间(例日、周、月)来进行统计。顾名思义,留存指的就是“有多少用户留下来了”。留存用户和留存率体现了应用的质量和保留用户的能力。

简单点说,第一天新增加了100个用户,第二天这100个人有50个还有登录,第三天这100个人还有30个有登录。。。依次类推 那次日留存率为50%,三日留存为30% 。

在统计系统中经常需要统计用户留存率,这里整理下用户留存率统计的几种实现方式。

1、通过最后登录时间实现

有一张唯一表来记录新增用户,这张表至少包含这三个字段: uid, reg_time, last_visited_time。用户每次访问后更新最后访问时间(last_visited_time),假设3.6号新注册100个用户,需要统计次日留存,则在3.8号凌晨统计reg_time为3.6并且last_visited_time为3.7号即可,参考SQL:

SELECT COUNT(*) FROM TBL_NAME WHERE DATE(reg_time) = '2014-03-06' AND DATE(last_visited_time) = '2014-03-07'

实现起来很简单,但问题也很明显,如果恰好这些用户0点有访问,且先一步更新了访问时间,留存率则记录不到了,这个对整个的结果偏差不会太大,先忽略。有一个更明显的问题就是无法重复统计,如果脚本出错或者需要重新统计则无法实现。当然好处也有,就是统计方便,同时也方便新增N日留存。

2、通过建立独立的字段实现

独立的字段可以这么设计,uid,reg_time,day_2,day_3,day_4…等等,当用户第二天有访问时更新day_2的字段为1,第三日访问更新day_3为1,该系列字段默认为0。同样的统计次日留存,则SQL应该是这样子:

SELECT COUNT(*) FROM TBL_NAME WHERE DATE(reg_time) = '2014-03-06' AND day_2 = 1

该方法可以重复统计了,但又不方便扩展了,如果当前没有考虑到15天流程,则需要修改表结构,新增day_15才行。

3、通过位运算实现

上面的数据表中记录的值就是很多的0和1,可以用这些二进制的0和1来表示当天是否有访问过,1表示有访问过,0表示未访问过。设计表中有这几个字段,uid,reg_time,retension,假设留存用retention记录,则

第一天访问 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 对应十进制的1,retention记录为1

第二天访问 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 第二天有访问后retention更新为3

第四天访问 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 1 第三天没有访问,第四天访问后rentention更新为11

依次类推,接下来就是计算该天的留存,以次日留存为例。将次日的数据与第2位为1其他位为0的值做按位与操作

按位与是将都为1的设置为1,如果用整数来表示,求次日留存是 3 & 2 ,如果结果为2则表示次日有访问过,如果不为2结果为0则说明没有访问过。所以求第N天的sql应该是(N表示第N天留存,如第3天用第3位来表示就是2的2次方):

0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1
|
0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0
=
0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1

第三天没有访问,第四天访问则是:

0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1
|
0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0
=
0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 1

用SQL来表示就是(N表示第N天访问)

UPDATE TBL_NAME SET retention = retention | 2^(N-1) WHERE uid = 'XX'

而且该更新操作在当天是可以重复操作的,因为按位或只需要有一个为1即可,第2天第一次更新1 | 2 = 3,第二次更新3 | 2 = 3。可见值是相同的。 听到这种方案后也怀疑效率问题,在1000w数据中统计速度与reg_time中索引时间差不多,所以问题不大;一个整形4个字节32位,可以表示32个不同的留存,整形不够也可以用长整型8个字节的。总体看来该方法可扩展,可重新统计,所以可行。

位运算之前只在权限中见过,这里用法也是一种不错的方式,期待更多的思考,下面是位运算的基本操作:

logic-op.png

使用redis统计用户留存

转载自 blog.csdn.net/agonie20121…

首先我们看一个场景:一个网站,需要统计一周内连续登陆的用户,以及一个月内登陆过的用户或者是用户留存率。

如果用传统的数据库如Mysql来实现的话,很难做到。但如果用Redis来做的话,就很简便。Redis的集合类型和Bitmap类型都可以很容易的做到。今天,我们主要来谈谈如何用Bitmaps来实现统计活跃用户的功能。

什么是 Bitmaps

Bitmaps 并不是实际的数据类型,而是定义在String类型上的一个面向字节操作的集合。因为字符串是二进制安全的块,他们的最大长度是512M,最适合设置成2^32个不同字节。

Bitmaps 的最大优势之一在存储信息时极其节约空间。例如,在一个以增量用户ID来标识不同用户的系统中,记录用户的四十亿的一个单独bit信息(例如,要知道用户是否想要接收最新的来信)仅仅使用512M内存。

在计算机系统中,最小的信息单位是字节,1个字节等于8位,每一位都只可能是0或1(计算机只认识这两个数)。使用Bitmaps可以直接对位进行操作。

可以把bigmaps看做一个数组,数组里每一位只可能是0或者1,数组的下标在这里看做偏移量。

下面我们来介绍几个和Bitmaps相关的命令:

从以下结果可以看出 Bitmaps实际上存的就是String

127.0.0.1:6379> set hello big
OK
127.0.0.1:6379> getbit hello 0
(integer) 0
127.0.0.1:6379> getbit hello 1
(integer) 1
127.0.0.1:6379> getbit hello 2
(integer) 1

1.png

setbit

setbit key offset value:给对应的位设置值

比如今天有用户3、8、23、32访问了网站,则

setbit user:view:2020-5-17 3 1
setbit user:view:2020-5-17 8 1
setbit user:view:2020-5-17 23 1
setbit user:view:2020-5-17 32 1

开发提示:很多应用id都不是从1开始,有许多是从指定数字开始的,比如1001、10001开始。对于这些,我们在设置的时候可以先减去初始值,防止浪费空间

getbit

getbit key offset 获取指定位的值

如果我想知道今天8号用户和45号用户是否登录过,则

127.0.0.1:6379> getbit user:view:2020-5-17 8
(integer) 1
127.0.0.1:6379> getbit user:view:2020-5-17 45
(integer) 0

可以看到8号用户今天登录过,但是45号用户今天还没有登录。

bitcount

bitcount key [start] [end] 获取指定范围为1的个数

我想知道今天有多少用户登陆过了,则

127.0.0.1:6379> bitcount user:view:2020-5-17 (integer) 4

Bitmaps间的操作

bitop op destkey key [key ...]

bitop命令可以对多个bitmaps做交集(and)、并集(or)、非(not)、异或(xor),并将操作结果存放在destkey中。

如果想知道连续三天都登陆过的用户,即5月17日、18日、19日都登陆的用户数量

这三天登陆情况如下:

  • 5月17日3、8、23、32用户登陆过

  • 5月18日3、23、43、54号用户登陆过

  • 5月19日3、5、23、 32、56、78号用户登陆过

      127.0.0.1:6379> bitop and three:and user:view:2020-5-17 user:view:2020-5-18 user:view:2020-5-19
      127.0.0.1:6379> bitcount three:and
      (integer) 2
    

如果想知道,这三天有多少用户登陆过。

127.0.0.1:6379> bitop or three:or user:view:2020-5-17 user:view:2020-5-18 user:view:2020-5-19
(integer) 10
127.0.0.1:6379> bitcount three:or
(integer) 9

可以看到,这三天共有9位用户登陆过。

实战

讲完上面所讲知识后,我们就可以来完成想要的需求:需要统计一周内连续登陆的用户,以及一个月内登陆过的用户。

首先模拟用户30天内登陆情况,伪代码如下:

for ($i = 0; $i < 20000; $i++) {
    $userId = mt_rand(1, 10000);
    $date   = time() - 86400 * mt_rand(0, 30);
    $key   = 'userlogin_'.date('Ymd', $date);

    $redis->setBit($key, $userId, 1);
}

获取一周内都登陆的用户,当然我们不会一次性全部取,而是想分页那样,一次取一定数量的,伪代码如下:

for ($i = 1; $i <= 7; $i ++) {
    $key = "userlogin_".date('Ymd', time() - (86400*$i));

    if ($i == 1) {
        $redis->bitOp('and', 'week_logined', $key);
    } else {
        $redis->bitOp('and', 'week_logined', 'week_logined', $key);
    }
}

// 获取前50个用户
$userIds = [];
for ($i=1; $i<=10000; $i++) {
    $ret = $redis->getBit('week_logined', $i);
    $ret && $userIds[] = $i;

    if (count($userIds) >=50) break;
}

这里面有一个注意点,也是易错点,在bitop时候,第一次的时候,因为week_logined还不存在,所以进行op的键只有一个。当从第二次开始时候,进行op的键就为2个了。

获取一个月内登陆的用户,思路基本和上面一样,只是将and改为or

 for ($i = 1; $i <= 3; $i ++) {
    $key = "userlogin_".date('Ymd', time() - (86400*$i));
    $redis->bitOp('or', 'month_loginOnce', 'month_loginOnce', $key);
}

// 获取一个月内登陆过的用户
$userIds = [];
for ($i=1; $i<=10000; $i++) {
    $ret = $redis->getBit('month_loginOnce', $i);
    $ret && $userIds[] = $i;
}

可以看到,在进行or的时候和and还是有些区别的。or的时候,无需对第一次进行判断。个中缘由,大家自己体会体会。