说一说Redis的Bitmaps和HyperLoLog?

本篇内容对应 "Redis高级数据类型"小节 和 "7.5 网站数据统计"小节

对应视频:
Redis高级数据结构
网站数据统计

1 什么是UV和DAU?

DAU UV
英文全称 Daily Active User Unique Visotr
中文全称 日活跃用户量 独立访客
如何统计数据 通过用户ID排重统计数据 通过用户IP排重统计数据
记录的是什么 记录的是用户 记录的是设备
使用的Redis数据结构 Bitmaps HyperLogLog

2 什么是Redis的Bitmap和HyperLoLog?

2.1 Bitmaps

计算机是用二进制作为信息的基础单位,1个字节是8个bit位。例如"big"字符由3个字节组成,实际在计算机存储时将其用二进制表示。"big"中每个字符分别对应的ASCII码是98,105和103,这些十进制数对应的8位二进制数如下:

许多开发语言都提供了此操作位的功能,例如Java语言中:

java 复制代码
i >> 1   // 右移1位  等价于 i / 2
i << 1   // 左移1位  等价于 i * 2
i & j    // 按符号与
i | j    // 按符号或
i ^ j    // 按符号异或
~i       // 按位取非

合理地使用位操作能够有效地提高内存使用率和开发效率。Redis也提供了操作位的功能,即利用Bitmaps这个"数据结构"实现对位的操作,这里使用Bitmaps时要注意两点:

  1. Bitmaps本身不是一种数据结构,实际上它就是字符串,只不过它可以对字符串的位进行操作。
  2. Bitmaps单独提供了一套命令,所以在Redis中使用Bitmaps和使用字符串的方法不太相同。可以把Bitmaps想象成一个以位为单位的数组,数组的每个单元只能存储0和1,数组的下标在Bitmaps中叫偏移量。

将每个独立用户是否访问过网站存放在Bitmaps中,将访问的用户记作1,没有访问的用户记作0,用偏移量作为用户的id。

1.设置值

设置键的第offset个位的值(从0开始算起),假设现在有20个用户,userid=0, 5, 11, 15, 19 的用户对网站进行了访问,我们使用以下Redis命令向Bitmaps中添加这几个访问用户的id,2024-04-03表示这天的独立访问的用户的Bitmaps

bash 复制代码
setbit unique:users:2024-04-03 0 1
setbit unique:users:2024-04-03 5 1
setbit unique:users:2024-04-03 11 1
setbit unique:users:2024-04-03 15 1
setbit unique:users:2024-04-03 19 1

我使用lua脚本,在redis中可以直接将这5条命令一次执行:

lua 复制代码
EVAL "
redis.call('SETBIT', 'unique:users:2024-04-03', 0, 1)
redis.call('SETBIT', 'unique:users:2024-04-03', 5, 1)
redis.call('SETBIT', 'unique:users:2024-04-03', 11, 1)
redis.call('SETBIT', 'unique:users:2024-04-03', 15, 1)
redis.call('SETBIT', 'unique:users:2024-04-03', 19, 1)
" 0

当前Bitmaps初始化结果是:

如果此时有一个userid=50的用户访问了网站,会将这个用户添加到Btimaps中:

bash 复制代码
setbit unique:users:2024-04-03 50 1

对应的Bitmaps应该长这样:

在使用Bitmaps统计独立访问的用户时,需要注意以下几点:

  • 一些应用的用户id可能以一个指定的数字开头(例如1000),直接使用这样的用户id作为偏移量会造成一定的浪费,可以考虑减去一个特定的数字。
  • 在第一次初始化Bitmaps,如果偏移量较大,整个初始化过程会比较慢,可能造成Redis的阻塞。

2.获取值

获取userid=8的用户在2024-04-03这天是否访问过网站:

bash 复制代码
getbit unique:users:2024-04-03 8

结果返回0,表示该用户在2024-04-03这天没有访问过网站,由于前面我们没有添加过userid=8的这个用户,所以这个结果时符合逻辑的。

获取一个不存在的userid,比如偏移量是10000,结果也是返回0:

bash 复制代码
getbit unique:users:2024-04-03 10000

3.获取Bitmaps指定范围值为1的个数

下面这个命令会计算2024-04-03这天网站的独立访问用户数量:

