通过redis的bitmap实现签到

实现思路

我们针对签到功能完全可以通过mysql来完成。

 CREATE TABLE `sign_record` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_id` bigint NOT NULL COMMENT '用户id',
  `year` year NOT NULL COMMENT '签到年份',
  `month` tinyint NOT NULL COMMENT '签到月份',
  `date` date NOT NULL COMMENT '签到日期',
  `is_backup` bit(1) NOT NULL COMMENT '是否补签',
  PRIMARY KEY (`id`),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='签到记录表';

但是,假如一个用户1年签到100次,而网站有100万用户,就会产生1亿条记录。随着用户量增多、时间的推移,这张表中的数据只会越来越多,占用的空间也会越来越大。有没有什么办法能够减少签到的数据记录,减少空间占用呢?

其实可以考虑小时候一个挺常见的方案,就是小时候,咱们准备一张小小的卡片,你只要签到就打上一个勾,我最后判断你是否签到,其实只需要到小卡片上看一看就知道了

如果我们能用程序来模拟这样的签到卡,用一行数据去记录一个用户一个月的签到情况,可想而知,那比这种数据库的方式是不是要大大地节省空间。

我们按月来统计用户签到信息,签到记录为1,未签到则记录为0。把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为位图(BitMap)。这样我们就用极小的空间,来实现了大量数据的表示

BitMap用法

Redis中就提供了BitMap这种结构以及一些相关的操作命令。https://redis.io/commands/?group=bitmap

  • offset:要修改第几个bit位的数据
  • value:0或1

如果要签到就可以利用上面的这个命令,例如这个月的第1、2、3、6、7、8几天签到了,就可以这样:

# 第1天签到
SETBIT bm 0 1
# 第2天签到
SETBIT bm 1 1
# 第3天签到
SETBIT bm 2 1

我们在redis控制台试一下:

通过redis的bitmap实现签到_第1张图片

通过redis的bitmap实现签到_第2张图片

如果我们要查询签到记录怎么办?

那就是要读取BitMap中的数据,可以用这个命令:这个命令比较复杂,是一个组合命令,可以实现查询、修改等多种操作。不过我们只关心读取,所以只看第一种操作,GET即可:

BITFIELD key GET encoding offset
  • key:就是BitMap的key
  • GET:代表查询
  • encoding:返回结果的编码方式,BitMap中是二进制保存,而返回结果会转为10进制,但需要一个转换规则,也就是这里的编码方式
    • u:无符号整数,例如 u2,代表读2个bit位,转为无符号整数返回
    • i:又符号整数,例如 i2,代表读2个bit位,转为有符号整数返回
  • offset:从第几个bit位开始读取,例如0:代表从第一个bit位开始

例如,我想查询从第1天到第3天的签到记录,可以这样:

BITFIELD bm GET u3 0

可以看到返回结果:

通过redis的bitmap实现签到_第3张图片

返回的结果是7. 为什么是7呢?

签到记录是 11100111,从0开始,取3个bit位,刚好是111,转无符号整数,刚好是7。

注意:二进制转化为十进制的计算方法为
1、无符号整数,从右往左依次用二进制位上的数字乘以2的n次幂的和(n大于等于0)

2、带符号的二进制整数,除去最高位的符号位(1为负数,0为正数),其余与无符号二进制转化为十进制方法相同:

例如:111(二进制) 转十进制
无符号转换规则:(12的2次方 + 12的1次方 + 1*2的0次方) = 7

**有符号转换归位。先看最高为是否为1,如果是1,代表是负数,其他位取反加一,**然后用常规方式转十进制(十进制添负号)。如果最高位为0,就直接常规方式转十进制。所以111 (二进制) 转为有符号为: -1

签到接口

我们计划每个月为每个用户生成一个独立的KEY,因此KEY中必须包含用户信息、月份信息,长这样:

sign:uid:xxx:202401

由KEY的结构可知,要签到,就必须知道是哪一天签到,也就是两个信息:

  • 当前用户
  • 当前时间

这两个信息我们都可以自己获取,因此签到时,前端无需传递任何参数。那么签到以后是否需要返回数据呢?

需求中说连续签到会有积分奖励,那么为了提升用户体验,在用户签到成功以后是不是应该返回连续签到天数和获取的积分奖励呢。

实体类

关于签到功能的返回值,我认为实体类我们只需要这三个字段:

  • signDays:连续签到天数
  • signPoints:签到得分,固定为1
  • rewardPoints:连续签到的奖励积分

因为我们不是在mysql进行签到实现的,我们是在redis实现的。所以我们只需要一个返回前端的一个类就行。

连续签到统计

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

Java逻辑代码:获得当前这个月的最后一次签到数据,定义一个计数器,然后不停的向前统计,直到获得第一个非0的数字即可,每得到一个非0的数字计数器+1,直到遍历完所有的数据,就可以获得当前月的签到总天数了

那么我们该如何从后向前遍历每个bit位?

注意:bitMap返回的数据是10进制,假如说redis返回一个数字8,那么我哪儿知道到底哪些是0,哪些是1呢?我们只需要让得到的10进制数字和1做与运算就可以了,因为1只有遇见1 才是1,其他数字都是0 ,我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,依次内推,我们就能完成逐个遍历的效果了。

项目的具体实现

新建一个springboot项目:

application.yaml

spring:
  redis:
    database: 3
    host: localhost
    port: 6379

domain:

package com.zd.domain;

import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;

@Data
public class Result {

//    连续签到天数
    private Integer signDays;
//    签到得分
    private Integer signPoints = 1;
//    连续签到奖励积分,连续签到超过7天以上才有奖励
    private Integer rewardPoints;
}

UserController:

package com.zd.controller;
import com.zd.domain.Result;
import com.zd.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserController {
    @Autowired
    private UserService userService;
    @PostMapping("/sign")
    public Result sign(){
        return userService.sign();
    }
}

UserService:

package com.zd.service;
import com.zd.domain.Result;
public interface UserService {
    //签到
    Result sign();
}

UserServiceImpl:

package com.zd.service.impl;

import com.zd.domain.Result;
import com.zd.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.BitFieldSubCommands;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;

@Service
public class UserServiceImpl implements UserService {
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Override
    public Result sign() {
        //todo 此处应该先获取用户id
        //拼接key
        LocalDate now =LocalDate.now();//得到当前的年月
        String format = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));//得到冒号年月的字符串
        String key= "sign"+format;
        //利用bitset命令,将签到记录保存到redis的bitmap结构中,需要校验是否已经签到
        int offset=now.getDayOfMonth()-1;//当前天数-1
        Boolean setBit = redisTemplate.opsForValue().setBit(key, offset, true);//最后一个参数是 是否签到
        if(setBit){
            throw new RuntimeException("不能重复签到");
        }
        //计算连续签到的天数
        int days =countSignDays(key,now.getDayOfMonth());
        //计算连续签到的奖励积分
        int rewardPoints=0;//代表连续签到 奖励积分
        switch(days){
            case 7:
                rewardPoints=10;
                break;
            case 14:
                rewardPoints=20;
                break;
            case 28:
                rewardPoints=40;
                break;
        }
        //开始想用if的,觉得太过于繁琐,换为switch
//        if(days==7){
//            rewardPoints=10;
//        }else if(days==14){
//            rewardPoints=20;
//        }else if(days==28){
//            rewardPoints=40;
//        }
        //todo 保存积分
        //封装vo返回
        Result vo=new Result();
        vo.setSignDays(days);
        vo.setRewardPoints(rewardPoints);
        return vo;
    }
    /**
     * 计算签到连续多少天
     * @param key
     * @param dayOfMonth 本月到今天的天数
     * @return
     */
    private int countSignDays(String key, int dayOfMonth) {
        //求本月第一天到当前天所有的签到数据
        List<Long> bitField = redisTemplate.opsForValue().bitField(key,
                BitFieldSubCommands.create().
                        get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));
        if(bitField.isEmpty()){
            return 0;
        }
        Long num = bitField.get(0);//本月第一天到当前天的签到数据
        //num转二进制,从后向前推有几个1
        int counter = 0;//计数器
        while((num&1)==1){//开始进行遍历,与1做与运算并且右移一位
            counter++;
            num=num>>>1;
        }
        return counter;
    }
}

启动项目,打开postman,访问http://localhost:8080/sign

通过redis的bitmap实现签到_第4张图片

收到了返回值,再看看redis里面有没有数据:

通过redis的bitmap实现签到_第5张图片

redis是有数据的。那再点一次试试会不会抛异常?

通过redis的bitmap实现签到_第6张图片

关于连续签到功能,我修改了一下连续签到的日期数据,结果也是正确的。

到此,我们成功完成了redis实现签到功能。

bitmap的扩展内容

Redis最基础的数据类型只有5种:String、List、Set、SortedSet、Hash,其它特殊数据结构大多都是基于以上5这种数据类型。

BitMap也不例外,它是基于String结构的。因为Redis的String类型底层是SDS,也会存在一个字节数组用来保存数据。而Redis就提供了几个按位操作这个数组中数据的命令,实现了BitMap效果。

由于String类型的最大空间是512MB,也就是2的31次幂个bit,因此可以保存的数据量级是十分恐怖的。

想要了解Redis的SDS结构,可以参考下面视频:https://www.bilibili.com/video/BV1cr4y1671t?p=146&vd_source=1ff0c1b434581723cf696ccc2f59ceaa

bitmap操作命令:

  • SETBIT:向指定位置(offset)存入一个0或1
  • GETBIT :获取指定位置(offset)的bit值
  • BITCOUNT :统计BitMap中值为1的bit位的数量
  • BITFIELD :操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
  • BITFIELD_RO :获取BitMap中bit数组,并以十进制形式返回
  • BITOP :将多个BitMap的结果做位运算(与 、或、异或)
  • BITPOS :查找bit数组中指定范围内第一个0或1出现的位置

参考文档

基于redis的bitmap实现签到功能(后端)_redis 连续签到-CSDN博客

https://redis.io/commands/?group=bitmap

你可能感兴趣的:(redis,数据库,缓存)