表
由于点赞系统是独立于其它业务的,这里我们需要创建一个新的数据库: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)
- 对应的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();
}
};
}
}
- 由于这个FeignClient是在Api下的,我们写一个配置文件FallbackConig,添加到spring.factories。SpringBoot会在启动时读取/META-INF/spring.factories文件,我们只需要在该配置文件
new RemarkClientFallback
手动 new RemarkClientFallBack() 是为了显式将降级逻辑注册到 Spring 容器。如果不希望显式声明,也可以通过 @Component 注解和自动扫描来完成 Bean 的注册。具体方式可以根据项目需求和代码风格选择。
- 开启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,并返回
}