Redis BitMap原理及实际使用介绍

一、介绍

Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1的设置,表示某个元素的值或者状态,时间复杂度为O(1)。

由于 bit 是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用二值统计的场景

Redis BitMap原理及实际使用介绍

内部实现

Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。

String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态,你可以把 Bitmap 看作是一个 bit 数组只是里面的内容只能为0或1而已(二进制位数组)。

二、常用命令

1.bitmap 基本操作:

# 设置值,为位数组指定偏移量上的二进制位设置值,偏移量从0开始计数,二进制位的值只能为0或1。返回原位置值。
SETBIT key offset value

# 获取指定偏移量上二进制位的值。
GETBIT key offset

# 统计位数组中值为1的二进制位数量。
# start 和 end 以字节为单位
BITCOUNT key start end

2.bitmap 运算操作:

# BitMap间的运算
# operations 位移操作符,枚举值
  AND 与运算 &
  OR 或运算 |
  XOR 异或 ^
  NOT 取反 ~
# result 计算的结果,会存储在该key中
# key1 … keyn 参与运算的key,可以有多个,空格分割,not运算只能一个key
# 当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0。返回值是保存到 destkey 的字符串的长度(以字节byte为单位),和输入 key 中最长的字符串长度相等。
BITOP [operations] [result] [key1] [keyn…]

# 返回指定key中第一次出现指定value(0/1)的位置
BITPOS [key] [value]

三、应用场景

Bitmap 类型非常适合二值状态统计的场景,这里的二值状态就是指集合元素的取值就只有 0 和 1 两种,在记录海量数据时,Bitmap 能够有效地节省内存空间。

1.签到统计

在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态。

签到统计时,每个用户一天的签到用 1 个 bit 位就能表示,一个月(假设是 31 天)的签到情况用 31 个 bit 位就可以,而一年的签到也只需要用 365 个 bit 位,根本不用太复杂的集合类型。

假设我们要统计 ID 100 的用户在 2023 年 10 月份的签到情况,就可以按照下面的步骤进行操作。

第一步,执行下面的命令,记录该用户 10 月 17 号已签到。

SETBIT USER_SIGN_KEY:100:202310 16 1

第二步,检查该用户10 月 17 日是否签到。

GETBIT USER_SIGN_KEY:100:202310 16 2 

第三步,统计该用户在 10 月份的签到次数。

BITCOUNT USER_SIGN_KEY:100:202310

这样,我们就知道该用户在 10 月份的签到情况了。

如何统计这个月首次打卡时间呢?

Redis 提供了 BITPOS key bitValue [start] [end]指令,返回数据表示 Bitmap 中第一个值为 bitValue 的 offset 位置。

在默认情况下, 命令将检测整个位图, 用户可以通过可选的 start 参数和 end 参数指定要检测的范围。(start 和 end 以字节为单位 一字节8位)所以我们可以通过执行这条命令来获取 userID = 100 在 2023年 10月份首次打卡日期:

BITPOS USER_SIGN_KEY:100:202310 1

需要注意的是,因为 offset 从 0 开始的,所以我们需要将返回的 value + 1 。

对应java代码如下:

/**
     * 签到
     *
     * @param userId 用户id
     * @param date   日期
     */
    @RequestMapping("/sign")
    public void sign(Integer userId, LocalDate date) {
        //拼接key
        String keySuffix = date.format(DateTimeFormatter.ofPattern(":yyyyMM"));
        String key = "USER_SIGN_KEY:" + userId + keySuffix;
        // 获取今天是本月的第几天
        int dayOfMonth = date.getDayOfMonth();
        // 写入redis setbit key offset 1
        stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
    }

  /**
     * 查询当天是否有签到
     *
     * @param userId 用户id
     * @param date   日期
     */
    @RequestMapping("/checkSign")
    public Boolean checkSign(Integer userId, LocalDate date) {
        String keySuffix = date.format(DateTimeFormatter.ofPattern(":yyyyMM"));
        String key = "USER_SIGN_KEY:" + userId + keySuffix;
        int dayOfMonth = date.getDayOfMonth();
        return stringRedisTemplate.opsForValue().getBit(key, dayOfMonth - 1);
    }
      /**
     * 获取本月累计签到数
     *
     * @param userId 用户id
     * @param date   日期
     */
    @RequestMapping("/getSumSignCount")
    public Long getSumSignCount(Integer userId, LocalDate date) {
        String keySuffix = date.format(DateTimeFormatter.ofPattern(":yyyyMM"));
        String key = "USER_SIGN_KEY:" + userId + keySuffix;
        return stringRedisTemplate.execute((RedisCallback<Long>) connection -> connection.bitCount(key.getBytes()));
    }
      /**
     * 获取本月首次签到日期
     *
     * @param userId 用户id
     * @param date   日期
     */
    @RequestMapping("/getFirstSign")
    public Long getFirstSign(Integer userId, LocalDate date) {
        String keySuffix = date.format(DateTimeFormatter.ofPattern(":yyyyMM"));
        String key = "USER_SIGN_KEY:" + userId + keySuffix;
        Long execute = stringRedisTemplate.execute((RedisCallback<Long>) connection -> connection.bitPos(key.getBytes(), Boolean.TRUE));
        if(Objects.nonNull(execute)&&execute>-1){
            return execute+1;
        }
        return execute;
    }