bash 复制代码
bitcount unique:users:2024-04-03

通过bitcout命令就可以计算出2024-04-03这天这天网站的DAU,值是6

4.Bitmaps间的运算

假设

2024-04-04这天访问网站的用户是userid=1, 2, 5, 9;

2024-04-05这天访问网站的用户是userid=0, 1, 4, 9

现在要计算2024-04-04和2024-04-05这两天都访问过网站的用户数量,首先初始化这连天的Bitmaps,可以使用bitio and命令将这两个Bitmaps取交集。

图示过程如下:

再用前面介绍的bitcout命令即可获取与操作之后的Btimaps数量。

Bitmaps性能分析

假设网站由一亿用户,每天独立访问的用户有5千万。分别用集合类型和Bitmaps类型计算DAU,性能比较如下:

数据类型 每个用户id占用的空间 需要存储的用户量 全部内存量
集合 64 位 5千万 64位 X 5千万=400MB
Bitmaps 1 位 1亿 1位X1亿=12.5MB

首先,我们知道:

1 Byte = 8 Bit

1 KB = 1024 Byte

1 MB = 1024 KB

那么,64乘以5千万个bit就是:

64 * 50,000,000 Bit

将Bit转换为Byte:

64 * 50,000,000 Bit / 8 = 400,000,000 Byte

再将Byte转换为KB:

400,000,000 Byte / 1024 = 390,625 KB

最后将KB转换为MB:

390,625 KB / 1024 = 381.47 MB

最后结果大约在400M

但是这并不说明Bitmaps是万金油,如果用户量还是1亿,但是每天访问的用户只有10万,也就是说注册的用户大量是僵尸用户。这时他们之间的性能对比如下:

数据类型 每个用户id占用的空间 需要存储的用户量 全部内存量
集合 64 位 10万 64位 X 10万=800KB
Bitmaps 1 位 1亿 1位X1亿=12.5MB

因为Bitmaps不管你每天具体独立访问的用户数量,而是关系用户id,如果有一个id很大的用户访问一次,那么Bitmaps也会变得非常大。

2.2 HyperLogLog

HyperLogLog是一种基数算法,实际数据类型还是字符串类型,通过HyperLogLog可以利用很小的内存空间完成独立总数的统计,数据集可以是IP、Email、ID等。

Philippe Flajolet教授发表的论文首次提出的HyperLogLog基数算法

论文:The analysis of a near-optimal cardinality estimation algorithm

1.添加

pfadd用于向HyperLogLog添加元素,如果添加成功返回1:

bash 复制代码
 pfadd 2024_04_03:unique:ids uuid-1 uuid-2 uuid-3 uuid-4

2.计算独立用户数

pfcount用于计算一个或多个HyperLogLog的独立总数:

bash 复制代码
pfcount 2024_04_03:unique:ids

3.HyperLogLog内存占用实验

一两条数据看不出HyperLogLog内存占用小的优势,我们使用linux的bash脚本,向redis插入100万条用户uuid。

首先我们查询插入前的内存大小,我们执行关注前两行数据即可:

bash 复制代码
info memory

向redis HyperLogLog中的2024_04_04:unique:ids键插入100万个用户id的bash脚本如下

bash 复制代码
#!/bin/bash

# 初始化变量
elements=""
key="2024_04_04:unique:ids"

# 循环生成并插入用户ID
for i in $(seq 1 1000000); do
    elements="${elements} user-"${i}
    if [[ $((i%1000))  == 0 ]]; then
        # 使用Redis的pfadd命令插入用户
        redis-cli pfadd ${key} ${elements}
        # 清空elements变量,准备下一次插入
        elements=""
    fi
done

# 如果最后一次循环的用户数量不足1000,将剩余的用户插入
if [[ -n "$elements" ]]; then
    redis-cli pfadd ${key} ${elements}
fi

我们将该bash脚本命名为insert_users.sh,然后在Linux运行该脚本

bash 复制代码
./insert_users.sh

如果报bash: ./insert_users.sh: Permission denied 错误,说明这个文件没有执行权限

使用chmod +x insert_users.sh命令给该文件添加执行权限,然后再次运行即可

执行完毕后到redis中,再次执行查询内存的命令

bash 复制代码
info memory

1108384b - 1091920b = 16464b = 16.46KB

