Redis Bitmap:实现千万级用户签到的秘密武器

前言

1. 在具体应用之前,有必要先了解一下什么是Redis Bitmap?

Redis Bitmap是一种数据结构,它通过比特位来记录信息,每一位都代表一个布尔值,这样它可以在一些大规模数据的存储和查询场景中比常规的记录方式更节省内存和高效。

2. Redis Bitmap常用的命令

Bitmap在Redis中是通过字符串实现的,每个字符串都是一个固定大小的位数组,常用的命令如下:

  1. SETBIT:设置指定偏移量上的位值。语法:SETBIT <key> <offset> <value>

    • <key>:指定的键名。
    • <offset>:指定的偏移量。
    • <value>:要设置的值,0或1。
  2. GETBIT:获取指定偏移量上的位值。语法:GETBIT <key> <offset>

    • <key>:指定的键名。
    • <offset>:指定的偏移量。
  3. BITCOUNT:统计指定键的位中设置为1的位数。语法:BITCOUNT <key>

    • <key>:指定的键名。
  4. BITPOS:找到第一个设置为1的位。语法:BITPOS <key> <offset>

    • <key>:指定的键名。
    • <offset>:指定的偏移量。
  5. BITOP:对多个Bitmap进行位操作。

    • AND:将所有Bitmap的对应位进行按位与操作。

    • OR:将所有Bitmap的对应位进行按位或操作。

    • XOR:将所有Bitmap的对应位进行按位异或操作。

    • NOT:对第一个Bitmap的对应位进行按位取反操作。语法:BITOP operation destkey key*

      • <operation>:要执行的操作,可以是AND、OR、XOR或NOT。
      • <destkey>:目标键名,用于存储结果。
      • <key*>:源键名列表,最多可以指定16个。

3. 常见的应用场景

  1. 用户在线状态监控:在大型社交网站中,管理员需要及时检测用户的在线状态。通过使用Redis的Bitmap结构,可以为每个在线用户分配一个唯一的ID,并将其对应的位设置为1。如果用户离线,则将其占据的位设置为0。这样,可以通过快速查询Bitmap来快速检测用户的在线状态。

  2. 签到活动统计:在某些需要每日签到的应用中,可以使用Redis的Bitmap来记录用户的签到状态。为每个用户分配一个唯一的ID,并将每天的位数组与该用户的ID相关联。如果用户签到,则将其对应的位设置为1,否则设置为0。这样,可以快速查询指定日期或用户的签到状态。

  3. 统计网站访问量:使用Redis的Bitmap可以快速统计网站的访问量。为每个访问的用户分配一个唯一的ID,并将其对应的位设置为1。通过BITCOUNT命令可以快速统计位数组中值为1的位的数量,从而得到网站的总访问量。

  4. 统计其他用户属性:除了上述场景,Redis的Bitmap还可以用于统计其他用户属性。例如,可以用于记录用户的性别、年龄段、地域等属性,以便进行用户分析、数据统计和挖掘等操作。

具体应用场景

1. 用户一天的访问量统计

需求

假如某个系统拥有1亿用户,现在需要每天统计这1亿用户中有多少用户访问了系统!

设计思路

首先我们要为每一个用户准备一个存储位置,存储内容实际上也非常简单,只需要能分区出"访问"或者"没访问"即可,因此自然就选择了Redis Bitmap来实现了。

容量预估

1个字节:可以表示8个用户,那么12,500,000个字节即可存下所有用户,约等于107M。

Key是以天为单位存储,如果想保留多天,那也只需要存储一份当天最终的静态数据即可,因此可以理解为无论要保留多少天的用户访问量统计,都只需要大约107M即可。

执行命名模拟

Redis Bitmap Key的粒度是到天的,表示当天的访问统计,假设ID分别为1,100,1000的三个用户,都在2023-08-30号访问了系统,那么执行的命令记录如下:

sql 复制代码
setbit dayVisitCount:20230830 0 1

setbit dayVisitCount:20230830 99 1

setbit dayVisitCount:20230830 999 1

注意:offset下标从0开始的,1表示访问过了