2.判断用户登陆状态

Bitmap 提供了 GETBIT、SETBIT 操作,通过一个偏移值 offset 对 bit 数组的 offset 位置的 bit 位进行读写操作,需要注意的是 offset 从 0 开始。

只需要一个 key = USER_LOG_IN_KEY 表示存储用户登陆状态集合数据, 将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 GETBIT判断对应的用户是否在线。50000 万 用户只需要 6 MB 的空间。

假如我们要判断 ID = 10086 的用户的登陆情况:

第一步,执行以下指令,表示用户已登录。

SETBIT USER_LOG_IN_KEY 10086 1

第二步,检查该用户是否登陆,返回值 1 表示已登录。

GETBIT USER_LOG_IN_KEY 10086

第三步,登出,将 offset 对应的 value 设置成 0。

SETBIT USER_LOG_IN_KEY 10086 0

对应java代码如下:

    /**
     * 登录
     *
     * @param userId 用户id
     */
    @RequestMapping("/logIn")
    public void logIn(Integer userId) {
        // 写入redis setbit key offset 1
        stringRedisTemplate.opsForValue().setBit("USER_LOG_IN_KEY", userId, true);
    }
    
    /**
     * 查询用户是否登录
     *
     * @param userId 用户id
     */
    @RequestMapping("/checkLogIn")
    public Boolean checkLogIn(Integer userId) {
        return stringRedisTemplate.opsForValue().getBit("USER_LOG_IN_KEY", userId);
    }
     /**
     * 登出
     *
     * @param userId 用户id
     */
    @RequestMapping("/checkLogIn")
    public void checkLogIn(Integer userId) {
        stringRedisTemplate.opsForValue().setBit("USER_LOG_IN_KEY", userId, false);
    }

3.获取连续签到天数

什么叫做连续签到天数?

从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。

获得当前这个月的最后一次签到数据,定义一个计数器,然后不停的向前统计,直到获得第一个非0的数字即可。

如何得到本月到今天为止的所有签到数据?

BITFIELD key GET u[dayOfMonth] 0

假设今天是18号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是18号,那么就是18位,去拿这段时间的数据,就能拿到所有的数据了,那么这18天里边签到了多少次呢?统计有多少个1即可。

如何从后向前遍历每个Bit位?

bitMap返回的数据是10进制,哪假如说返回一个数字8,那么我哪儿知道到底哪些是0,哪些是1呢?

我们只需要让得到的10进制数字和1做与运算就可以了,因为1只有遇见1 才是1,其他数字都是0 ,我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,依次内推,我们就能完成逐个遍历的效果了。

对应java代码如下:

 /**
     * 签到
     *
     * @param userId 用户id
     * @param date   日期
     */
    @RequestMapping("/sign")
    public void sign(Integer userId, LocalDate date) {
        String keySuffix = date.format(DateTimeFormatter.ofPattern(":yyyyMM"));
        String key = "USER_SIGN_KEYS:" + userId + keySuffix;
        stringRedisTemplate.opsForValue().setBit(key, date.getDayOfMonth(), true);
    }
 /**
     * 获取今天之前的的连续签到次数
     *
     * @return Integer
     */
    @GetMapping("/signCount")
    public Integer signCount(Integer userId) {
        //1. 获取日期
        LocalDateTime now = LocalDateTime.now();
        //2. 拼接key
        String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
        String key = "USER_SIGN_KEYS:" + userId + keySuffix;
        //3. 获取今天是本月的第几天
        int dayOfMonth = now.getDayOfMonth();
        //4. 获取本月截至今天为止的所有的签到记录,返回的是一个十进制的数字 BITFIELD USER_SIGN_KEYS:100:202310 GET u18 0
        List<Long> result = stringRedisTemplate.opsForValue().bitField(
                key,
                BitFieldSubCommands.create()
                        .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));
        //没有任务签到结果
        if (CollectionUtils.isEmpty(result)) {
            return 0;
        }
        //11 -> 1011
        Long num = result.get(0);
        if (num == null || num == 0) {
            return 0;
        }
        //5. 循环遍历
        int count = 0;
        while (true) {
            //6 让这个数字与1 做与运算,得到数字的最后一个bit位 判断这个数字是否为0
            if ((num & 1) == 0) {
                //如果为0,签到结束
                break;
            } else {
                count++;
            }
            //右移一位
            num >>>= 1;
        }
        return count;
    }

