天机学堂 第6天 点赞逻辑

首先我们来分析整理一下点赞业务的需求,一个通用点赞系统需要满足下列特性:

  • 通用:点赞业务在设计的时候不要与业务系统耦合,必须同时支持不同业务的点赞功能

  • 独立:点赞功能是独立系统,并且不依赖其它服务。这样才具备可迁移性。(可以发送消息队列通知其他微服务)

  • 并发:一些热点业务点赞会很多,所以点赞功能必须支持高并发

  • 安全:要做好并发安全控制,避免重复点赞

要保证安全,避免重复点赞,我们就必须保存每一次点赞记录。只有这样在下次用户点赞时我们才能查询数据,判断是否是重复点赞。同时,因为业务方经常需要根据点赞数量排序,因此每个业务的点赞数量也需要记录下来。

综上,点赞的基本思路如下:

如果业务方需要根据点赞数排序,就必须在数据库中维护点赞数字段(比如评论里面回复的点赞)。但是点赞系统无法修改其它业务服务的数据库,否则就出现了业务耦合。该怎么办呢?

学习评论的微服务

点赞业务本质

点赞记录本质就是记录谁给什么内容点了赞,所以核心属性包括:

  • 点赞目标id

  • 点赞人id(前端不用提交,后端直接判断)

不过点赞的内容多种多样,为了加以区分,我们还需要把点赞内的类型记录下来:

  • 点赞对象类型(为了通用性)

CREATE TABLE IF NOT EXISTS `liked_record` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键id',
  `user_id` bigint NOT NULL COMMENT '用户id',
  `biz_id` bigint NOT NULL COMMENT '点赞的业务id',
  `biz_type` VARCHAR(16) NOT NULL COMMENT '点赞的业务类型',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_biz_user` (`biz_id`,`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='点赞记录表';

点赞或取消点赞

当用户点击点赞按钮的时候,第一次点击是点赞,按钮会高亮;第二次点击是取消,点赞按钮变灰:点赞就是新增一条点赞记录,取消就是删除这条记录

从后台实现来看,点赞就是新增一条点赞记录,取消就是删除这条记录。为了方便前端交互,这两个合并为一个接口即可。

因此,请求参数首先要包含点赞有关的数据,并且要标记是点赞还是取消:

  • 点赞的目标业务id:bizId

  • 谁在点赞(就是登陆用户,可以不用提交)

  • 点赞还是取消

除此以外,我们之前说过,在问答、笔记等功能中都会出现点赞功能,所以点赞必须具备通用性。因此还需要在提交一个参数标记点赞的类型:

  • 点赞目标的类型

返回值有两种设计:

  • 方案一:无返回值,200就是成功,页面直接把点赞数+1展示给用户即可

  • 方案二:返回点赞数量,页面渲染

这里推荐使用方案一,因为每次统计点赞数量也有很大的性能消耗。

 

 逻辑梳理

我们先梳理一下点赞业务的几点需求:

  • 点赞就新增一条点赞记录,取消点赞就删除记录

  • 用户不能重复点赞

  • 点赞数由具体的业务方保存,需要通知业务方更新点赞数

思路: 首先实现判断点赞或者取消赞是否成功(因为如果已经点过赞了(数据库存在点赞记录)再次点赞机会失败,取消赞同理,)成功了才取统计点赞业务的数量,然后发送mq取更新

@Override
    @Transactional
    public void addLikeRecord(LikeRecordFormDTO recordDTO) {
        // 获取当前登录用户id
        Long userId = UserContext.getUser();
        // 点赞取消赞业务是否失败,失败了就不用统计点赞数量
        Boolean flag = false;
        //判断是否点赞
        if (recordDTO.getLiked()) {
            flag = liked(recordDTO, userId);
        } else {
            //取消赞逻辑,有点赞记录直接删除,没有什么也不做
            flag = cancelLiked(recordDTO, userId);
        }
        // 统计该业务的总点赞数
        if (flag) {
            Integer count = this.lambdaQuery()
                    .eq(LikedRecord::getBizId, recordDTO.getBizId())
                    .count();
            // 发送消息给mq
            rabbitMqHelper.send(
                    MqConstants.Exchange.LIKE_RECORD_EXCHANGE,
                    MqConstants.Key.QA_LIKED_TIMES_KEY,
                    LikedTimesDTO.builder().bizId(recordDTO.getBizId()).likedTimes(count).build()
            );
        }

    }

    private Boolean cancelLiked(LikeRecordFormDTO recordDTO, Long userId) {
        LikedRecord likedRecord = this.lambdaQuery()
                .eq(LikedRecord::getBizId, recordDTO.getBizId())
                .eq(LikedRecord::getUserId, userId)
                .one();
        if (likedRecord != null) {
            removeById(likedRecord.getId());
            return true;
        }
        return false;
    }

    private Boolean liked(LikeRecordFormDTO recordDTO, Long userId) {
        //点赞逻辑 查看有没有点赞记录,没有则新增
        LikedRecord likedRecord = this.lambdaQuery()
                .eq(LikedRecord::getBizId, recordDTO.getBizId())
                .eq(LikedRecord::getUserId, userId)
                .one();
        if (likedRecord == null) {
            likedRecord = new LikedRecord();
            likedRecord.setBizId(recordDTO.getBizId());
            likedRecord.setBizType(recordDTO.getBizType());
            likedRecord.setUserId(userId);
            save(likedRecord);
            return true;
        }
        return false;
    }

 其他微服务监听点赞状态变更的消息 

直接更新数据库即可

批量查询点赞状态

由于这个接口是供其它微服务调用,实现完成接口后,还需要定义对应的FeignClient

 前端发送一系列业务id,判断哪些是该用户点赞过的,返回用户点赞过的id

