• 使用 Redis BitMap 实现签到与查询历史签到以及签到统计功能(SpringBoot环境)


    一、前言

          签到是一个很常见的功能,如果使用数据库实现,那么用户一次签到,就是一条记录,假如有100万用户,平均每个用户每年签到次数为30次,则这张表一年的数据量为 3000 万条,一般签到记录字段不会太多一条数据按照30字节算,一年就是858.3MB左右,但是对于签到信息查询是比较频繁的,如查询当天是否签到、查询用户近7天签到记录、查询用户近30天签到记录、统计用户签到次数,如果这些查询都要去签到表查询那么数据库压力是非常大的,而且考虑到数据量会不断增长,这里使用Redis BitMap 实现高效的签到与统计。

    二、Redis BitMap 位图原理

          BitMap 在 Redis 中并不是一个新的数据类型,其底层是 Redis 实现,Redis 的位图(BitMap)是由多个二进制位组成的数组,只有两种状态,0和1, 数组中的每个二进制位都有与之对应的偏移量(从 0 开始),通过这些偏移量可以对位图中指定的一个或多个二进制位进行操作,由于采用一个bit 来存储一个数据,因此可以大大的节省空间。

    在这里插入图片描述

    2.1、BitMap 能解决什么

    • BitMap 能解决很多问题,核心就是使用位数组节省存储空间,常见业务有用户签到、打卡、统计活跃用户、统计用户在线状态、实现布隆过滤器、数据去重、快速查找等。

    • BitMap是如何使用位数组节省存储空间的
      在20亿个随机整数中找出某个数m是否存在其中,并假设32位操作系统,4G内存。
      计算机分配给内存的最小单元是bit,在Java中,int占4字节,1字节=8位(1 byte = 8 bit)。
      如果每个数字用int存储,那就是20亿个int,因而占用的空间约为 (2000000000*4/1024/1024/1024)≈7.45G
      如果按位存储就不一样了,20亿个数就是20亿位,占用空间约为 (2000000000/8/1024/1024/1024)≈0.23G

    2.2、BitMap 存储空间计算

    • 在 Redis 中是使用字符串类型存储的,Redis 中字符串的最大长度是 512M,所以 BitMap 的 offset (偏移量)最大值为:512 * 1024 * 1024 * 8 = 2^32,也就是说一个BitMap只能存储2^32个位,差不多4.29亿。
    • 还注意一个问题,如果我们只在一个 BitMap 偏移量为99的位置存放了一个数据,那么这个 BitMap 也是会占用100个位的内存的,0-98这些位都会被隐式地初始化为 0。

    2.3、BitMap 存在问题

    • 数据碰撞。比如将字符串映射到 BitMap 的时候会有碰撞的问题,那就可以考虑用 Bloom Filter 来解决,Bloom Filter 使用多个 Hash 函数来减少冲突的概率。

    • 数据稀疏。又比如要存入(10,100000,10000000)这三个数据,我们需要建立一个 9999999 长度的 BitMap ,但是实际上只存了3个数据,这时候就有很大的空间浪费,碰到这种问题的话,可以通过引入 Roaring BitMap 来解决。

    三、Redis BitMap 操作基本语法和原生实现签到

    3.1、基本语法

    # 设置指定偏移量上的位的值(0 或 1),语法:SETBIT key offset value
    ## 示例:给mykey 偏移量为9的位置设置值为1
    SETBIT mykey 9 1
    
    # 获取指定偏移量上的位的值,语法:GETBIT key offset
    ## 示例:获取mykey 偏移量为9上的值
    GETBIT mykey 9
    
    # 统计指定范围内所有位为1的数量 如果不指定范围则统计整个key,这个范围是以字节为单位的比如start设置成1其实代表8bit,对应偏移量是8开始,语法:BITCOUNT key [start end]
    ## 示例:获取mykey 所有所有位为1的数量
    BITCOUNT mykey
    
    # 在指定范围内查找第一个被设置为 1 或 0 的位,语法:BITPOS key bit [start] [end]
    ## 示例:查找mykey中第一个被设置为 1 的位置
    BITPOS mykey 1
    
    # 对位图的指定偏移量进行位级别的读写操作:语法:BITFIELD key [GET type offset] [SET type offset value] [INCRBY type offset increment]
    ## GET type offset 用于获取指定偏移量上的位,type 可以是 u(无符号整数)或 i(有符号整数),offset 是位图的偏移量。
    ## SET type offset value 用于设置指定偏移量上的位,type 是位的类型,offset 是位图的偏移量,value 是要设置的值。
    ## INCRBY type offset increment 用于递增或递减指定偏移量上的位,type 是位的类型,offset 是位图的偏移量,increment 是递增或递减的值。
    ## 示例:获取mykey 偏移量从 0 开始的4位无符号整数(u4 表示 4 位的无符号整数)
    BITFIELD mykey GET u4 0
    
    # 对一个或多个位图执行指定的位运算操作(AND、OR、XOR、NOT),语法:BITOP operation destkey key [key ...]
    ## 示例:将key1和key1进行AND运算(对应位都为 1 时结果位为 1,否则为 0),将运算后的结果保存到新的key:destkey 
    BITOP AND destkey key1 key2
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    3.2、Redis BitMap 实现签到操作指令

          这里模拟一个月签到,日期上选择2023年11月的1 3 5号这三天签到,因为偏移量是从0开始,所以对应偏移量就是0、2、4,其余日期不签到。

    • 1、添加用户签到位 key = USER_SIGN_IN:U0001:202311,其中U0001代表用户编号,202311代表对应年和月

      127.0.0.1:6379> SETBIT USER_SIGN_IN:U0001:202311 0 1
      (integer) 0
      127.0.0.1:6379> SETBIT USER_SIGN_IN:U0001:202311 2 1
      (integer) 0
      127.0.0.1:6379> SETBIT USER_SIGN_IN:U0001:202311 4 1
      (integer) 0
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
    • 2、查看用户指定日期是否有签到(查看当天是否有签到同理),这里查看5号是否有签到偏移量为4,返回1则代表有签到

      127.0.0.1:6379> GETBIT USER_SIGN_IN:U0001:202311 4
      (integer) 1
      
      • 1
      • 2
    • 3、查看用户2023年11月一共签到了几天

      127.0.0.1:6379> BITCOUNT USER_SIGN_IN:U0001:202311
      (integer) 3
      
      • 1
      • 2
    • 4、查看用户2023年11月那些日期签到了,11月一共有30天

      • 通过BITFIELD获取30位的无符号十进制整数,从偏移量0开始
      127.0.0.1:6379> BITFIELD USER_SIGN_IN:U0001:202311 GET u30 0
      1) (integer) 704643072
      
      • 1
      • 2
      • 将获取到的无符号十进制整数转换成二进制,这里可以看到从左到右二进制的第1 3 5位置值都是1,对应偏移量0 2 4,这里不是从右到左的,然后通过业务代码判断判断这个二进制对应位为1则代表有签到,具体代码会在下面做实现。
      # 十进制
      704643072
      # 二进制
      101010000000000000000000000000
      
      • 1
      • 2
      • 3
      • 4

    四、SpringBoot 使用 Redis BitMap 实现签到与统计功能

          这里会使用SpringBoot环境RedisTemplate来操作Redis,需要集成文章可以查看,SpringBoot集成Lettuce客户端操作Redis:https://blog.csdn.net/weixin_44606481/article/details/133907103

    我这里还会使用到hutool工具包操作时间解析,有需要可以引入。

    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.7.17</version>
    </dependency>
    
    • 1
    • 2
    • 3
    • 4
    • 5

    4.1、代码实现

    代码里注释比较完整这里就不做额外介绍了

    import cn.hutool.core.date.DateTime;
    import cn.hutool.core.date.DateUtil;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.connection.BitFieldSubCommands;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.stereotype.Service;
    import java.time.YearMonth;
    import java.util.*;
    
    /**
     * 签到业务
     */
    @Service
    public class SignInService {
    
        @Autowired
        private RedisTemplate<String, String> redisTemplate;
    
        private static String yyyy_MM_dd = "yyyy-MM-dd";
        private static String yyyy_MM = "yyyy-MM";
    
        /**
         * 用户签到
         * @param userNo 用户编号
         * @param date   日期 格式yyyy-MM-dd
         */
        public boolean signIn(String userNo, String date) {
            // 获取缓存key
            String cacheKey = getCacheKey(userNo, date);
            // 获取日期
            DateTime dateTime = DateUtil.parse(date, yyyy_MM_dd);
            int day = dateTime.dayOfMonth();
            // 设置给BitMap对应位标记 其中offset为0表示第一天所以要day-1
            Boolean result = redisTemplate.opsForValue().setBit(cacheKey, day - 1, true);
            // 如果响应true则代表之前已经签到,在Redis指令操作setbit 设置对应位为1的时候,如果之前是0或者不存在会响应0,如果为1则响应1
            if (result) {
                System.out.println("用户userNo=" + userNo + " date=" + date + "  已签到");
            }
            return result;
        }
    
        /**
         * 查看用户指定日期是否签到(查看当天是否有签到同理)
         * @param userNo
         * @param date   日期 格式yyyy-MM-dd
         */
        public boolean isSignIn(String userNo, String date) {
            // 获取缓存key
            String cacheKey = getCacheKey(userNo, date);
            // 获取日期
            DateTime dateTime = DateUtil.parse(date, yyyy_MM_dd);
            int day = dateTime.dayOfMonth();
            return redisTemplate.opsForValue().getBit(cacheKey, day - 1);
        }
    
        /**
         * 统计用户指定年月签到次数
         * @param userNo
         * @param date   格式yyyy-MM
         */
        public Long getSignInCount(String userNo, String date) {
            // 获取缓存key
            String cacheKey = getCacheKey(userNo, date);
            // 不知道是那个版本才有的下面这个方法,我的现在使用的spring-data-redis是2.3.9.RELEASE 是没有这个方法的,改用connection直接调用bitCount
    //        Long count = redisTemplate.opsForValue().bitCount(key, start, end);
            Long count = redisTemplate.execute(connection -> connection.bitCount(cacheKey.getBytes()), true);
            return count;
        }
    
        /**
         * 获取用户指定年月签到列表,也可以通过这种方式获取用户月签到次数
         *
         * @param userNo
         * @param date   格式yyyy-MM
         */
        public List<Map> getSignInList(String userNo, String date) {
            // 获取缓存key
            String cacheKey = getCacheKey(userNo, date);
            // 获取传入月份有多少天
            DateTime dateTime = DateUtil.parse(date, yyyy_MM);
            YearMonth yearMonth = YearMonth.of(dateTime.year(), dateTime.monthBaseOne());
            int days = yearMonth.lengthOfMonth();
            BitFieldSubCommands bitFieldSubCommands = BitFieldSubCommands.create()
                    .get(BitFieldSubCommands.BitFieldType.unsigned(days)).valueAt(0);
            // 获取位图的无符号十进制整数
            List<Long> list = redisTemplate.opsForValue().bitField(cacheKey, bitFieldSubCommands);
            if (list == null || list.isEmpty()) {
                return null;
            }
            // 获取位图的无符号十进制整数值
            long bitMapNum = list.get(0);
            // 进行位运算判断组装那些日期有签到
            List<Map> result = new ArrayList<>();
            for (int i = days; i > 0; i--) {
                Map<String, Object> map = new HashMap<>();
                map.put("day", i);
                //先 右移,然后在 左移,如果得到的结果仍然与本身相等,则 最低位是0 所以是未签到
                if (bitMapNum >> 1 << 1 == bitMapNum) {
                    map.put("active", false);
                } else {
                    //与本身不等,则最低位是1 表示已签到
                    map.put("active", true);
                }
                result.add(map);
                // 将位图的无符号十进制整数右移一位,准备下一轮判断
                bitMapNum >>= 1;
            }
            Collections.reverse(result);
            return result;
        }
    
    
        /**
         * 获取缓存key
         */
        private static String getCacheKey(String userNo, String date) {
            DateTime dateTime = DateUtil.parse(date, yyyy_MM);
            return String.format("USER_SIGN_IN:%s:%s", userNo, dateTime.year() + "" + dateTime.monthBaseOne());
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120

    4.2、功能测试

    import com.redisscene.service.SignInService;
    import org.junit.Test;
    import org.junit.runner.RunWith;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
    import java.util.List;
    import java.util.Map;
    
    /**
     * 签到功能测试
     */
    @RunWith(SpringJUnit4ClassRunner.class)
    @SpringBootTest
    public class SignInTest {
    
        @Autowired
        private SignInService signInService;
    
        /**
         * 测试用户签到
         */
        @Test
        public void t1() {
            boolean b1 = signInService.signIn("U0001", "2023-11-01");
            boolean b2 = signInService.signIn("U0001", "2023-11-03");
            boolean b3 = signInService.signIn("U0001", "2023-11-05");
            boolean b4 = signInService.signIn("U0001", "2023-11-01");
            System.out.println("b1=" + b1 + " b2=" + b2 + " b3=" + b3 + " b4=" + b4);
        }
    
        /**
         * 测试查看用户指定日期是否签到(查看当天是否有签到同理)
         */
        @Test
        public void t2() {
            boolean b1 = signInService.isSignIn("U0001", "2023-11-01");
            System.out.println(b1 ? "b1已签到" : "b1未签到");
            boolean b2 = signInService.isSignIn("U0001", "2023-11-06");
            System.out.println(b2 ? "b2已签到" : "b2未签到");
        }
    
        /**
         * 测试统计用户指定年月签到次数
         */
        @Test
        public void t3() {
            Long count = signInService.getSignInCount("U0001", "2023-11");
            System.out.println("签到次数count=" + count);
        }
    
        /**
         * 测试获取用户指定年月签到列表,也可以通过这种方式获取用户月签到次数
         */
        @Test
        public void t4() {
            List<Map> list = signInService.getSignInList("U0001", "2023-11");
            if (list != null && !list.isEmpty()) {
                list.forEach(item -> System.out.println(item));
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
  • 相关阅读:
    2024年阿里云2核4G服务器租用价格_2核4G性能测评_支持人数
    服务器和客户端信息的获取
    网页拒绝连接,怎么办怎么办
    Helm3模板-内置函数和Values
    java毕业生设计医院取药系统计算机源码+系统+mysql+调试部署+lw
    ​​​​​​​Python---练习:使用while嵌套循环打印 9 x 9乘法表
    Learned Index on GPU(ICDE2022)
    springboot服务端接口公网远程调试 - 实现HTTP服务监听【端口映射】
    csapp 第四章 读书笔记 part1
    ch4、编写第一个Go程序
  • 原文地址:https://blog.csdn.net/weixin_44606481/article/details/134446032