最终统计命名如下:

sql 复制代码
bitcount dayVisitCount:20230830

代码示例

less 复制代码
@RestController
@RequestMapping(value = "/demo")
public class VisitCountTest {

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    public final static String DAY_VISIT_COUNT = "dayVisitCount:%s";

    @RequestMapping("/count")
    public void count(@RequestBody VisitDTO visitDTO) {
        String visitKey = String.format(DAY_VISIT_COUNT, visitDTO.getDate());
        System.out.println(visitDTO.getDate() + ",访问用户数:" + bitCount(visitKey));
    }

    public long bitCount(String key) {
        Long ans = redisTemplate.execute((RedisCallback<Long>) con -> con.bitCount(key.getBytes()));
        return ans == null ? 0 : ans;
    }

    @RequestMapping("/visit")
    public void visit(@RequestBody VisitDTO visitDTO) {
        String visitKey = String.format(DAY_VISIT_COUNT, visitDTO.getDate());
        // 下标从0开始的
        setBit(visitKey, visitDTO.getUserId() - 1);
    }

    private void setBit(String visitKey, int userId) {
        if (!redisTemplate.opsForValue().setBit(visitKey, userId, true)) {
            System.out.println("新增访问用户ID:" + userId);
        }
    }

}

效果展示

java 复制代码
20230831,当前访问用户数:0
新增访问用户ID:0
新增访问用户ID:1
新增访问用户ID:2
新增访问用户ID:99
新增访问用户ID:999
20230831,当前访问用户数:5
新增访问用户ID:9999
20230831,当前访问用户数:6

2. 用户日签到功能

需求

系统现在推出了签到功能,假设有1亿用户,该功能要求能够完成每个用户每天的签到功能、过去时间的补签功能,以及连续签到天数和签到日历表。

设计思路

以月维度,维护每个用户每月的签到情况,如ID:1的用户在8月26号~8月30号每天都签到了,8月26号之前未签到,因此连续签到天数为5天,ID:100的用户在8月26号~8月30号期间,其中8月28号断签了,因此连续签到天数为2天。

容量预估

我们可以分析一下在此方案中,如果1亿用户全部都是每天都签到,那么也就是每个月会产生1亿个Key,但每个Key的大小实际最多都不会超过32bit(4个字节),因此一个月的存储总量也就是400M不到。当然实际上不可能1亿用户全部都会每天签到,因此实际上每月用到的存储总量预估不会超过100M。

执行命名模拟

执行命名如下:

用户ID:1

sql 复制代码
setbit daySignIn:20238:1 25 1

setbit daySignIn:20238:1 26 1

setbit daySignIn:20238:1 27 1

setbit daySignIn:20238:1 28 1

setbit daySignIn:20238:1 29 1

用户ID:100

sql 复制代码
setbit daySignIn:20238:100 25 1

setbit daySignIn:20238:100 26 1

setbit daySignIn:20238:100 28 1

setbit daySignIn:20238:100 29 1

注意:offset从0开始,因此25表示的是26号

用户ID1,从1号开始,签到日历应该如下

0 0 0 0 0 0 0

0 0 0 0 0 0 0

0 0 0 0 0 0 0

0 0 0 0 1 1 1

1 1 0

用户ID100,从1号开始,签到日历应该如下

0 0 0 0 0 0 0

0 0 0 0 0 0 0

0 0 0 0 0 0 0

0 0 0 0 1 1 0

1 1 0

代码示例

java 复制代码
@RestController
@RequestMapping(value = "/signIn")
public class SignTest {

    @Resource
    private RedisTemplate<String, String> redisTemplate;

    public final static String USER_DAY_SIGN_IN = "daySignIn:%s:%d";

    /**
     * 签到,可任意选择一天签到
     * <p>
     * 在此基础上可实现日签、补签功能。
     */
    @RequestMapping("/daySignIn")
    public void daySignIn(@RequestBody DaySignDTO daySignDTO) {
        String signKey = String.format(USER_DAY_SIGN_IN, String.valueOf(daySignDTO.getYear()) + daySignDTO.getMonth(), daySignDTO.getUserId());
        // 下标从0开始的
        signIn(signKey, daySignDTO.getDay() - 1);

    }