所以存入100万个用户的uuid之后,内存只增加了16.46KB。可以看到内存占用非常小。我们再来看一下统计结果是否正确,用pfcount命令统计以下:

bash 复制代码
pfcount 2024_04_04:unique:ids

可以看到结果是1003860,并不是精确的100万,但是也很接近了。考虑到占用的内存有很大的优势,对精确值要求敏感的业务完全可以用HyperLogLog。

再来看一下如果用集合来存储着100万个用户uuid,然后统计总数,bash脚本如下:

bash 复制代码
#!/bin/bash

# 初始化变量
elements=""
key="2024_04_04:unique:ids:set"

# 循环生成并插入用户ID
for i in $(seq 1 1000000); do
    elements="${elements} user-"${i}
    if [[ $((i%1000))  == 0 ]]; then
        # 使用Redis的sadd命令插入用户
        redis-cli sadd ${key} ${elements}
        # 清空elements变量,准备下一次插入
        elements=""
    fi
done

# 如果最后一次循环的用户数量不足1000,将剩余的用户插入
if [[ -n "$elements" ]]; then
    redis-cli pfadd ${key} ${elements}
fi

存入insert_users_set.sh脚本文件中,然后运行。

bash 复制代码
./insert_users_set.sh

执行完后,到redis执行info memory命令,与上一次的内存大中比较:

bash 复制代码
info memory

89496112b - 1108384b = 88387728b = 84.28M

可以看到与前一次相比,内存增加了84M左右。但是用集合统计的结果是精确的:

bash 复制代码
scard 2024_04_04:unique:ids:set

根据上面的实验结果,这里列出了使用集合类型和HyperLogLog类型统计百万级用户的占用空间对比:

数据类型 1天 1个月 1年
集合 80M 2.4G 28G
HyperLogLog 15K 450K 5M

可以看到HyperLogLog内存占用量小得惊人,用如此小的空间来估算如此巨大的数据,必然不是100%的精确,存在一定的误差率。Redis官方给出的数字是0.81%的失误率。

4.合并

pfmerge可以求出多个HyperLogLog的并集并赋值给destkey,例如要计算2016年3月5日和3月6日的访问独立用户数,可以按照如下方式来执行:

bash 复制代码
pfadd 2016_03_06:unique:ids "uuid-1" "uuid-2" "uuid-3" "uuid-4"
pfadd 2016_03_05:unique:ids "uuid-4" "uuid-5" "uuid-6" "uuid-7"
pfmerge 2016_03_05_06:unique:ids 2016_03_05:unique:ids 2016_03_06:unique:ids
pfcount 2016_03_05_06:unique:ids

3 网站数据统计

为什么UV要用ip作为统计访客的依据?

因为我们希望将一些匿名的用户,或者一些没有登录的用户或一些没有账号的游客也统计进来。 UV这个统计值关注的是访问量。而不是关注到底注没注册、登没登录。不管是谁,只要你访问就算。所以,我们要对每次访问都要进行统计,每次访问都把这个用户的ip存到HyperLogLog里,HyperLogLog会统计不同ip的个数,得到最终去重以后的结果,虽然有误差率,但是其极致的内存利用率,这点误差率可以忍受的。

为什么HyperLogLog适合用统计UV的数据结构?

因为HyperLogLog最大的特点是性能好,存储空间非常小。而我们网站的访问量,加上游客的话,可能会非常大,每天可能有几十万甚至上百万的访问量。因为即使是同一个ip访问,由于服务器事先不知道,也会取把这个ip记录到数据结构中。所以访问量才这么大。而我们对UV这个统计值要求不是那么精确,HyperLogLog的误差率足以忍受,所以我们使用HyperLogLog来统计UV。

你的网站如何定义用户是否活跃?

我的网站定义只要今天访问一次就算是一个活跃用户。

DAU与UV的区别?

我们统计DAU是根据用户id来进行统计的,也就是说一些没有登录的游客,DAU是不统计的。

为什么DAU要用Bitmaps?

