小蔡学Java

项目一总结:(七)用户签到与MQ的topic方式实现不同渠道的积分变动

2024-01-22 20:34 1065 0 项目 RedisRedis优化Redis数据结构BitMap

背景

在我的项目一:Online学习平台中,有这么一个场景;积分的获取,积分的高低又形成了排行榜。

用户获取积分的途径有5种:

  • 签到:在个人积分页面可以每日签到,每次签到得1分,连续签到有额外积分奖励。
  • 学习:也就是看视频
  • 写回答:就是给其他学员提问的问题回答,给回答做评论是没有积分的。
  • 写笔记:就是学习的过程中记录公开的学习笔记,分享给所有人看。或者你的笔记被人点赞。
  • 写评价:对你学习过的课程评价,可以获取积分。但课程只能评价一次。

我们重点要说的就是签到

分析

使用数据库记录签到记录

我们设计出如下表

缺点

占用空间大:

  • 这张表中的一条记录是一个用户一次的签到记录。假如一个用户1年签到100次,而网站有100万用户,就会产生1亿条记录。
  • 随着用户量增多、时间的推移,这张表中的数据只会越来越多,占用的空间也会越来越大。
  • 计算一下大小 id 8个字节,user_id 8个字节,year 1个字节,month 1个字节,date 3个字节 , is_backup 1 个字节 ------> (8+8+1+1+3+1 = 22 Byte)

22Byte*10^8/1024/1024 = 2098MB (其实也不是很大哈哈哈)

如何解决

使用Redis的BitMap结构

如果有对这个不熟悉的小伙伴可以看我这篇文章哦:Redis之BitMap的使用场景

我们从问题本质出发,其实签到就两种情况:签到未签到 ; 没错,就是 0 或者1 如果我们按月来统计用户签到信息,签到记录为1未签到则记录为0,就可以用一个长度为31位二级制数组来表示一个用户一个月的签到情况。最终效果如下:

内存分析

一个月最多也就 31 天,因此一个月的签到记录最多也就使用 31 bit 就能保存了,还不到 4 个字节

而如果用到我们前面讲的数据库方式来保存相同数据,则要使用数百字节,是这种方式的上百倍都不止。

使用这种方式是非常高效的

使用

修改某个bit位上的数据:

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

怎么签到

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

# 第1天签到
SETBIT bm 0 1
# 第2天签到
SETBIT bm 1 1
# 第3天签到
SETBIT bm 2 1
# 第6天签到
SETBIT bm 5 1
# 第7天签到
SETBIT bm 6 1
# 第8天签到
SETBIT bm 7 1

查询签到记录怎么办?

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天的签到记录,可以这样:

可以看到,返回的结果是7. 为什么是7呢? 签到记录是 11100111, 从0开始,取3个bit位 ,刚好是111,转无符号整数,刚好是7

代码实现

签到接口:

@Override
    public SignResultVO addSignRecords() {
        // 1.签到
        // 1.1.获取登录用户
        Long userId = UserContext.getUser();
        // 1.2.获取日期
        LocalDate now = LocalDate.now();
        // 1.3.拼接key
        String key = RedisConstants.SIGN_RECORD_KEY_PREFIX
                + userId
                + now.format(DateUtils.SIGN_DATE_SUFFIX_FORMATTER);
        // 1.4.计算offset
        int offset = now.getDayOfMonth() - 1;
        // 1.5.保存签到信息
        Boolean exists = redisTemplate.opsForValue().setBit(key, offset, true);
        if (BooleanUtils.isTrue(exists)) {
            throw new BizIllegalException("不允许重复签到!");
        }
        // 2.计算连续签到天数
        int signDays = countSignDays(key, now.getDayOfMonth());
        // 3.计算签到得分
        int rewardPoints = 0;
        switch (signDays) {
            case 7:
                rewardPoints = 10;
                break;
            case 14:
                rewardPoints = 20;
                break;
            case 28:
                rewardPoints = 40;
                break;
        }
        // 4.保存积分明细记录
        mqHelper.send(
                MqConstants.Exchange.LEARNING_EXCHANGE,
                MqConstants.Key.SIGN_IN,
                SignInMessage.of(userId, rewardPoints + 1));
        // 5.封装返回
        SignResultVO vo = new SignResultVO();
        vo.setSignDays(signDays);
        vo.setRewardPoints(rewardPoints);
        return vo;
    }

监听由于签到而导致的积分变动信息:

@RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "sign.points.queue", durable = "true"),
            exchange = @Exchange(name = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
            key = MqConstants.Key.SIGN_IN
    ))
    public void listenSignInMessage(SignInMessage message){
        recordService.addPointsRecord(message.getUserId(), message.getPoints(), PointsRecordType.SIGN);
    }

更新积分:

@Override
    public void addPointsRecord(Long userId, int points, PointsRecordType type) {
        LocalDateTime now = LocalDateTime.now();
        int maxPoints = type.getMaxPoints();
        // 1.判断当前方式有没有积分上限
        int realPoints = points;
        if(maxPoints > 0) {
            // 2.有,则需要判断是否超过上限
            LocalDateTime begin = DateUtils.getDayStartTime(now);
            LocalDateTime end = DateUtils.getDayEndTime(now);
            // 2.1.查询今日已得积分
            int currentPoints = queryUserPointsByTypeAndDate(userId, type, begin, end);
            // 2.2.判断是否超过上限
            if(currentPoints >= maxPoints) {
                // 2.3.超过,直接结束
                return;
            }
            // 2.4.没超过,保存积分记录
            if(currentPoints + points > maxPoints){
                realPoints = maxPoints - currentPoints;
            }
        }
        // 3.没有,直接保存积分记录
        PointsRecord p = new PointsRecord();
        p.setPoints(realPoints);
        p.setUserId(userId);
        p.setType(type);
        save(p);
        // 4.更新总积分到Redis
        String key = RedisConstants.POINTS_BOARD_KEY_PREFIX + now.format(DateUtils.POINTS_BOARD_SUFFIX_FORMATTER);
        redisTemplate.opsForZSet().incrementScore(key, userId.toString(), realPoints);
    }

MQ的topic方式实现不同渠道的积分变动

前面说到用户获取积分的途径有5种:签到、看视频、写回答、写评论、写笔记

那么我是如何根据不同的方式,增加对应的积分记录呢?答案是RabbitMQ的topic方式

首先实现不同方式的routingKey

以写回答为例子:当评论或者回答入库后我们就发送积分记录消息

分别实现各自渠道的消息监听器,进而添加积分记录

@Component
@RequiredArgsConstructor
public class LearningPointsListener {

    private final IPointsRecordService recordService;


    /**
     * 用户点赞而导致的积分变更
     */
    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "qa.points.queue", durable = "true"),
            exchange = @Exchange(name = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
            key = MqConstants.Key.WRITE_REPLY
    ))
    public void listenWriteReplyMessage(Long userId){
        recordService.addPointsRecord(userId, 5, PointsRecordType.QA);
    }


    /**
     * 用户签到而导致的积分变更
     */
    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "sign.points.queue", durable = "true"),
            exchange = @Exchange(name = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
            key = MqConstants.Key.SIGN_IN
    ))
    public void listenSignInMessage(SignInMessage message){
        recordService.addPointsRecord(message.getUserId(), message.getPoints(), PointsRecordType.SIGN);
    }

    /**
     * 用户看视频而导致的积分变更
     */

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "learning.points.queue", durable = "true"),
            exchange = @Exchange(name = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
            key = MqConstants.Key.LEARN_SECTION
    ))
    public void listenLearnSectionMessage(Long userId){
        recordService.addPointsRecord(userId, 10, PointsRecordType.LEARNING);
    }


    /**
     * 用户写笔记而导致的积分变更
     */
    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "note.new.points.queue", durable = "true"),
            exchange = @Exchange(name = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
            key = MqConstants.Key.WRITE_NOTE
    ))
    public void listenWriteNodeMessage(Long userId){
        recordService.addPointsRecord(userId, 3, PointsRecordType.NOTE);
    }


    /**
     * 用户笔记被采集而导致的积分变更
     */
    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "note.gathered.points.queue", durable = "true"),
            exchange = @Exchange(name = MqConstants.Exchange.LEARNING_EXCHANGE, type = ExchangeTypes.TOPIC),
            key = MqConstants.Key.NOTE_GATHERED
    ))
    public void listenNodeGatheredMessage(Long userId){
        recordService.addPointsRecord(userId, 2, PointsRecordType.NOTE);
    }
}

总结:

模拟面试:

你项目中使用过Redis的那些数据结构啊?能不能具体说说使用的场景?

答:比如很多的缓存,我们就使用了String结构来存储。还有点赞功能,我们用了Set结构和SortedSet结构。签到功能,我们用了BitMap结构。
就拿签到来说吧。因为签到数据量非常大嘛,而BitMap则是用bit位来表示签到数据,31bit位就能表示1个月的签到记录,非常节省空间,而且查询效率也比较高。

你使用Redis保存签到记录,那如果Redis宕机怎么办?

对于Redis的高可用数据安全问题,有很多种方案。
比如:我们可以给Redis添加数据持久化机制,比如使用AOF持久化。这样宕机后也丢失的数据量不多,可以接受。
或者呢,我们可以搭建Redis主从集群,再结合Redis哨兵。主节点会把数据持续的同步给从节点,宕机后也会有哨兵重新选主,基本不用担心数据丢失问题。
当然,如果对于数据的安全性要求非常高。肯定还是要用传统数据库来实现的。但是为了解决签到数据量较大的问题,我们可能就需要对数据做分表处理了。或者及时将历史数据存档。

总的来说,签到数据使用Redis的BitMap无论是安全性还是数据内存占用情况,都是可以接受的。但是具体是选择Redis还是数据库方案,最终还是要看公司的要求来选择。

评论( 0 )

  • 博主 Mr Cai
  • 坐标 河南 信阳
  • 标签 Java、SpringBoot、消息中间件、Web、Code爱好者

文章目录