测试:

Redis BitMap原理及实际使用介绍
Redis BitMap原理及实际使用介绍

4.连续签到用户总数

如何统计出这连续 7 天连续打卡用户总数呢?

我们把每天的日期作为 Bitmap 的 key,userId 作为 offset,若是打卡则将 offset 位置的 bit 设置成 1。

key 对应的集合的每个 bit 位的数据则是一个用户在该日期的打卡记录。

一共有 7 个这样的 Bitmap,如果我们能对这 7 个 Bitmap 的对应的 bit 位做 『与』运算。同样的 UserID offset 都是一样的,当一个 userID 在 7 个 Bitmap 对应对应的 offset 位置的 bit = 1 就说明该用户 7 天连续打卡。

结果保存到一个新 Bitmap 中,我们再通过 BITCOUNT 统计 bit = 1 的个数便得到了连续打卡7天的用户总数了。

Redis 提供了 **BITOP operation destkey key [key ...]**这个指令用于对一个或者多个 key 的 Bitmap 进行位元操作。opration 可以是 andORNOTXOR。当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0 。空的 key 也被看作是包含 0 的字符串序列。

举个例子,比如将三个 bitmap 进行 AND 操作,并将结果保存到 destmap 中,接着对 destmap 执行 BITCOUNT 统计。

# 与操作
BITOP AND destmap bitmap:01 bitmap:02 bitmap:03
# 统计 bit 位 =  1 的个数
BITCOUNT destmap

即使一天产生一个亿的数据,Bitmap 占用的内存也不大,大约占 12 MB 的内存(10^8/8/1024/1024),7 天的 Bitmap 的内存开销约为 84 MB。同时我们最好给 Bitmap 设置过期时间,让 Redis 删除过期的打卡数据,节省内存。

对应java代码如下:

 /**
     * 签到
     *
     * @param userId 用户id
     * @param date   日期
     */
    @RequestMapping("/sign")
    public void sign(Integer userId, LocalDate date) {
        //拼接key
        String key = "USER_SIGN_KEY:" + date;
        // 写入redis setbit key offset 1
        stringRedisTemplate.opsForValue().setBit(key, userId, true);
    }
    
    /**
     * 连续签到用户总数
     *
     * @param startDate 开始日期
     * @param endDate   结束日期
     */
    @RequestMapping("/continuousLogIn")
    public Long continuousLogIn(LocalDate startDate, LocalDate endDate) {
        List<LocalDate> allBetweenDate = getAllBetweenDate(startDate, endDate);
        List<byte[]> bytes = allBetweenDate.stream().map(s -> ("USER_SIGN_KEYS:" + s).getBytes()).collect(Collectors.toList());
        stringRedisTemplate.execute((RedisCallback<Long>) connection -> connection.bitOp(RedisStringCommands.BitOperation.AND, "collect".getBytes(),
                bytes.get(0), bytes.get(1), bytes.get(2), bytes.get(3), bytes.get(4), bytes.get(5), bytes.get(6)
        ));

        return stringRedisTemplate.execute((RedisCallback<Long>) connection -> connection.bitCount("collect".getBytes()));
    }

    private List<LocalDate> getAllBetweenDate(LocalDate startDate, LocalDate endDate) {
        long numOfDaysBetween = ChronoUnit.DAYS.between(startDate, endDate) + 1;
        return IntStream.iterate(0, i -> i + 1).limit(numOfDaysBetween).mapToObj(startDate::plusDays).collect(Collectors.toList());
    }

四、总结

本文详细介绍了如何使用Redis BitMap,并给出了实际开发使用的java代码用例,开箱即用!

Redis的Bitmap是操作String数据结构的key所存储的字符串指定偏移量上的位,返回原位置的值。

Bitmap是通过最小的单位bit来进行0或者1的设置,表示某个元素对应的值或者状态。一个bit的值,或者是0,或者是1;也就是说一个bit能存储的最多信息是2。

位(bit):是计算机内部数据储存的最小单位,11001100是一个八位二进制数。字节(byte):是计算机中数据处理的基本单位,习惯上用大写B来表示,1B(byte,字节)=8bit。

对于Bitmap的优点,首先是节省空间,通过一个bit位来表示某个元素对应的值或者状态,其中key就是对应元素的值。实际上8个bit可以组成一个Byte,所以是及其节省空间的。其次,效率高,setbit和getbit的时间复杂度都是O(1),其他位运算效率也高。

对于Bitmap的缺点,只能表示0和1两种状态,所以能映射的状态有限。

原文始发于微信公众号(明月予我):Redis BitMap原理及实际使用介绍

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/272821.html

(0)
明月予我的头像明月予我bm

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!