因为我们在统计DAU时更关注用户的有效性,通过Bitmaps不仅可以统计某天的活跃用户数量,还可以通过命令判断某天,某个用户是不是活跃的等功能,也就是Bitmaps虽然空间利用率没有HyperLogLog高,但是一个网站真正注册登录的用户其实相较于访问量会小很多,所以对空间的占用不会特别高。而且Bitmaps统计是精确的结构。我们关注用户的核心程度,所以对于这样的数据,要求它是一个精确的结果。Bitmaps本身统计的内存占用也不大,性能也比较好,能统计出精确的结果。

你如何实现用Bitmaps存储用户id?

在数据库表设计时,我将用户表的userid设计成为int类型,从1开始自增的主键,所以可以直接将用户id作为偏移量,是否访问了网站分别设置为1和0来存入bitmaps中。

由于userid是整数,而我们要记录的是这个userid访没访问过,访问过就是1,没访问过就是0。1就是活跃,0就是不活跃。

例如:bitmaps的key = dau:2024-04-04,然后userid=101的用户在2024-04-04这条访问过我们的网站,我就将Bitmaps的101位设置成1

这样我们每天可能有几万个用户访问了网站,我只需要用一个Bitmaps,这个Bitmaps的长度=max(userid)。所以,Bitmaps也是比较节约空间的,这么多用户我只需用这么些位来统计即可。

下面我们开始编码实现,因为要用到redis来存储数据,所以首先需要定义redisk key。在RedisKeyUtil类里面添加UV和DAU的key:

java 复制代码
    private static final String PREFIX_UV = "uv";
    private static final String PREFIX_DAU = "dau";

如何查看一周的UV?

由于HyperLogLog支持合并,我们只需使用pfmerge命令求出多个HyperLogLog的并集。例如要计算2024-04-04到2024-04-11这一周的UV,我们可以将这一周7天的7个HyperLogLog进行合并。

我们按照单日UV的方式来记录:

java 复制代码
	// 单日UV
    public static String getUVKey(String date) {
        return PREFIX_UV + SPLIT + date;
    }

单日UV的key类似于:"uv:2024-04-04"。

如果想统计一周,一个区间的UV,可以对多个HyperLogLog合并,合并的适合会产生一个新的数据,这个数据要指定一个新的key,所以我们要构造一周UV的key,即区间UV。

java 复制代码
	// 区间UV
    public static String getUVKey(String startDate, String endDate) {
        return PREFIX_UV + SPLIT + startDate + SPLIT + endDate;
    }

区间UV的key类似于 "uv:2024-04-04:2024-04-11"。

同理,统计活跃用户也需要但是DAU和区间DAU

java 复制代码
    // 单日活跃用户
    public static String getDAUKey(String date) {
        return PREFIX_DAU + SPLIT + date;
    }

    // 区间活跃用户
    public static String getDAUKey(String startDate, String endDate) {
        return PREFIX_DAU + SPLIT + startDate + SPLIT + endDate;
    }

定义完以后,我们开发首先开发数据访问层,因为我们用的是Redis,所以我们数据访问层不用单独一个组件,直接上来些Service即可。取调用这个RedisTemplate就行了,创建一个新的Server:DataService类。

首先将RedisTemplate 对象注入进来,由于创建DAU和UV的key时要不断的和日期打交道,所以也将SimpleDateFormat 注入进来,日期格式是"yyyyMMdd"

java 复制代码
	@Autowired
    private RedisTemplate redisTemplate;

    private SimpleDateFormat df = new SimpleDateFormat("yyyyMMdd");

要统计这个数据包括两个方面:

  1. 我要把这个数据记录下来,在每一次请求当中,需要截获这次请求,把相关数据记录到redis。(记)
  2. 当我想看的时候,能够提供一个查询的方法,能够访问到。(查)

首先做UV记的方法:

java 复制代码
    // 将指定的IP计入UV
    public void recordUV(String ip) {
        String redisKey = RedisKeyUtil.getUVKey(df.format(new Date()));
        redisTemplate.opsForHyperLogLog().add(redisKey, ip);
    }

UV区间查的方法:

