[中间件]Redis 实现用户签到功能

905 阅读2分钟

签到功能对应的逻辑很常见,主要有以下几种场景

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

最简单的就是使用数据库保存,假设数据签到表设计如下

create table user_sign(
  id int primary key AUTO_INCREMENT,
  user_id int not null comment '用户id',
  sign_date datetime not null comment '签到日期',
  amount int not null comment '连续签到次数'
);

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

使用bitmap

就是通过一个bit位来表示某个元素对应的值或者状态,其中的key就是对应元素本身。我们知道8个bit可以组成一个Byte,所以bitmap本身会极大的节省储存空间。

Redis从2.2.0版本开始新增了setbit,getbit,bitcount等几个bitmap相关命令。虽然是新命令,但是并没有新增新的数据类型,因为setbit等命令只不过是在set上的扩展。

内存开销小、效率高且操作简单,很适合用于签到这类场景。比如按月进行存储,一个月最多 31 天,那么我们将该月用户的签到缓存二进制就是 00000000000000000000000000000000,当某天签到将 0 改成 1 即可,而且 Redis 提供对 bitmap 的很多操作比如存储、获取、统计等指令,使用起来非常方便。

常用命令

命令功能参数示例
setbit指定偏移量 bit 位置设置值key offset value【0=< offset< 2^32】setbit user:sign:202105 0 1
getbit查询指定偏移位置的 bit 值key offsetgetbit user:sign:202105 0
bitcount统计指定字节区间 bit 为 1 的数量key [start end]bitcount user:sign:202105 0 31
bitfield操作多字节位域key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WRAP/SAT/FAIL]user:sign:202105 get u31 0

设置签到

假设需要设置2021年5月份的签到数据

setbit user:sign:202105 0 1 # key: user:sign:202105, 0 代表1号,1 代表已签到
setbit user:sign:202105 1 1
setbit user:sign:202105 2 1
# 第三天断签了
setbit user:sign:202105 4 1
setbit user:sign:202105 5 1

Redis中数据为

11101100

统计这个月签到天数

bitcount user:sign:202105 0 31

范围指定从0-31,一个月最多就31天

连续签到次数

bitfield user:sign:202105 get u31 0 # 1979711488

user:sign:202105这个key 中获取31位,u代表无符号,1979711488代表获取到的十进制数字

位运算判断是否签到

假设获取到连续31天签到次数为 : 1979711488,如何判断某天是否已经签到

1979711488的二进制为

1110110000000000000000000000000
# 先右移一位
0111011000000000000000000000000
# 再左移一位, 和原数字一样
1110110000000000000000000000000

# 假设当天最后一天签到了,则最后一位会是1
1110110000000000000000000000001
# 先右移一位
0111011000000000000000000000000
# 再左移一位,和原数字不一样
1110110000000000000000000000000

所以,如果右移再左移等于之前的数字,则代表没有签到。如果右移再左移不等于之前的数字,则代表已经签到了。

按照上面逻辑,可以计算用户这个月连续签到的次数

Java代码实现计算用户这个月连续签到的次数

private int continuousSignIn(long userId, LocalDate date) {
    // 统计连续签到次数, 假设今天是31号
    int dayOfMonth = date.getDayOfMonth();
    ValueOperations<String, Object> opsForValue = redisTemplate.opsForValue();
    // bitfield user:sign:202105 get u31 0
    BitFieldSubCommands bitFieldSubCommands = BitFieldSubCommands.create()
        .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
        .valueAt(0);
    String signKey = "user:sign:202105";
    List<Long> bitFields = opsForValue.bitField(signKey, bitFieldSubCommands);
    if (CollectionUtils.isEmpty(bitFields)) {
        return 0;
    }
    AtomicInteger continuousSignInCount = new AtomicInteger();
    bitFields.stream()
        .findFirst()
        .ifPresent(v -> {
            // i: 操作多少次位移, 今天是多少号,则有多少次
            for (int i = dayOfMonth; i > 0; i--) {
                // 右移再左移等于自己, 代表移动的是0,表示未签到。也有可能今天是第一天签到,也要排除
                if ((v >> 1 << 1 == v) && (i != dayOfMonth)) {
                    // 低位是0且非当天签到
                    break;
                } else {
                    continuousSignInCount.getAndIncrement();
                }
                // 右移一位,开始前一天的判断
                v >>= 1;
            }
        });
    return continuousSignInCount.get();
}