一、点赞

业务情景:用户可以通过点击每篇笔记左下角的大拇指图标来进行点赞/取消点赞

redis实现热搜排行 redis 榜单_redis

实现

在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实现热搜排行 redis 榜单_redis_02

需求实现

既然要把点赞的前五个用户筛选出来,那么注定涉及到对时间的排序。在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;
    }

三、关注用户

redis实现热搜排行 redis 榜单_java_03


额外创建一张数据库的表专门用来记录用户关注情况,为每个用户创建一个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();
    }

四、共同关注

redis实现热搜排行 redis 榜单_redis实现热搜排行_04

有同学可能会有疑惑,为什么关注的时候不为被关注的用户创建一个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条关注的博主发布的笔记,用户通过向上滑动来进行翻页,向下滑动来重置,也就是返回当前第一页的数据

推送问题

redis实现热搜排行 redis 榜单_redis_05

Feed模式

拉模式(很少用)

博主发布笔记时将笔记存放在自己的发件箱中,粉丝打开关注列表时发送请求,从所有关注博主的发件箱中取出笔记,并按照时间顺序排序,难点在于取出笔记中实现分页

推模式(简单常用)

redis实现热搜排行 redis 榜单_redis_06

推拉结合

redis实现热搜排行 redis 榜单_redis_07

普通人发布笔记,采取推模式推送给任何人
大V发布笔记,通过推模式推送给活跃粉丝,而普通粉丝通过拉模式拉取大V笔记

redis实现热搜排行 redis 榜单_redis_08

业务实现

在本项目中,为了节约成本快速上线,我们采取推模式用来推送消息

发布笔记

  1. 将笔记信息保存到保存到数据库中
  2. 查询数据库找到该博主的所有粉丝
  3. 将笔记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:每页记录数

但是这会面临以下问题:

redis实现热搜排行 redis 榜单_redis_09


由于不断有新的博客发布,导致索引更改造成重复读

思路:采用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);


    }