@Override
public Set<Long> isBizLiked(List<Long> bizIds) {
    // 1.获取登录用户id
    Long userId = UserContext.getUser();
    // 2.查询点赞状态
    List<LikedRecord> list = lambdaQuery()
            .in(LikedRecord::getBizId, bizIds)
            .eq(LikedRecord::getUserId, userId)
            .list();
    // 3.返回结果
    return list.stream().map(LikedRecord::getBizId).collect(Collectors.toSet());
}

点赞功能改进 

点赞是个很频繁,访问量很高的操作

新增点赞或取消赞改进

 用redis的set存储可以减少数据库查询,大大缓解压力

由于Redis本身具备持久化机制,AOF提供的数据可靠性已经能够满足点赞业务的安全需求,因此我们完全可以用Redis存储来代替数据库的点赞记录。

也就是说,用户的一切点赞行为,以及将来查询点赞状态我们可以都走Redis,不再使用数据库查询。

 有同学会担心,如果点赞数据非常庞大,达到数百亿,那么该怎办呢?

 代码实现

点赞次数

由于点赞次数需要在业务方持久化存储到数据库,因此Redis只起到缓存作用即可。

由于需要记录业务id、业务类型、点赞数三个信息:

  • 一个业务类型下包含多个业务id

  • 每个业务id对应一个点赞数。

使用zset来存储

  • zset(sorted set:有序集合): 类似于集合,但是每个元素都有一个分数(score)与之关联。

 通过定时任务定期将数据持久化到数据库

 

 定时任务批量处理更新到数据库

两个业务来回循环,每次取30条数据更新(避免压力过大)

 popmin 按照分值去取size大小的数据,取出来并返回

    public void readLikedTimesAndSendMessage(String bizType, int maxBizSize) {
        // 1.拼接key
        String bizTypeTotalLikeKey = RedisConstants.LIKES_TIMES_KEY_PREFIX + bizType;
        ArrayList<LikedTimesDTO> list = new ArrayList<>();
        // 2.从redis中的zset结构中取maxbizsize的业务点赞信息  popmin
        Set<ZSetOperations.TypedTuple<String>> typedTuples = redisTemplate.opsForZSet().popMin(bizTypeTotalLikeKey, maxBizSize);
        for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
            String bizId = typedTuple.getValue();
            Double likedTimes = typedTuple.getScore();
            if (StringUtils.isBlank(bizId) || likedTimes == null) {
                continue;
            }
            //3.封装LikedTimesDTO 消息数据
            LikedTimesDTO msg = new LikedTimesDTO();
            msg.setBizId(Long.valueOf(bizId));
            msg.setLikedTimes(likedTimes.intValue());
            list.add(msg);
        }
        // 4.发送消息到mq
        if (CollUtils.isNotEmpty(list)){
            log.debug("批量发送点赞消息,消息内容{}",list);
            String routingKey = StringUtils.format(MqConstants.Key.LIKED_TIMES_KEY_TEMPLATE, bizType);
            rabbitMqHelper.send(
                    MqConstants.Exchange.LIKE_RECORD_EXCHANGE,
                    routingKey,
                    list);
        }
    }

 发送器其他微服务取批量更新

批量查询点赞状态统计

点赞记录都缓存到redis中,直接去redis查询,又因为一个一个查太费劲使用redis的管道技术

    public Set<Long> getLikeStatusByzIds(List<Long> bizIds) {
        if (CollUtils.isEmpty(bizIds)) {
            return CollUtils.emptySet();
        }
        // 1.获取登录用户id
        Long userId = UserContext.getUser();
        // 2.查询点赞状态   短时间执行大量的查询的时候用
        List<Object> objects = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
            StringRedisConnection src = (StringRedisConnection) connection;
            for (Long bizId : bizIds) {
                String key = RedisConstants.LIKE_BIZ_KEY_PREFIX + bizId;
                src.sIsMember(key, userId.toString());
            }
            // 这个return没有意义,会把结果封装到集合中
            return null;
        });
        // 3.返回结果
        return IntStream.range(0, objects.size()) // 创建从0到集合size的流
                .filter(i -> (boolean) objects.get(i)) // 遍历每个元素,保留结果为true的角标i
                .mapToObj(bizIds::get)// 用角标i取bizIds中的对应数据,就是点赞过的id
                .collect(Collectors.toSet());// 收集
        /**
         * 传统的写法
         * // 获取用户id
         *         Long userId = UserContext.getUser();
         *         // 2.查询点赞状态
         *         List<LikedRecord> list = lambdaQuery()
         *                 .in(LikedRecord::getBizId, bizIds)
         *                 .eq(LikedRecord::getUserId, userId)
         *                 .list();
         *         // 3.返回结果
         *         return list.stream().map(LikedRecord::getBizId).collect(Collectors.toSet());
         */

    }

sentinal降级

1引入依赖 2降级类 3引用降级类 4配置文件中自动注入 5.开始远程降级服务 6 测试

2 编写降级配置类 

3 引用降级类

 4 配置文件中自动注入(因为是其他微服务引入,要想让其被spring管理,必须让spring扫描到这个类,在spring.factories)spring启动器只扫描与他在同一个包路径下的和指定的路径的,引入的Maven依赖需要在spring.factories中配置 要扫描哪些类。其他服务只要依赖了某个依赖就会扫描那个依赖中spring.factories写的bean

5.开启降级服务 

 一个微服务调用另一个微服务时,另一个微服务不能获取到threadlocal中的用户信息

 

解决办法,使用feign拦截器

有userid时就重新放入请求头中

发送feign的时候先获取用户,然后把用户放入请求头发出去

 

  • 20
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值