侧边栏壁纸
博主头像
cappuccino博主等级

路漫漫其修远兮,吾将上下而求索。

  • 累计撰写 38 篇文章
  • 累计创建 19 个标签
  • 累计收到 44 条评论

目 录CONTENT

文章目录

Redis应用之用户签到 (BitMaps类型)

cappuccino
2022-05-22 / 0 评论 / 1 点赞 / 422 阅读 / 2,283 字

1. 需求分析

在很多互联网应用中,我们会存在签到送积分、签到领取奖励等这样的需求,比如:

  • 签到1天送10积分,连续签到2天送20积分,3天送30积分,4天以上均送50积分等。
  • 如果连续签到中断,则重置计数,每月初重置计数。
  • 显示用户某个月的签到次数。
  • 在日历控件上展示用户每月签到情况,可以切换年月显示。

2. 设计思路

2.1. 数据库解决

最简单的设计思路就是利用关系型数据库保存签到数据 (t_user_sign) ,如下:

字段名 描述
id 数据表主键 (AUTO_INCREMENT)
fk_diner_id 用户ID
sign_date 签到日期 (如2010-11-11)
amount 连续签到天数 (如2)
  • 用户签到:往此表插入一条数据,并更新连续签到天数;
  • 查询根据签到日期查询
  • 统计根据amount统计

如果这样存数据的话,对于用户量比如较大的应用,数据库可能就扛不住,比如1000W用户,一天一条,那么一个月就是3亿数据,这是非常庞大的。

那怎么优化呢?
做了以下两种数据库存储对比:

数据库:

id 、 dinerid、 分别16byte date 16byte amount 5byte = 47byte 这是一个人一天的签到数据

47B * 一千万 = 47千万B / 1024 / 1024 = 448兆 MB,这是一千万用户一天的签到数据

448MB * 30 = 13440MB / 1024 = 13GB,这是一千万签到狂魔一个月的签到数据

13GB * 12 = 156G 数据,这是一千万签到狂魔一年的签到数据

156G * 5 = 780G 数据,5 年下来一千万签到狂魔一共产生 780G 数据。

Redis bitmap:

假设一个月31天都存满了也就 31bit 位,31 / 8 = 3B (byte) 这是一个人一月的签到数据

3B * 一千万 = 3 千万 B / 1024 / 1024 = 28兆 MB,这是一千万签到狂魔一月的签到数据

28MB * 365 = 10220MB / 1024 = 9G 数据,这是一千万签到狂魔一年的签到数据

9G * 5 = 45G 数据,5 年下来一千万签到狂魔一共产生 45G 数据。

2.2. 使用Redis的BitMaps完成

Bitmaps叫位图,它不是Redis的基本数据类型 (比如Strings、Lists、Sets、Hashes这类实际的数据类型),而是基于String数据类型的按位操作,高阶数据类型的一种。Bitmaps支持的最大位数是232位。使用512M内存就可以存储多达42.9亿的字节信息(232 = 4,294,967,296) ps: 计算器=》科学 2 xy 32

​ 它是由一组bit位组成的,每个bit位对应0和1两个状态,虽然内部还是采用String类型存储,但Redis提供了一些指令用于直接操作位图,可以把它看作是一个bit数组,数组的下标就是偏移量。它的优点是内存开销小、效率高且操作简单,很适合用于签到这类场景。比如按月进行存储,一个月最多31天,那么我们将该月用户的签到缓存二进制就是

00000000000000000000000000000000,当某天签到将0改成1即可,而且Redis提供对bitmap的很多操作比如存储、获取、统计等指令,使用起来非常方便。

3. BitMaps常用指令

命令 功能 参数
SETBIT 指定偏移量bit位置设置值 key offset value 【0=< offset<2^32】
GETBIT 查询指定偏移量位置的bit值 key offset
BITCOUNT 统计指定字节区间bit为1的数量 key [start end] 【@LBN】
BITFIELE 操作多字节位域 key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WPAP/SAT/FAIL]
BITPOS 查询指定字节区间第一个被设置成1的bit位的位置 key bit [start] [end] 【@LBN】

考虑到每月初需要重置连续签到次数,最简单的方式是按用户每月存一条签到数据(也可以每年存一条数据)。Key的格式为

user:sign:userid:yyyyMM,Value则采用长度为4个字节(32位)的位图(最大月份只有31天)。位图的每一位代表一天的签到,1表示已签到,0表示未签到。从高位插入,也就是说左边位算是开始日期。

例如:user:sign:98:202204 表示用户id=98的用户在2022年4月的签到记录。那么

# 2022年4月10号签到
127.0.1.1:0>SETBIT user:sign:98:202204 9 1

4. 功能开发

4.1. 用户签到,可以补签

4.1.1. 需求说明

用户签到,默认是当天,但可以通过传入日期补签,返回用户连续签到次数(后续如果有几分规则,就会返回用户此次签到积分)

