首先我们来分析整理一下点赞业务的需求,一个通用点赞系统需要满足下列特性:
-
通用:点赞业务在设计的时候不要与业务系统耦合,必须同时支持不同业务的点赞功能
-
独立:点赞功能是独立系统,并且不依赖其它服务。这样才具备可迁移性。(可以发送消息队列通知其他微服务)
-
并发:一些热点业务点赞会很多,所以点赞功能必须支持高并发
-
安全:要做好并发安全控制,避免重复点赞
要保证安全,避免重复点赞,我们就必须保存每一次点赞记录。只有这样在下次用户点赞时我们才能查询数据,判断是否是重复点赞。同时,因为业务方经常需要根据点赞数量排序,因此每个业务的点赞数量也需要记录下来。
综上,点赞的基本思路如下:
如果业务方需要根据点赞数排序,就必须在数据库中维护点赞数字段(比如评论里面回复的点赞)。但是点赞系统无法修改其它业务服务的数据库,否则就出现了业务耦合。该怎么办呢?
学习评论的微服务
点赞业务本质
点赞记录本质就是记录谁给什么内容点了赞,所以核心属性包括:
-
点赞目标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的时候先获取用户,然后把用户放入请求头发出去