天机学堂4-点赞实现

由于点赞系统是独立于其它业务的,这里我们需要创建一个新的数据库:tj_remark

CREATE DATABASE tj_remark CHARACTER SET 'utf8mb4';

然后在ER图基础上,加上一些通用属性,点赞记录表结构如下:点赞的业务id–>如果点赞类型是评论就是评论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='点赞记录表';

在这里插入图片描述
发送消息到MQ:

LikedTimesDTO likedTimesDTO = LikedTimesDTO.builder()
                 .bizId(dto.getBizId())
                .likedTimes(likedTimes)
                .build();
log.info("发送点赞消息,消息内容:{}", likedTimesDTO);
// 发送消息到RabbitMQ消息队列
rabbitMqHelper.send(
	MqConstants.Exchange.LIKE_RECORD_EXCHANGE,  // 消息队列交换机
    StrUtil.format(MqConstants.Key.LIKED_TIMES_KEY_TEMPLATE, dto.getBizType()),  // 消息队列Key, dto.getBizType()是"回答"还是“评论”
    likedTimesDTO
);

SpringMVC如果接收集合对象,必须加@RequestParam, JSON转对象的是Body

暴露Feign接口

一个服务挂掉,调用它的服务就会出现很多阻塞线程(也会慢慢挂掉)。由于一个服务挂掉导致调用该服务的都挂掉,叫雪崩(级联失败)。

Sentinel 降级的核心概念

降级规则是 Sentinel 中用于定义降级条件的配置。常见的降级触发条件包括以下几种:

  • RT(响应时间)策略
    如果某资源的平均响应时间在最近 N 秒内超过阈值,并且在此期间通过的请求量大于设置的最小请求数,则触发降级。
    在降级窗口时间内,所有请求都会被拒绝(可以自定义降级处理逻辑)。

与熔断的区别
降级是为了保护调用方,使系统在异常时快速失败。
熔断是保护被调用方,避免其因过载或不可用而崩溃。

由于该接口是给其它微服务调用的,所以必须暴露出Feign客户端,并且定义好fallback降级处理:
在这里插入图片描述
@FeignClient(value = "remark-service", fallbackFactory = RemarkClientFallback.class)

  1. 对应的fallback逻辑:
package com.tianji.api.client.remark.fallback;

import com.tianji.api.client.remark.RemarkClient;
import com.tianji.common.utils.CollUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;

import java.util.Set;

@Slf4j
public class RemarkClientFallback implements FallbackFactory<RemarkClient> {

    @Override
    public RemarkClient create(Throwable cause) {
        log.error("查询remark-service服务异常", cause);
        return new RemarkClient() {

            @Override
            public Set<Long> isBizLiked(Iterable<Long> bizIds) {
                return CollUtils.emptySet();
            }
        };
    }
}
  1. 由于这个FeignClient是在Api下的,我们写一个配置文件FallbackConig,添加到spring.factories。SpringBoot会在启动时读取/META-INF/spring.factories文件,我们只需要在该配置文件new RemarkClientFallback
    在这里插入图片描述

手动 new RemarkClientFallBack() 是为了显式将降级逻辑注册到 Spring 容器。如果不希望显式声明,也可以通过 @Component 注解和自动扫描来完成 Bean 的注册。具体方式可以根据项目需求和代码风格选择。

  1. 开启Feign对Sentinel的降级支持

在这里插入图片描述

Feign拦截器

服务之间的Feign调用没有用户信息,所以设置Feign拦截器获取用户信息,加到请求头,后面feign调用才有

package com.tianji.authsdk.resource.interceptors;

import com.tianji.auth.common.constants.JwtConstants;
import com.tianji.common.utils.UserContext;
import feign.RequestInterceptor;
import feign.RequestTemplate;

public class FeignRelayUserInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        Long userId = UserContext.getUser();
        //经由网关到后端有用户信息,服务之间的Feign调用没有
        if (userId == null) {
            return;
        }
        //将用户信息加到请求头里
        template.header(JwtConstants.USER_HEADER, userId.toString());
    }
}
import feign.Feign;
import com.tianji.authsdk.resource.interceptors.FeignRelayUserInterceptor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConditionalOnClass(Feign.class)
public class FeignRelayUserAutoConfiguration {

    @Bean
    public FeignRelayUserInterceptor feignRelayUserInterceptor(){
        return new FeignRelayUserInterceptor();
    }
}