    /**
     * 查询指定年、月签到日历表
     */
    @RequestMapping("/querySignCalendar")
    public void querySignCalendar(@RequestBody DaySignDTO daySignDTO) {
        String signKey = String.format(USER_DAY_SIGN_IN, String.valueOf(daySignDTO.getYear()) + daySignDTO.getMonth(), daySignDTO.getUserId());
        LocalDate date = LocalDate.of(daySignDTO.getYear(), daySignDTO.getMonth(), 1);
        int lengthOfMonth = date.lengthOfMonth();
        System.out.println(getSignCalendarFromRedis(signKey, lengthOfMonth));
    }

    /**
     * 查询某个用户连续签到天数
     */
    @RequestMapping("/getContinuousSignInDays")
    public void getContinuousSignInDays(@RequestBody DaySignDTO daySignDTO) {
        String signKey = String.format(USER_DAY_SIGN_IN, String.valueOf(daySignDTO.getYear()) + daySignDTO.getMonth(), daySignDTO.getUserId());
        System.out.println("userId: " + daySignDTO.getUserId() + ",最近连续签到:" + getSignCountFromRedis(signKey, LocalDate.now().getDayOfMonth()) + "天");
    }


    private void signIn(String signKey, int lengthOfMonth) {
        Boolean suc = redisTemplate.opsForValue().setBit(signKey, lengthOfMonth, true);
        if (suc == null) {
            System.out.println("异常处理");
            return;
        }
        if (!suc) {
            System.out.println(signKey + "【" + (lengthOfMonth + 1) + "号,签到成功!】");
        } else {
            System.out.println(signKey + "【" + (lengthOfMonth + 1) + "号,重复签到!】");
        }
    }

    private String getSignCalendarFromRedis(String key, int dayOfMonth) {
        BitFieldSubCommands bitFieldSubCommands = BitFieldSubCommands
                .create()
                .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
                .valueAt(0);

        List<Long> list = redisTemplate.opsForValue().bitField(key, bitFieldSubCommands);
        if (list == null || list.isEmpty()) {
            return "";
        }
        long count = list.get(0) == null ? 0 : list.get(0);

        StringBuilder sb = new StringBuilder();
        sb.append(key).append(" 签到日历").append("\n");
        for (int i = dayOfMonth; i > 0; i--) {
            // --- 仅仅为了格式化,无业务含义 【开始】
            if (i < 10) {
                sb.append("0");
            }
            // --- 仅仅为了格式化,无业务含义 【结束】
            sb.append(i);
            if ((count & 1) == 1) {
                sb.append("签了");
            } else {
                sb.append("未签");
            }
            count >>= 1;
            // --- 仅仅为了格式化,无业务含义 【开始】
            sb.append("    ");
            if ((i - 1) % 7 == 0) {
                sb.append("\n");
            }
            // --- 仅仅为了格式化,无业务含义 【结束】
        }

        return sb.toString();

    }

    public int getSignCountFromRedis(String key, int dayOfMonth) {
        // 表示从下标0开始,一次性获取到dayOfMonth范围内的数据,比如当月有31天,那就等于下标的取值范围是【0~30】,因为bitmap的位数不会超过当月天数,所以,实际上就是把所有位上的值都统计出来。
        BitFieldSubCommands bitFieldSubCommands = BitFieldSubCommands
                .create()
                .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
                .valueAt(0);

        // redisTemplate提供的通过pipeline的方式,调用redis的BITFIELD key offset get命名,获取对应的值(转换成十进制)
        List<Long> list = redisTemplate.opsForValue().bitField(key, bitFieldSubCommands);
        if (list == null || list.isEmpty()) {
            return 0;
        }
        int signCount = 0;
        long count = list.get(0) == null ? 0 : list.get(0);

        for (int i = 0; i < dayOfMonth; i++) {
            // 如果(count & 1) == 1条件成立,则表示当前二进制串中,最后一位是1,业务上即表示【已签到】,否则就代表断签了
            if ((count & 1) == 1) {
                signCount++;
            } else {
                break;
            }
            // 移除最后一位,再继续检查当前最后一位是不是为1,以此循环遍历。
            count >>= 1;
        }
        return signCount;
    }

}

