一、点赞
业务情景:用户可以通过点击每篇笔记左下角的大拇指图标来进行点赞/取消点赞
实现
在redis中为每一篇博客创建一个set,key为blog:like:id,set记录点赞的用户id,同时为Blog类添加一个isLike字段
分页查询笔记
当分页查询blog的时候,还需要查询当前用户id是否在该博客对应的set中,是的话则为其isLike设置为true
点赞/取消点赞
判断该用户id是否在set中,若不存在则将其添加到set中,并将数据库中该博客的点赞数+1
若在则将其移除,并更新数据库的点赞数-1
思考:是否需要考虑并发问题?
当一个用户短时间内发送大量请求时,很可能出现多个线程同时判断出该用户没点赞或该用户已经点赞,导致该博客的点赞数大幅度增加或减少,因此在对set进行查询之前应该加上锁
业务代码
@Override
public Result likeBlog(Long id) {
//1.查询用户
UserDTO user = UserHolder.getUser();
if(user == null){
Result.fail("请先登录");
}
//2.判断redis中该blog的点赞用户中是否包含此用户id
// Boolean isMember = stringRedisTemplate.opsForSet().isMember(RedisConstants.BLOG_LIKED_KEY + id, String.valueOf(user.getId()));
RLock lock = redissonClient.getLock("user:lock:" + user.getId());
if(lock.tryLock()) {
try {
Double score = stringRedisTemplate.opsForZSet().score(RedisConstants.BLOG_LIKED_KEY + id, user.getId().toString());
if (score != null) {//如果存在说明此次是取消点赞
//数据库操作
update().setSql("liked = liked - 1").eq("id", id).update();
stringRedisTemplate.opsForZSet().remove(RedisConstants.BLOG_LIKED_KEY + id, String.valueOf(user.getId()));
} else { //否则是点赞
update().setSql("liked = liked + 1").eq("id", id).update();
stringRedisTemplate.opsForZSet().add(RedisConstants.BLOG_LIKED_KEY + id, String.valueOf(user.getId()), System.currentTimeMillis());
}
lock.unlock();
return Result.ok();
}catch (Exception e){
log.error(e.getMessage());
return Result.fail("点赞失败,请稍后再试");
}finally {
if(lock.isLocked() && lock.isHeldByCurrentThread()){
lock.unlock();
}
}
}else {
return likeBlog(id);
}
}
二、点赞排行榜
需求实现
既然要把点赞的前五个用户筛选出来,那么注定涉及到对时间的排序。在redis中,既涉及到去重又涉及到排序的容器只有ScoreSet,那么我们可以使用ScoreSet来存放用户id,Score为点赞时间转化为毫秒。
代码
@Override
public Result queryLikesUser(Long id) {
//1.查询给该blog点赞的前五个用户
Set<String> top5 = stringRedisTemplate.opsForZSet().range(RedisConstants.BLOG_LIKED_KEY + id, 0, 4);
//2.根据用户id查询用户信息
if(!top5.isEmpty()) {
String ids = StrUtil.join(",", top5);
System.out.println("ids: " + ids);
List<User> users = userService.query().in("id", top5).last("order by Field(id," + ids + ")").list();
//3.将用户信息映射到dto中
List<UserDTO> dtos = users.stream().map((user -> BeanUtil.copyProperties(user, UserDTO.class))).collect(Collectors.toList());
return Result.ok(dtos);
}
return null;
}
三、关注用户
额外创建一张数据库的表专门用来记录用户关注情况,为每个用户创建一个set用来记录该用户关注的用户id
代码
@Override
public Result isFollow(Long id) {
UserDTO user = UserHolder.getUser();
//1.查询
Integer count = this.query().eq("follow_user_id", id).eq("user_id", user.getId()).count();
return Result.ok(count > 0);
}
@Override
public Result followUser(long follwer_id, boolean isFollwer) {
//1. 获取当前用户
UserDTO user = UserHolder.getUser();
if(user == null){
return Result.fail("请先登录");
}
Follow follow = new Follow();
follow.setUserId(user.getId());
follow.setFollowUserId(follwer_id);
//2. 判断isfollwer
if(isFollwer){ //关注用户
boolean save = save(follow);
if(save){
stringRedisTemplate.opsForSet().add(RedisConstants.USER_FOLLOW_KEY + user.getId(),String.valueOf(follwer_id));
}else {
return Result.fail("关注失败");
}
}else{ //取关用户
boolean remove = this.remove(new QueryWrapper<Follow>().eq("user_id", user.getId()).eq("follow_user_id", follwer_id));
if(remove){
stringRedisTemplate.opsForSet().remove(RedisConstants.USER_FOLLOW_KEY + user.getId(),String.valueOf(follwer_id));
}else {
return Result.fail("取关失败");
}
}
return Result.ok();
}
四、共同关注
有同学可能会有疑惑,为什么关注的时候不为被关注的用户创建一个set,里面记录关注它的用户呢?是因为我们这个业务需要对两个用户的关注列表进行交集运算,所以使用当前用户id作为set容器的key,被关注对象id作为元素
代码
@GetMapping("/common/{id}")
public Result findCommonFollow(@PathVariable("id") Long target_id){
//1.获取当前用户
UserDTO user = UserHolder.getUser();
if(user == null){
return Result.fail("请先登录");
}
//2.取关注列表交集
Set<String> set = stringRedisTemplate.opsForSet().intersect(RedisConstants.USER_FOLLOW_KEY + target_id, RedisConstants.USER_FOLLOW_KEY + user.getId());
if(set == null || set.isEmpty() ){
return Result.ok(Collections.emptySet());
}
List<Long> ids = set.stream().map(Long::valueOf).collect(Collectors.toList());
List<User> users = userService.listByIds(ids);
return Result.ok(users);
}
五、关注推送
业务描述:当用户点开自己的个人主页中的关注那一栏时,会按照时间顺序从新到旧展现count条关注的博主发布的笔记,用户通过向上滑动来进行翻页,向下滑动来重置,也就是返回当前第一页的数据
推送问题
Feed模式
拉模式(很少用)
博主发布笔记时将笔记存放在自己的发件箱中,粉丝打开关注列表时发送请求,从所有关注博主的发件箱中取出笔记,并按照时间顺序排序,难点在于取出笔记中实现分页
推模式(简单常用)
推拉结合
普通人发布笔记,采取推模式推送给任何人
大V发布笔记,通过推模式推送给活跃粉丝,而普通粉丝通过拉模式拉取大V笔记
业务实现
在本项目中,为了节约成本快速上线,我们采取推模式用来推送消息
发布笔记
- 将笔记信息保存到保存到数据库中
- 查询数据库找到该博主的所有粉丝
- 将笔记Id发送到他们的收件箱(zset)中
代码
@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
// 获取登录用户
UserDTO user = UserHolder.getUser();
blog.setUserId(user.getId());
// 保存探店博文
blogService.save(blog);
// 查询关注该博主的用户
List<Long> ids = followService.query().eq("follow_user_id", user.getId()).
list().
stream().
map(f -> f.getUserId()).
collect(Collectors.toList());
// 推送给关注的用户
for(long id:ids){
stringRedisTemplate.opsForZSet().add(RedisConstants.FOLLOW_USER_BLOG +id, blog.getId().toString(),System.currentTimeMillis());
}
// 返回id
return Result.ok(blog.getId());
}
接收推送
通过场景描述我们知道应该使用分页查询来查询数据,又由于涉及到时间的排序,所以使用我们熟悉的方法,用zset存储笔记id,时间作为score
分页问题
由于我们使用了ZSet作为收件箱来存储笔记id,很多同学自然而然想到了用
stringRedisTemplate.opsForZSet().
reverseRange(RedisConstants.FOLLOW_USER_BLOG + user.getId(), page*count,count);
page:当前页数,count:每页记录数
但是这会面临以下问题:
由于不断有新的博客发布,导致索引更改造成重复读
思路:采用ZSet充当用户收件箱,Score设置为时间,调用reverseRangeByScoreWithScores方法来逆序获得某段时间内的笔记
reverseRangeByScoreWithScores(key,beginId,lastId,offset,count)执行过程:
1. 获取[beginId,lastId]上的所有笔记,并逆序
2. 将得到的笔记逆序,再跳过offset个
3. 从offset后往后取count条笔记返回
因此,我们只需要将每次获取的最后一条笔记的score作为下一次的lastId,若为第一页查询,则lastId为now
注意:当存在笔记最后时间一致时,需要计算其offset,比如五条消息,最后两条时间一致,那么需要将offset设置为3,如果没有时间一致的消息,那么offset为1,只需跳过最后一个id即可
@Override
public Result queryFollowBlog(Long lastId,int offset) {
//1.获取当前用户
UserDTO user = UserHolder.getUser();
//2.使用zset获取最新的5条blog
/**
* 获取分数在[0,lastId]区间的元素并从高到低排序
* offset表示跳过的个数
*/
Set<ZSetOperations.TypedTuple<String>> set = stringRedisTemplate.opsForZSet().
reverseRangeByScoreWithScores(RedisConstants.FOLLOW_USER_BLOG + user.getId(), 0, lastId, offset, 5);
if(set == null || set.isEmpty()){
return Result.ok();
}
//3. 解析数据
List<String> ids = new ArrayList<>();
List<Long> scores = new ArrayList<>();
//3.1.告知前端下次需要传的参数
Long minTime = null;
int nextOffset = 0;
for(ZSetOperations.TypedTuple<String> item:set){
String id = item.getValue();
long time = item.getScore().longValue();
ids.add(id);
scores.add(time);
minTime = time;
}
// 获得lastId的重复个数,若无重复则nextOffset = 1
int i = scores.indexOf(minTime);
nextOffset = scores.size() - i;
//4.根据blogId查询对应blog:要求按照in的顺序,否则会自动按照blogId的顺序排列
String idStr = StrUtil.join(",", ids);
List<Blog> blogs = blogService.query().in("id",ids).last("order by Field(id,"+ idStr +")").list();
//4.1 设置blog作者有关信息以及判断当前用户是否给该blog点赞
blogs.forEach(blog -> {
setBlogUser(blog);
});
//5.封装成滚动数据对象
ScrollResult scrollResult = new ScrollResult();
scrollResult.setList(blogs);
scrollResult.setMinTime(minTime);
scrollResult.setOffset(nextOffset);
return Result.ok(scrollResult);
}