@Configuration:
声明 FeignRelayUserAutoConfiguration 是一个配置类,用于定义 Spring 容器中的 Bean。
@ConditionalOnClass(Feign.class):
仅当类路径中存在 Feign.class 时,Spring Boot 才会加载该配置类。
如果 Feign.class 不存在,Spring Boot 会跳过该配置类的加载,避免无关配置或启动错误。

类路径(Classpath)是指 Java 虚拟机(JVM)在运行时或编译时用来查找类文件和资源文件的路径集合。如果项目中引入了 Feign 的依赖(例如通过 Maven 或 Gradle),对应的 JAR 文件会被下载并加入类路径。

Redis数据结构优化

数据结构选择

zset里面除了有HashMap。还可以根据score进行排序,

  • 右边用zset 【总点赞次数需要在业务方持久化存储到数据库】
  • 左边用set【用户是否点赞】
# 判断用户是否点赞
SISMEMBER bizId userId
# 点赞,如果返回1则代表点赞成功,返回0则代表点赞失败
SADD bizId userId
# 取消点赞,就是删除一个元素
SREM bizId userId
# 统计点赞总数
SCARD bizId

在这里插入图片描述

  • 右边用zset 【点赞次数需要在业务方持久化存储到数据库】

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

  • 一个业务类型下包含多个业务id
  • 每个业务id对应一个点赞数。
    因此,我们可以把每一个业务类型作为一组,使用Redis的一个key,然后业务id作为键,点赞数作为值。这样的键值对集合,有两种结构都可以满足:
    Hash:传统键值对集合,无序 SortedSet:基于Hash结构,并且增加了跳表。因此可排序,但更占用内存

如果是从节省内存角度来考虑,Hash结构无疑是最佳的选择;但是考虑到将来我们要从Redis读取点赞数,然后移除(避免重复处理)。为了保证线程安全,查询、移除操作必须具备原子性。而SortedSet则提供了几个移除并获取的功能,天生具备原子性。并且我们每隔一段时间就会将数据从Redis移除,并不会占用太多内存。因此,这里我们计划使用SortedSet结构。
在这里插入图片描述

定时任务把缓存的点赞数,通过MQ通知到业务方,持久化到业务方的数据库

在之前的提交播放记录业务中,由于播放记录是定期每隔15秒发送一次请求,频率固定。因此我们可以通过接收到播放记录后延迟20秒检测数据变更来确定是否有新数据到达。

但是点赞则不然,用户何时点赞、点赞频率如何完全不确定。因此无法采用延迟检测这样的手段。
事实上这也是大多数合并写请求业务面临的问题,而多数情况下,我们只能通过定时任务,定期将缓存的数据持久化到数据库中。

在这里插入图片描述

给业务点赞 和 点赞数统计代码

public interface RedisConstants {
    /*给业务点赞的用户集合的KEY前缀,后缀是业务id*/
    String LIKE_BIZ_KEY_PREFIX = "likes:set:biz:";
    /*业务点赞数统计的KEY前缀,后缀是业务类型*/
    String LIKES_TIMES_KEY_PREFIX = "likes:times:type:";
}

在这里插入图片描述

private boolean liked(LikeRecordFormDTO dto, Long userId) {
    String key = RedisConstants.LIKE_BIZ_KEY_PREFIX + dto.getBizId();
    // set的唯一性,已经存在add失败
    Long result = redisTemplate.opsForSet().add(key, userId.toString());
    return result != null && result > 0;
}

右边的代码:

 // 统计该业务id的总点赞数
 String key = RedisConstants.LIKE_BIZ_KEY_PREFIX + dto.getBizId();
 Long likedTimes = redisTemplate.opsForSet().size(key);
 if (likedTimes == null) {
     return;
 }
 String bizTypeKey = RedisConstants.LIKE_COUNT_KEY_PREFIX + dto.getBizType();
 // 三个参数依次为:key,value(业务id),score(点赞数量)
 redisTemplate.opsForZSet().add(bizTypeKey, dto.getBizId().toString(), likedTimes);

点赞数持久化代码

从redis取指定类型点赞数量并发送消息到RabbitMQ

/**
 * @param bizType    业务类型
 * @param maxBizSize 每次任务取出的业务score标准
 */