java 复制代码
// 统计指定日期范围内的UV
    public long calculateUV(Date start, Date end) {
        if (start == null || end == null) {
            throw new IllegalArgumentException("参数不能为空!");
        }

        // 整理该日期范围内的key
        List<String> keyList = new ArrayList<>();
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(start);
        while (!calendar.getTime().after(end)) {
            String key = RedisKeyUtil.getUVKey(df.format(calendar.getTime()));
            keyList.add(key);
            calendar.add(Calendar.DATE, 1);
        }
        /*
        * 如果是同一天,2024-04-03 00:00:00~ 2024-04-03 23:59:59
        * 合并生成的redisKey是vu:2024-04-03:2024-04-04
        * keyList的值是[vu:2024-04-03],union操作只union了一个
        * 实现了一个方法统计单日UV和区间UV
        * 下面DAU查的方式也是一样的
        * */

        // 合并这些数据
        String redisKey = RedisKeyUtil.getUVKey(df.format(start), df.format(end));
        redisTemplate.opsForHyperLogLog().union(redisKey, keyList.toArray());

        // 返回统计的结果
        return redisTemplate.opsForHyperLogLog().size(redisKey);
    }

DAU的记和查的逻辑类似:

java 复制代码
// 将指定用户计入DAU
    public void recordDAU(int userId) {
        String redisKey = RedisKeyUtil.getDAUKey(df.format(new Date()));
        redisTemplate.opsForValue().setBit(redisKey, userId, true);
    }

    // 统计指定日期范围内的DAU
    public long calculateDAU(Date start, Date end) {
        if (start == null || end == null) {
            throw new IllegalArgumentException("参数不能为空!");
        }

        // 整理该日期范围内的key
        List<byte[]> keyList = new ArrayList<>();
        Calendar calendar = Calendar.getInstance();
        calendar.setTime(start);
        while (!calendar.getTime().after(end)) {
            String key = RedisKeyUtil.getDAUKey(df.format(calendar.getTime()));
            keyList.add(key.getBytes());
            calendar.add(Calendar.DATE, 1);
        }
		/*
        * redisTemplate.opsForValue().setBit 可以设置位
        * 但是要使用or运算必须使用底层连接
        * */
        // 进行OR运算
        return (long) redisTemplate.execute(new RedisCallback() {
            @Override
            public Object doInRedis(RedisConnection connection) throws DataAccessException {
                String redisKey = RedisKeyUtil.getDAUKey(df.format(start), df.format(end));
                connection.bitOp(RedisStringCommands.BitOperation.OR,
                        redisKey.getBytes(), keyList.toArray(new byte[0][0]));
                return connection.bitCount(redisKey.getBytes());
            }
        });
    }

业务层逻辑写好以后,接下来就可以写表现层的逻辑。表现从逻辑一分为二:

  1. 什么时候去记录这个值
  2. 什么时候去查看这个值

记录这个值需要每次请求都进行记录,因为每次请求都有可能是一个新的访问,所以我们在拦截器里面写这个代码。请求到底Handler之前将请求拦截

创建一个DataInterceptor类,只需要重写preHandle方法即可,在该方法里面进行DAU和UV的统计。

java 复制代码
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 统计UV
        String ip = request.getRemoteHost();
        dataService.recordUV(ip);

        // 统计DAU
        User user = hostHolder.getUser();
        if (user != null) {
            dataService.recordDAU(user.getId());
        }

        return true;
    }

然后将该拦截器注册。

之后就是统计方法,比较简单。

相关推荐
睡不醒的小泽2 小时前
VSCode环境下连接 MySQL 8.0 数据库 (C++)
数据库·windows·vscode
藓类少女4 小时前
正则表达式
数据库·python·mysql·正则表达式
魏 无羡4 小时前
pgsql 分组查询方法
java·服务器·数据库
szcsd1234567895 小时前
简单有效关于msvcp140.dll丢失的解决方法,msvcp140.dll修复的方法原理及步骤
数据库·dll文件·dll修复工具·dll修复·dll丢失
江凡心5 小时前
Qt 每日面试题 -1
服务器·数据库·qt
好记忆不如烂笔头abc5 小时前
db2恢复数据库
数据库
Counter-Strike大牛5 小时前
MySQL迁移达梦报错,DMException: 第1 行附近出现错误: 无效的表或视图名[ACT_GE_PROPERTY]
java·数据库
小诸葛的博客7 小时前
pg入门18—如何使用pg gis
数据库
林太白7 小时前
❤Node09-用户信息token认证
数据库·后端·mysql·node.js
我爱娃哈哈7 小时前
MySQL 优化器:理解与探秘
数据库·mysql