效果展示

java 复制代码
# ID为168的用户8月未签到
daySignIn:20238:168 签到日历
31未签    30未签    29未签    
28未签    27未签    26未签    25未签    24未签    23未签    22未签    
21未签    20未签    19未签    18未签    17未签    16未签    15未签    
14未签    13未签    12未签    11未签    10未签    09未签    08未签    
07未签    06未签    05未签    04未签    03未签    02未签    01未签    

# 30号,29号补签了2天
daySignIn:20238:168【30号,签到成功!】
daySignIn:20238:168【29号,签到成功!】

# 再次查看签到日历
daySignIn:20238:168 签到日历
31未签    30签了    29签了    
28未签    27未签    26未签    25未签    24未签    23未签    22未签    
21未签    20未签    19未签    18未签    17未签    16未签    15未签    
14未签    13未签    12未签    11未签    10未签    09未签    08未签    
07未签    06未签    05未签    04未签    03未签    02未签    01未签    

# 查看连续签到天数
userId: 168,最近连续签到:2天

# 27号又补签了
daySignIn:20238:168【27号,签到成功!】

# 再次查看签到日历
daySignIn:20238:168 签到日历
31未签    30签了    29签了    
28未签    27签了    26未签    25未签    24未签    23未签    22未签    
21未签    20未签    19未签    18未签    17未签    16未签    15未签    
14未签    13未签    12未签    11未签    10未签    09未签    08未签    
07未签    06未签    05未签    04未签    03未签    02未签    01未签    

# 由于28号未签,因此连续签到天数依然是2天
userId: 168,最近连续签到:2天

# 把28号补签上
daySignIn:20238:168【28号,签到成功!】

# 再次查看签到日历
daySignIn:20238:168 签到日历
31未签    30签了    29签了    
28签了    27签了    26未签    25未签    24未签    23未签    22未签    
21未签    20未签    19未签    18未签    17未签    16未签    15未签    
14未签    13未签    12未签    11未签    10未签    09未签    08未签    
07未签    06未签    05未签    04未签    03未签    02未签    01未签    

# 此时连续签到天数变成4天了
userId: 168,最近连续签到:4天

总结

Redis Bitmap是一种高效的数据存储和处理方式。通过位运算,可以快速查找到满足特定条件的记录,大大提高了处理效率。同时,Bitmap还可以节省大量的存储空间。Redis Bitmap被广泛应用于统计、计数、索引等场景。当然,使用Bitmap时也要注意内存使用问题,如果offset跨度较大,例如分别为:dayVisitCount:20230830 1 1 和dayVisitCount:20230830 10000000 1,虽然实际只用到了两个bit位,但实际上是会按照最大偏移量来分配内存占用量的,这里要考虑清楚;除此之外,如果有复杂查询的场景,也不适合使用Bitmap。

希望这篇文章能够帮助你更好地理解Redis Bitmap的原理和应用场景。

相关推荐
LCG元3 小时前
【面试问题】JIT 是什么?和 JVM 什么关系?
面试·职场和发展
向前看-3 小时前
验证码机制
前端·后端
岁月变迁呀4 小时前
Redis梳理
数据库·redis·缓存
超爱吃士力架5 小时前
邀请逻辑
java·linux·后端
Code apprenticeship6 小时前
怎么利用Redis实现延时队列?
数据库·redis·缓存
百度智能云技术站6 小时前
广告投放系统成本降低 70%+,基于 Redis 容量型数据库 PegaDB 的方案设计和业务实践
数据库·redis·oracle
装不满的克莱因瓶6 小时前
【Redis经典面试题六】Redis的持久化机制是怎样的?
java·数据库·redis·持久化·aof·rdb
AskHarries7 小时前
Spring Cloud OpenFeign快速入门demo
spring boot·后端
GISer_Jing7 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试
m0_748245527 小时前
吉利前端、AI面试
前端·面试·职场和发展