@Override
public void readLikedTimesAndSendMessage(String bizType, int maxBizSize) {
    // 拼接key
    String key = RedisConstants.LIKE_COUNT_KEY_PREFIX + bizType;
    // 读取redis,popMin 方法用于从指定的zset有序集合 key 中移除并返回最小的 maxBizSize 个元素。
    Set<ZSetOperations.TypedTuple<String>> typedTuples = redisTemplate.opsForZSet().popMin(key, maxBizSize);
    if (CollUtil.isNotEmpty(typedTuples)) {
        List<LikedTimesDTO> likedTimesDTOS = new ArrayList<>(typedTuples.size());
        for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
            Double likedTimes = tuple.getScore();   // 获取点赞数量
            String bizId = tuple.getValue();    // 获取业务id
            // 校验是否为空
            if (StringUtils.isBlank(bizId) || likedTimes == null) {
                continue;
            }
            // 封装LikedTimeDTO
            LikedTimesDTO likedTimesDTO = LikedTimesDTO.builder()
                    .bizId(Long.valueOf(bizId))
                    .likedTimes(likedTimes.intValue())
                    .build();
            likedTimesDTOS.add(likedTimesDTO);
        }
        // 发送RabbitMQ消息
        if (CollUtil.isNotEmpty(likedTimesDTOS)) {
            log.info("发送点赞消息,消息内容:{}", likedTimesDTOS);
            rabbitMqHelper.send(MqConstants.Exchange.LIKE_RECORD_EXCHANGE,
                    StringUtils.format(MqConstants.Key.LIKED_TIMES_KEY_TEMPLATE, bizType),
                    likedTimesDTOS);
        }
    }
}

对应的消费方也要改:

package com.tianji.learning.mq;

import com.tianji.api.dto.remark.LikedTimesDTO;
import com.tianji.common.constants.MqConstants;
import com.tianji.learning.domain.po.InteractionReply;
import com.tianji.learning.service.IInteractionReplyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;


@Component
@Slf4j
@RequiredArgsConstructor
public class LikeRecordChangeListener {

    private final IInteractionReplyService replyService;


    /***
     * @param dtoList 接受的参数类型为List<LikedTimesDTO>
     */
    @RabbitListener(bindings = @QueueBinding(value = @Queue(value = "qa.liked.times.queue", durable = "true"),
            exchange = @Exchange(value = MqConstants.Exchange.LIKE_RECORD_EXCHANGE, type = ExchangeTypes.TOPIC),
            key = MqConstants.Key.QA_LIKED_TIMES_KEY))
    public void onMsg(List<LikedTimesDTO> dtoList) {
        log.info("LikeRecordChangeListener监听到消息,消息内容:{}", dtoList);
        // 封装到list以执行批量更新
        List<InteractionReply> replyList = new ArrayList<>();
        for (LikedTimesDTO dto : dtoList) {
            InteractionReply reply = new InteractionReply();
            reply.setId(dto.getBizId());    // 业务id
            reply.setLikedTimes(dto.getLikedTimes());   // 点赞数量
            replyList.add(reply);
        }
        // 批量更新
        replyService.updateBatchById(replyList);
    }

}

批量查询点赞状态:
在这里插入图片描述
管道方式实现 不要上面的循环:节省时间
在这里插入图片描述

在这里插入图片描述

public Set<Long> getLikesStatusByBizIds(List<Long> bizIds) {
        if (CollUtil.isEmpty(bizIds)) {
            return Collections.emptySet();
        }
        Long userId = UserContext.getUser();
        // 使用redis管道,提高性能,objects实际上是Boolean类型列表
        List<Object> objects = redisTemplate.executePipelined(new RedisCallback<Object>() {
            @Override
            public Object doInRedis(RedisConnection connection) throws DataAccessException {
                StringRedisConnection stringRedisConnection = (StringRedisConnection) connection;
                for (Long bizId : bizIds) {
                    String key = RedisConstants.LIKE_BIZ_KEY_PREFIX + bizId;
                    // 真正的返回值类型由调用的方法决定
                    stringRedisConnection.sIsMember(key, userId.toString());    // 查询当前userId是否在对应业务id的set中
                }
                // executePipelined方法会忽略 doInRedis 方法的返回值,而只关心执行管道操作后得到的结果列表。
                return null;
            }
        });
        // 根据管道操作的结果列表 objects,筛选出当前用户点赞过的业务id列表
        return IntStream.range(0, objects.size())  // 遍历结果列表的索引范围
                .filter(i -> (Boolean) objects.get(i))  // 过滤出返回值为 true 的索引
                .mapToObj(bizIds::get)  // 将索引映射为对应的业务id
                .collect(Collectors.toSet());  // 将业务id 收集为 Set,并返回
    }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值