4.1.2. 代码实现

SignService层签到方法

  • 获取登录用户信息
  • 根据日期获取当前是多少号(使用BITSET 指令签到时,offset从0开始计算,0就代表1号)
  • 构建用户按月存储key(user:sign:用户id:月份)
  • 判断用户是否签到(GETBIT指令)
  • 用户签到(SETBIT)
  • 返回用户连续签到次数(BITFIELD key GET [u/i] type offset value,获取从用户当前日期开始到1号的所有签到状态,然后进行位移从操作,获取连续签到天数)

4.2. 统计用户签到次数

用户需求:统计某月签到次数,默认是当月

4.2.1. SignService 方法统计
/**
 * 获取用户签到次数
 *
 * @param accessToken 登录token
 * @param dateStr     查询日期,默认当月 yyyy-MM-dd
 * @return 当前的签到次数
 */
public long getSignCount(String accessToken, String dateStr) {
    // 获取登录用户信息
    SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
    // 获取日期
    Date date = getDate(dateStr);
    // 构建 Key
    String signKey = buildSignKey(dinerInfo.getId(), date);
    // e.g. BITCOUNT user:sign:5:202011
    return (Long) redisTemplate.execute(
            (RedisCallback<Long>) con -> con.bitCount(signKey.getBytes())
    );
}

4.3. 获取用户签到情况

获取用户某月签到情况,默认当月,返回当前月的所有日期以及该日期的签到情况。

4.3.1. SignService 方法

获月某月签到情况,默认当月

  • 获取登录用户信息
  • 构建Redis保存的Key
  • 获取月分的总天数(考虑2月闰、平年)
  • 通过BITFIELD指令获取当前月的所有签到数据
  • 遍历进行判断是否签到,并存入TreeMap方便排序
/**
 * 获取当月签到情况
 *
 * @param accessToken 登录token
 * @param dateStr     查询日期,默认当月 yyyy-MM-dd
 * @return Key为签到日期,Value为签到状态的Map
 */
public Map<String, Boolean> getSignInfo(String accessToken, String dateStr) {
    // 获取登录用户信息
    SignInDinerInfo dinerInfo = loadSignInDinerInfo(accessToken);
    // 获取日期
    Date date = getDate(dateStr);
    // 构建 Key
    String signKey = buildSignKey(dinerInfo.getId(), date);
    // 构建一个自动排序的 Map
    Map<String, Boolean> signInfo = new TreeMap<>();
    // 获取某月的总天数(考虑闰年)
    int dayOfMonth = DateUtil.lengthOfMonth(DateUtil.month(date) + 1,
            DateUtil.isLeapYear(DateUtil.dayOfYear(date)));
    // bitfield user:sign:5:202011 u30 0
    BitFieldSubCommands bitFieldSubCommands = BitFieldSubCommands.create()
            .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
            .valueAt(0);
    List<Long> list = redisTemplate.opsForValue().bitField(signKey, bitFieldSubCommands);
    if (list == null || list.isEmpty()) {
        return signInfo;
    }
    long v = list.get(0) == null ? 0 : list.get(0);
    // 从低位到高位进行遍历,为 0 表示未签到,为 1 表示已签到
    for (int i = dayOfMonth; i > 0; i--) {
        /*
            签到:  yyyy-MM-01 true
            未签到:yyyy-MM-01 false
         */
        LocalDateTime localDateTime = LocalDateTimeUtil.of(date).withDayOfMonth(i);
        boolean flag = v >> 1 << 1 != v;
        signInfo.put(DateUtil.format(localDateTime, "yyyy-MM-dd"), flag);
        v >>= 1;
    }
    return signInfo;
}

5 BitMaps总结

  • BitMaps最大长度位数是多少?

    由于String数据类型的最大长度是512M,所以String支持的位数是2^32位。512M表示字节Byte长度,换算成Bit需要乘以8,即512 * 2^10 * 2^10 * 8 = 2^32 (4.29亿的字节信息;4,294,967,296);

  • BitMaps可以支持超过512M的数据吗?

    Strings的最大长度是512M,还能存跟更大的数据吗?当然不能,但是我们可以换一种实现思路,就是将大key换成小key,这样存储的大小完全不受限制。

  • 排查Redis 大Key

    rdb_bigkeys,这是用go写的一款工具,分析rdb文件,找出文件中的大key,实测发现,不管执行时间还是准确度都是很高的,一个3G左右的rdb文件,执行完大概两三分钟,直接导出到csv文件,方便查看,比较推荐使用该工具去查找大key。

    ./rdb_bigkeys --bytes 1024 --file bigkeys.csv --sep 0 --sorted --threads 4 /home/redis/dump.rdb

  • bitmap实质上是采取时间换空间的操作

Redis项目Github

学习来自

1

评论区