当前位置: 首页 > news >正文

Redis实战-黑马点评项目完结(p78-p95)

前言:

项目完结,这里把从达人探店到测试百万数据这5个接口功能就整理成一篇博客了。

今日所学:

  • 达人探店
  • 好友关注
  • 附近商铺
  • 用户签到
  • UV统计

1.达人探店

1.1 发布探店笔记

需求分析:

探店笔记类似点评网站的评价,往往是图文结合。对应的表有两个:

tb_blog:探店笔记表,包含笔记中的标题、文字、图片等

tb_blog_comments:其他用户对探店笔记的评价

代码实现:

在Controller包下新建一个UploadController类

用于上传图片

@Slf4j

@RestController

@RequestMapping("upload")

public class UploadController {

    @PostMapping("blog")

    public Result uploadImage(@RequestParam("file") MultipartFile image) {

        try {

            // 获取原始文件名称

            String originalFilename = image.getOriginalFilename();

            // 生成新文件名

            String fileName = createNewFileName(originalFilename);

            // 保存文件

            image.transferTo(new File(SystemConstants.IMAGE_UPLOAD_DIR, fileName));

            // 返回结果

            log.debug("文件上传成功,{}", fileName);

            return Result.ok(fileName);

        } catch (IOException e) {

            throw new RuntimeException("文件上传失败", e);

        }

    }

}

在Controller层下创建BlogController类

用于保存博客

@RestController

@RequestMapping("/blog")

public class BlogController {

    @Resource

    private IBlogService blogService;

    @PostMapping

    public Result saveBlog(@RequestBody Blog blog) {

        //获取登录用户

        UserDTO user = UserHolder.getUser();

        blog.setUserId(user.getId());

        //保存探店博文

        blogService.saveBlog(blog);

        //返回id

        return Result.ok(blog.getId());

    }

}

效果展示:

关联商户记得选择,不然会报错

在主页可以看到相应博客展示

1.2 查看探店笔记

需求分析:

首页点击可以查看博客详情

代码实现:

Controller层下BlogController类

service层下BlogServiceImpl类

@Override

public Result queryBlogById(Long id) {

    // 1.查询blog

    Blog blog = getById(id);

    if (blog == null) {

        return Result.fail("笔记不存在!");

    }

    // 2.查询blog有关的用户

    queryBlogUser(blog);

    return Result.ok(blog);

}

效果展示:

首页点击博客

可以查看详情

1.3 点赞功能

需求分析:

实现博客点赞功能

代码实现:

初始代码:

@GetMapping("/likes/{id}")

public Result queryBlogLikes(@PathVariable("id") Long id) {

    //修改点赞数量

    blogService.update().setSql("liked = liked +1 ").eq("id",id).update();

    return Result.ok();

}

问题:单个用户可以对多个博客无限点赞,这明显是不合理的

完善:单个用户对单个博客只能点赞一次,再次点赞则取消刚才的点赞功能

需求分析(完善):

需求:

  •  同一个用户只能点赞一次,再次点击则取消点赞
  •  如果当前用户已经点赞,则点赞按钮高亮显示(前端已实现,判断字段Blog类的isLike属性)

代码实现(完善):

 1.给Blog类中添加一个isLike字段,标示是否被当前用户点赞

使用@TableFiled表示这个字段属于实体类但不存在于数据库表中

@TableField(exist = false)

private Boolean isLike;

 2.修改点赞功能,利用Redis的set集合判断是否点赞过,未点赞过则点赞数+1,已点赞过则点赞数-1

@Override

    public Result likeBlog(Long id){

        // 1.获取登录用户

        Long userId = UserHolder.getUser().getId();

        // 2.判断当前登录用户是否已经点赞

        String key = BLOG_LIKED_KEY + id;

        Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());

        if(BooleanUtil.isFalse(isMember)){

             //3.如果未点赞,可以点赞

            //3.1 数据库点赞数+1

            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();

            //3.2 保存用户到Redis的set集合

            if(isSuccess){

                stringRedisTemplate.opsForSet().add(key,userId.toString());

            }

        }else{

             //4.如果已点赞,取消点赞

            //4.1 数据库点赞数-1

            boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();

            //4.2 把用户从Redis的set集合移除

            if(isSuccess){

                stringRedisTemplate.opsForSet().remove(key,userId.toString());

            }

        }

3. 修改根据id查询Blog的业务,判断当前登录用户是否点赞过,赋值给isLike字段

 4.修改主页查询Blog业务,判断当前登录用户是否点赞过,赋值给isLike字段

其中isBlogLiked是封装的方法,用来查看是否当前用户点过赞

private void isBlogLiked(Blog blog) {// 1.获取登录用户Long userId = UserHolder.getUser().getId();// 2.判断当前用户是否已经点赞String key = BLOG_LIKED_KEY + blog.getId();//Boolean ismember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());blog.setIsLike(score == null);
}

效果展示:

1.4 点赞排行榜

需求分析:

在探店笔记的详情页面,应该把给该笔记点赞的人显示出来,比如最早点赞的TOP5,形成点赞排行榜:

之前的点赞是放到set集合,但是set集合是不能排序的,所以这个时候,咱们可以采用一个可以排序的set集合,就是咱们的sortedSet

原有代码修改:

在service层的BlogServiceImpl类下

修改原有的点赞逻辑代码

1.删掉redis中原有set集合,获取登录用户,组成key,如果未点赞,则传入Zset中

2.如果点赞成功,记录当前时间,作为score跟userID一起传入到zset中(用于后续排序)

其他不变

 @Override

    public Result likeBlog(Long id) {

        // 1.获取登录用户

        Long userId = UserHolder.getUser().getId();

        // 2.判断当前登录用户是否已经点赞

        String key = BLOG_LIKED_KEY + id;

        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());

        if (score == null) {

            // 3.如果未点赞,可以点赞

            // 3.1.数据库点赞数 + 1

            boolean isSuccess = update().setSql("liked = liked + 1").eq("id", id).update();

            // 3.2.保存用户到Redis的set集合  zadd key value score

            if (isSuccess) {

                stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());

            }

        } else {

            // 4.如果已点赞,取消点赞

            // 4.1.数据库点赞数 -1

            boolean isSuccess = update().setSql("liked = liked - 1").eq("id", id).update();

            // 4.2.把用户从Redis的set集合移除

            if (isSuccess) {

                stringRedisTemplate.opsForZSet().remove(key, userId.toString());

            }

        }

        return Result.ok();

    }

isBlogLiked方法也要由set改为Zset

 private void isBlogLiked(Blog blog) {

        // 1.获取登录用户

        UserDTO user = UserHolder.getUser();

        if (user == null) {

            // 用户未登录,无需查询是否点赞

            return;

        }

        Long userId = user.getId();

        // 2.判断当前登录用户是否已经点赞

        String key = "blog:liked:" + blog.getId();

        Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());

        blog.setIsLike(score != null);

    }

代码实现:

在Controller层下BlogController类中

@GetMapping("/likes/{id}")

public Result queryBlogLikes(@PathVariable("id") Long id) {

    return blogService.queryBlogLikes(id);

}

在service层下BlogServiceImpl类下

1.根据zrange查询前5个点赞的用户

2.解析出他们的用户id

3,根据用户id查询用户信息并返回

这里需要注意的是mysql默认是按照id大小排序的,如果我们想要实现按时间大小进行排序,需手动设置order by和order by Field(这里使用ids是因为ids本身就是时间排序的结果,如下图)

public Result queryBlogLikes(Long id) {

    String key = BLOG_LIKED_KEY + id;

    // 1.查询top5的点赞用户 zrange key 0 4

    Set<String> top5 = stringRedisTemplate.opsForZSet().range(key, 0, 4);

    if (top5 == null || top5.isEmpty()) {

        return Result.ok(Collections.emptyList());

    }

    // 2.解析出其中的用户id

    List<Long> ids = top5.stream().map(Long::valueOf).collect(Collectors.toList());

    String idStr = StrUtil.join(",", ids);

    // 3.根据用户id查询用户 WHERE id IN ( 5 , 1 ) ORDER BY FIELD(id, 5, 1)

    List<UserDTO> userDTOS = userService.query()

            .in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list()

            .stream()

            .map(user -> BeanUtil.copyProperties(user, UserDTO.class))

            .collect(Collectors.toList());

    // 4.返回

    return Result.ok(userDTOS);

}

效果展示:

首页点击一个博客进入详情就能看到

因为我是用jmeter随机用户点赞的,所以头像都是一样的

2. 好友关注

2.1 关注和取消关注

需求分析:

针对用户的操作:可以对用户进行关注和取消关注功能。

需求:基于该表数据结构,实现两个接口:

  •  关注和取关接口
  •  判断是否关注的接口

关注是User之间的关系,是博主与粉丝的关系,数据库中有一张tb_follow表来标示:

需要注意的是follow_user_id储存的是当前用户所关注的人

代码实现:

Controller层下的FollowController类:

//关注

@PutMapping("/{id}/{isFollow}")

public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {

    return followService.follow(followUserId, isFollow);

}

//取消关注

@GetMapping("/or/not/{id}")

public Result isFollow(@PathVariable("id") Long followUserId) {

      return followService.isFollow(followUserId);

}

Service层下的FollowService类:

核心逻辑:

1.封装一个isFollow方法,判断当前状态是否关注,返回一个boolean数组

2.如果isFollow是true,往tb_follow新增相关数据

3.否则tb_follow删除相关数据

// 查找是否关注service

@Override

public Result isFollow(Long followUserId) {

        // 1.获取登录用户

        Long userId = UserHolder.getUser().getId();

        // 2.查询是否关注 select count(*) from tb_follow where user_id = ? and follow_user_id = ?

        Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();

        // 3.判断

        return Result.ok(count > 0);

    }

 //关注service

 @Override

    public Result follow(Long followUserId, Boolean isFollow) {

        // 1.获取登录用户

        Long userId = UserHolder.getUser().getId();

        String key = "follows:" + userId;

        // 1.判断到底是关注还是取关

        if (isFollow) {

            // 2.关注,新增数据

            Follow follow = new Follow();

            follow.setUserId(userId);

            follow.setFollowUserId(followUserId);

            boolean isSuccess = save(follow);

        } else {

            // 3.取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ?

            remove(new QueryWrapper<Follow>()

                    .eq("user_id", userId).eq("follow_user_id", followUserId));

        }

        return Result.ok();

    }

效果展示:

2.2 共同关注

前置代码

想要去看共同关注的好友,需要首先进入到这个页面,这个页面会发起两个请求

1、去查询用户的详情

2、去查询用户的笔记

这两个功能跟共同关注毫无关系,不过是要进入共同关注界面的话要先经过这个页面,代码也是笔记中有的,复制粘贴即可。

/ UserController 根据id查询用户

@GetMapping("/{id}")

public Result queryUserById(@PathVariable("id") Long userId){

    // 查询详情

    User user = userService.getById(userId);

    if (user == null) {

        return Result.ok();

    }

    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);

    // 返回

    return Result.ok(userDTO);

}

// BlogController  根据id查询博主的探店笔记

@GetMapping("/of/user")

public Result queryBlogByUserId(

        @RequestParam(value = "current", defaultValue = "1") Integer current,

        @RequestParam("id") Long id) {

    // 根据用户查询

    Page<Blog> page = blogService.query()

            .eq("user_id", id).page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));

    // 获取当前页数据

    List<Blog> records = page.getRecords();

    return Result.ok(records);

}

需求分析:

需求:利用Redis中恰当的数据结构,实现共同关注功能。在博主个人页面展示出当前用户与博主的共同关注。

思路:使用我们之前学习过的set集合,在set集合中,有交集并集补集的api,我们可以把两人的关注的人分别放入到一个set集合中,然后再通过api去查看这两个set集合中的交集数据

我们先来改造当前的关注列表

改造原因是因为我们需要在用户关注了某位用户后,需要将数据放入到set集合中,方便后续进行共同关注,同时当取消关注时,也需要从set集合中进行删除

在service层的followServiceImpl中,改造当前代码,增加add set和delete set两步

@Override

public Result follow(Long followUserId, Boolean isFollow) {

    // 1.获取登录用户

    Long userId = UserHolder.getUser().getId();

    String key = "follows:" + userId;

    // 1.判断到底是关注还是取关

    if (isFollow) {

        // 2.关注,新增数据

        Follow follow = new Follow();

        follow.setUserId(userId);

        follow.setFollowUserId(followUserId);

        boolean isSuccess = save(follow);

        if (isSuccess) {

            // 把关注用户的id,放入redis的set集合 sadd userId followerUserId

            stringRedisTemplate.opsForSet().add(key, followUserId.toString());

        }

    } else {

        // 3.取关,删除 delete from tb_follow where user_id = ? and follow_user_id = ?

        boolean isSuccess = remove(new QueryWrapper<Follow>()

                .eq("user_id", userId).eq("follow_user_id", followUserId));

        if (isSuccess) {

            // 把关注用户的id从Redis集合中移除

            stringRedisTemplate.opsForSet().remove(key, followUserId.toString());

        }

    }

    return Result.ok();

}

代码实现:

核心逻辑:

1.获取当前用户(userID)和查看的用户(id)

2.各自拼接上follow:查找key对应的value(各自关注的人)

3.求交集

4.解析数据 返回

service层下的FollowServiceImpl

@Override

public Result followCommons(Long id) {

    // 1.获取当前用户

    Long userId = UserHolder.getUser().getId();

    String key = "follows:" + userId;

    // 2.求交集

    String key2 = "follows:" + id;

    Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);

    if (intersect == null || intersect.isEmpty()) {

        // 无交集

        return Result.ok(Collections.emptyList());

    }

    // 3.解析id集合

    List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());

    // 4.查询用户

    List<UserDTO> users = userService.listByIds(ids)

            .stream()

            .map(user -> BeanUtil.copyProperties(user, UserDTO.class))

            .collect(Collectors.toList());

    return Result.ok(users);

}

效果展示:

2.3 关注博客推送-feed实现

前置知识:

当我们关注了用户后,这个用户发了动态,那么我们应该把这些数据推送给用户,这个需求,其实我们又把他叫做Feed流,关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。

对于传统的模式的内容解锁:我们是需要用户去通过搜索引擎或者是其他的方式去解锁想要看的内容

对于新型的Feed流的的效果:不需要我们用户再去推送信息,而是系统分析用户到底想要什么,然后直接把内容推送给用户,从而使用户能够更加的节约时间,不用主动去寻找。

Feed流的实现有两种模式:

Timeline:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈

  •  优点:信息全面,不会有缺失。并且实现也相对简单
  •  缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低

智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户

  •  优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
  •  缺点:如果算法不精准,可能起到反作用

本例中的个人页面,是基于关注的好友来做Feed流,只需要拿到我们关注用户的信息,然后按照时间排序即可,因此,采用Timeline的模式。该模式的实现方案有三种:

  •  拉模式
  •  推模式
  •  推拉结合

1.拉模式:也叫做读扩散

该模式的核心含义就是:当张三和李四和王五发了消息后,都会保存在自己的邮箱中,假设赵六关注了这三人并且要读取信息,那么就会从三人的邮箱中拉取信息到赵六的关注界面。

优点:

比较节约空间,因为赵六在读信息时,并没有重复读取,而且读取完之后可以把他的收件箱进行清除。

缺点:

比较延迟,当用户读取数据时才去关注的人里边去读取数据,假设用户关注了大量的用户,那么此时就会拉取海量的内容,对服务器压力巨大。

2.推模式:也叫做写扩散。

推模式是没有写邮箱的,当张三写了一个内容,此时会主动的把张三写的内容发送到他的粉丝收件箱中去,假设此时李四再来读取,就不用再去临时拉取了

优点:

时效快,不用临时拉取

缺点:

内存压力大,假设一个大V写信息,很多人关注他, 就会写很多分数据到粉丝那边去

3.推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点。

推拉模式是一个折中的方案,站在发件人这一段,如果是个普通的人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝中去,因为普通的人他的粉丝关注量比较小,所以这样做没有压力,如果是大V,那么他是直接将数据先写入到一份到发件箱里边去,然后再直接写一份到活跃粉丝收件箱里边去,现在站在收件人这端来看,如果是活跃粉丝,那么大V和普通的人发的都会直接写入到自己收件箱里边来,而如果是普通的粉丝,由于他们上线不是很频繁,所以等他们上线时,再从发件箱里边去拉信息。

简单来说:粉丝量少的博主我们采用写扩散,粉丝量多的博主我们对其活跃粉丝采用写扩散,对于不活跃粉丝采用拉扩散。

在本项目中我们采用推模式

需求分析:

需求:

  •  修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱
  •  收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现
  •  查询收件箱数据时,可以实现分页查询

注意点:

Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式

传统了分页在feed流是不适用的,因为我们的数据会随时发生变化

假设在t1 时刻,有十条数据。我们去读取第一页,此时page = 1 ,size = 5 ,那么我们拿到的就是10~6 这几条记录,那么现在t2时候又发布了一条记录,我们再去读取第二页,读取第二页传入的参数是page=2 ,size=5 ,由于新插入的数据是在开头位置的(越后面时间搓越大),那么此时读取到的第二页实际上是从6 开始,然后是6~2 ,我们就读取到了重复的数据,所以feed流的分页,不能采用原始方案来做。

简单来说,就算传统分页方式偏移量是固定的,每次计算时都要重新计算整个数据集的分页窗口。如果在开头有新增数据,偏移量固定,就会导致重复读取。

Feed流的滚动分页

我们需要记录每次操作的最后一条,然后从这个位置开始去读取数据

举个例子:我们从t1时刻开始,拿第一页数据,拿到了10~6,然后记录下当前最后一次拿取的记录,就是6,t2时刻发布了新的记录,此时这个11放到最顶上,但是不会影响我们之前记录的6,此时t3时刻来拿第二页,第二页这个时候拿数据,还是从6后一点的5去拿,就拿到了5-1的记录。我们这个地方可以采用sortedSet来做,可以进行范围查询,并且还可以记录当前获取数据时间戳最小值,就可以实现滚动分页了

代码实现:

发表博客代码逻辑中

1.我们将redis作为收件箱,以userId区别key,保存博客id

2.更改保存博客的代码逻辑,在发表博客成功后推送博客id给每个粉丝

3.由于采用的是推模式,我们直接将相关博客保存到每个粉丝的收件箱

@Override

public Result saveBlog(Blog blog) {

    // 1.获取登录用户

    UserDTO user = UserHolder.getUser();

    blog.setUserId(user.getId());

    // 2.保存探店笔记

    boolean isSuccess = save(blog);

    if(!isSuccess){

        return Result.fail("新增笔记失败!");

    }

    // 3.查询笔记作者的所有粉丝 select * from tb_follow where follow_user_id = ?

    List<Follow> follows = followService.query().eq("follow_user_id", user.getId()).list();

    // 4.推送笔记id给所有粉丝

    for (Follow follow : follows) {

        // 4.1.获取粉丝id

        Long userId = follow.getUserId();

        // 4.2.推送

        String key = FEED_KEY + userId;

        stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());

    }

    // 5.返回id

    return Result.ok(blog.getId());

在个人主页的“关注”卡片中,查询并展示推送的Blog信息:

总体操作如下:

1、每次查询完成后,我们要分析出查询出数据的最小时间戳,这个值会作为下一次查询的条件

2、我们需要找到与上一次查询相同的查询个数作为偏移量,下次查询时,跳过这些查询过的数据,拿到我们需要的数据

由此我们的请求参数中就需要携带 lastId:上一次查询的最小时间戳 和偏移量这两个参数。

这两个参数第一次会由前端来指定,以后的查询就根据后台结果作为条件,再次传递到后台。

具体代码逻辑:

1.定义出来具体的返回值实体类,用于储存返回前端的数据

@Data

public class ScrollResult {

    private List<?> list;

    private Long minTime;

    private Integer offset;

}

2.在Controller层下的blogController

注意:RequestParam 表示接受url地址栏传参的注解,当方法上参数的名称和url地址栏不相同时,可以通过RequestParam 来进行指定

比如说max在url网址上传参是“lastId”

@GetMapping("/of/follow")

public Result queryBlogOfFollow(

    @RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset){

    return blogService.queryBlogOfFollow(max, offset);

}

3. 在service层下BlogServiceImpl

在讲代码的核心逻辑前,我们需先明确一点:

我们判断时间戳相等跳过,是建立在这一时间戳在指定偏移量内已读取完毕,如果不跳过,在下一次读取时就会重复读取。跳过的是已读完的相同时间戳数据,而非根据内容本身。

代码核心逻辑:

1.查询当前登录用户的收件箱(因为采用的是推模式,博主发布博客是直接发送的粉丝的收件箱)

2.判断是否有拿到数据

3.定义一个ids用于储存博客id,minTime用于储存时间戳,os用于储存初始偏移量(最小为1)

4.使用增强for一个个取出拿到的数据,添加博客id

5.获取时间戳,判断是否与上一循环的时间戳相同,相同则偏移量加一

5.不相同,os初始化为1,设置时间戳

6.根据ids得到blogs

7.封装相应数据返回blogs,os(注意!),minTime,用于下一次请求

@Override

public Result queryBlogOfFollow(Long max, Integer offset) {

    // 1.获取当前用户

    Long userId = UserHolder.getUser().getId();

    // 2.查询收件箱 ZREVRANGEBYSCORE key Max Min LIMIT offset count

    String key = FEED_KEY + userId;

    Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()

        .reverseRangeByScoreWithScores(key, 0, max, offset, 2);

    // 3.非空判断

    if (typedTuples == null || typedTuples.isEmpty()) {

        return Result.ok();

    }

    // 4.解析数据:blogId、minTime(时间戳)、offset

    List<Long> ids = new ArrayList<>(typedTuples.size());

    long minTime = 0; // 2

    int os = 1; // 2

    for (ZSetOperations.TypedTuple<String> tuple : typedTuples) { // 5 4 4 2 2

        // 4.1.获取id

        ids.add(Long.valueOf(tuple.getValue()));

        // 4.2.获取分数(时间戳)

        long time = tuple.getScore().longValue();

        if(time == minTime){

            os++;

        }else{

            minTime = time;

            os = 1;

        }

    }

    os = minTime == max ? os : os + offset;

    // 5.根据id查询blog

    String idStr = StrUtil.join(",", ids);

    List<Blog> blogs = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();

    for (Blog blog : blogs) {

        // 5.1.查询blog有关的用户

        queryBlogUser(blog);

        // 5.2.查询blog是否被点赞

        isBlogLiked(blog);

    }

    // 6.封装并返回

    ScrollResult r = new ScrollResult();

    r.setList(blogs);

    r.setOffset(os);

    r.setMinTime(minTime);

    return Result.ok(r);

}

效果展示:

每次请求显示两条

往下翻页则发起新的请求,根据上一页传回的数据得到新的博客

3. 附近商户

3.1 导入店铺数据到GEO

前置知识-GEO

GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:

 GEOADD:

添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)

 GEODIST:

计算指定的两个点之间的距离并返回

 GEOHASH:

将指定member的坐标转为hash字符串形式并返回

 GEOPOS:

返回指定member的坐标

 GEORADIUS:

指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。(6.以后已废弃)

 GEOSEARCH:

在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形(6.2.新功能)

 GEOSEARCHSTORE:

与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key( 6.2.新功能)

需求分析:

当我们点击美食之后,会出现一系列的商家,商家中可以按照多种排序方式,我们此时关注的是距离,这个地方就需要使用到我们的GEO,向后台传入当前app收集的地址(我们此处是写死的) ,以当前坐标作为圆心,同时绑定相同的店家类型type,以及分页信息,把这几个条件传入后台,后台查询出对应的数据再返回。

我们要做的事情是:将数据库表中的数据导入到redis中去,redis中的GEO,GEO在redis中就一个menber和一个经纬度,我们把x和y轴传入到redis做的经纬度位置去,但我们不能把所有的数据都放入到menber中去,毕竟作为redis是一个内存级数据库,如果存海量数据,redis还是力不从心,所以我们在这个地方存储他的id即可。

但是这个时候还有一个问题,就是在redis中并没有存储type,所以我们无法根据type来对数据进行筛选,所以我们可以按照商户类型做分组,类型相同的商户作为同一组,以typeId为key存入同一个GEO集合中

根据typeId作为区分key的一种手段,value记录具体名称,score记录距离

代码实现:

需求:把店铺按照typeId分组,分批存储到redis中

在HmDianPingApplicationTests中

1.查询店铺信息

2.将店铺分组,typeId一致的放到一个集合

3.对每个集合(同一类型),获取类型id以及同类型店铺集合

4.相同的key(同一类型)下,传入x(经度),y(纬度),shopId添加至redis中

@Test

void loadShopData() {

    // 1.查询店铺信息

    List<Shop> list = shopService.list();

    // 2.把店铺分组,按照typeId分组,typeId一致的放到一个集合

    Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));

    // 3.分批完成写入Redis

    for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {

        // 3.1.获取类型id

        Long typeId = entry.getKey();

        String key = SHOP_GEO_KEY + typeId;

        // 3.2.获取同类型的店铺的集合

        List<Shop> value = entry.getValue();

        List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());

        // 3.3.写入redis GEOADD key 经度 纬度 member

        for (Shop shop : value) {

            // stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());

            locations.add(new RedisGeoCommands.GeoLocation<>(

                    shop.getId().toString(),

                    new Point(shop.getX(), shop.getY())

            ));

        }

        stringRedisTemplate.opsForGeo().add(key, locations);

    }

}

3.2 实现附近商户功能

配置修改:

SpringDataRedis的2.3.9版本并不支持Redis 6.2提供的GEOSEARCH命令,因此我们需要提示其版本,修改自己的POM

<dependency>

    <groupId>org.springframework.boot</groupId>

    <artifactId>spring-boot-starter-data-redis</artifactId>

    <exclusions>

        <exclusion>

            <artifactId>spring-data-redis</artifactId>

            <groupId>org.springframework.data</groupId>

        </exclusion>

        <exclusion>

            <artifactId>lettuce-core</artifactId>

            <groupId>io.lettuce</groupId>

        </exclusion>

    </exclusions>

</dependency>

<dependency>

    <groupId>org.springframework.data</groupId>

    <artifactId>spring-data-redis</artifactId>

    <version>2.6.2</version>

</dependency>

<dependency>

    <groupId>io.lettuce</groupId>

    <artifactId>lettuce-core</artifactId>

    <version>6.1.6.RELEASE</version>

</dependency>

代码实现:

在Controller层下ShopController中

注意:RequestParam 表示接受url地址栏传参的注解,当方法上参数的名称和url地址栏不相同时,可以通过RequestParam 来进行指定

其中required表示可以传入,可以不传入

@GetMapping("/of/type")

public Result queryShopByType(

        @RequestParam("typeId") Integer typeId,

        @RequestParam(value = "current", defaultValue = "1") Integer current,

        @RequestParam(value = "x", required = false) Double x,

        @RequestParam(value = "y", required = false) Double y

) {

   return shopService.queryShopByType(typeId, current, x, y);

}

service层下ShopServiceImpl中

1.判断是否使用坐标查询(可以使用可以不使用)

2. 如果不使用坐标查询,正常按照数据库查询即可

3.如果需要使用坐标查询,先计算分页参数,current代表着当前页

4.查询redis,查看符合距离范围的店铺,进行逻辑分页,截取到end

5.如果后续不断分页至最后一页,店铺列表list的大小已经小于from,那么返回空集合

6跳过前from的条店铺信息(前一页的内容),截取当前页的内容(from-end),获取店铺id,距离

7.得到详细的店铺信息,并设置相应的距离,返回给前端

@Override

    public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {

        // 1.判断是否需要根据坐标查询

        if (x == null || y == null) {

            // 不需要坐标查询,按数据库查询

            Page<Shop> page = query()

                    .eq("type_id", typeId)

                    .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));

            // 返回数据

            return Result.ok(page.getRecords());

        }

        // 2.计算分页参数

        int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;

        int end = current * SystemConstants.DEFAULT_PAGE_SIZE;

        // 3.查询redis、按照距离排序、分页。结果:shopId、distance

        String key = SHOP_GEO_KEY + typeId;

        GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()

                .search(

                        key,

                        GeoReference.fromCoordinate(x, y),

                        new Distance(5000),

                        RedisGeoCommands.GeoSearchCommandArgs

.newGeoSearchArgs().includeDistance().limit(end)

                );

        // 4.解析出id

        if (results == null) {

            return Result.ok(Collections.emptyList());

        }

        List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();

        if (list.size() <= from) {

            // 没有下一页了,结束

            return Result.ok(Collections.emptyList());

        }

        // 4.1.截取 from ~ end的部分

        List<Long> ids = new ArrayList<>(list.size());

        Map<String, Distance> distanceMap = new HashMap<>(list.size());

        list.stream().skip(from).forEach(result -> {

            // 4.2.获取店铺id

            String shopIdStr = result.getContent().getName();

            ids.add(Long.valueOf(shopIdStr));

            // 4.3.获取距离

            Distance distance = result.getDistance();

            distanceMap.put(shopIdStr, distance);

        });

        // 5.根据id查询Shop

        String idStr = StrUtil.join(",", ids);

        List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();

        for (Shop shop : shops) {

            shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());

        }

        // 6.返回

        return Result.ok(shops);

    }

4.用户签到

4.1 实现签到功能

前置知识-bitMap

实现签到功能,或许有人会考虑使用mysql来解决。例如,实现下面这张表:

但是这样做,用户一次签到,就是一条记录,假如有1000万用户,平均每人每年签到次数为10次,则这张表一年的数据量为 1亿条

每签到一次需要使用(8 + 8 + 1 + 1 + 3 + 1)共22 字节的内存,一个月则最多需要600多字节

我们如何能够简化一点呢?其实可以考虑小时候一个挺常见的方案,就是小时候,咱们准备一张小小的卡片,你只要签到就打上一个勾,我最后判断你是否签到,其实只需要到小卡片上看一看就知道了

我们可以采用类似这样的方案来实现我们的签到需求。

我们按月来统计用户签到信息,签到记录为1,未签到则记录为0.

把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为位图(BitMap)。这样我们就用极小的空间,来实现了大量数据的表示

Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是 2^32个bit位。

BitMap的操作命令有:

  •  SETBIT:

向指定位置(offset)存入一个0或1

  •  GETBIT :

获取指定位置(offset)的bit值

  •  BITCOUNT :

统计BitMap中值为1的bit位的数量

  •  BITFIELD :

操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值

  •  BITFIELD_RO :

获取BitMap中bit数组,并以十进制形式返回

  •  BITOP :

将多个BitMap的结果做位运算(与 、或、异或)

  •  BITPOS :

查找bit数组中指定范围内第一个0或1出现的位置

需求分析:

需求:

实现签到接口,将当前用户当天签到信息保存到Redis中

思路:

我们可以把年和月作为bitMap的key,然后保存到一个bitMap中,每次签到就到对应的位上把数字从0变成1,只要对应是1,就表明说明这一天已经签到了,反之则没有签到。

我们通过接口文档发现,此接口并没有传递任何的参数,没有参数怎么确实是哪一天签到呢?这个很容易,知道当天的日期,其他可以通过后台代码直接获取即可,然后到对应的地址上去修改bitMap。

代码实现:

Controller层下的UserController

@PostMapping("/sign")

 public Result sign(){

    return userService.sign();

 }

service层的UserServiceImpl

1.获取当前用户

2.获取日期

3.加上常量拼接成key

4.获取今天是本月第几天,将bitmap对应索引位置改为1,表示签到(使用dayOfMonth-1是因为索引从零开始)

@Override

public Result sign() {

    // 1.获取当前登录用户

    Long userId = UserHolder.getUser().getId();

    // 2.获取日期

    LocalDateTime now = LocalDateTime.now();

    // 3.拼接key

    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));

    String key = USER_SIGN_KEY + userId + keySuffix;

    // 4.获取今天是本月的第几天

    int dayOfMonth = now.getDayOfMonth();

    // 5.写入Redis SETBIT key offset 1

    stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);

    return Result.ok();

}

4.2 签到统计

需求分析:

1.什么是连续签到天数

从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。

2.如何得到本月到今天为止的所有签到数据?

假设今天是10号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是10号,那么就是10位,去拿这段时间的数据,就能拿到所有的数据了,那么这10天里边签到了多少次呢?统计有多少个1即可。

3.如何从后向前遍历每个bit位?

注意:bitMap返回的数据是10进制,哪假如说返回一个数字8,那么我哪儿知道到底哪些是0,哪些是1呢?我们只需要让得到的10进制数字和1做与运算就可以了,因为1只有遇见1 才是1,其他数字都是0 ,我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,依次内推,我们就能完成逐个遍历的效果了。

需求:

实现下面接口,统计当前用户截止当前时间在本月的连续签到天数

有用户有时间我们就可以组织出对应的key,此时就能找到这个用户截止这天的所有签到记录,再根据这套算法,就能统计出来他连续签到的次数了

代码实现:

Controller层下的UserController

@GetMapping("/sign/count")

public Result signCount(){

    return userService.signCount();

}

service层UserServiceImpl

1.获取当前登录用户和日期

2.作为key进行拼接,获取本月今天为止所有的签到记录(比如1110011),返回结果时会转换成十进制数字(二进制->十进制)

3.初始化一个count记录次数,循环遍历将得到的num与1进行位运算(计算机会隐式转换成二进制计算),如果是零,代表着未签到(1与0位运算为0),结束循环。如果不是,count+1。

4.每次循环结束num要向右位移,为什么是右移呢?因为内存储存中,第一天在最高(左)位,最后一天在最低(右)位,每次跟1进行位运算后,都要把bitmap右移1位。注意移动的是bitmap不是1。比如第一次是第十天进行位运算,第二次就算第九天的和1进行位运算

5.返回count

@Override

public Result signCount() {

    // 1.获取当前登录用户

    Long userId = UserHolder.getUser().getId();

    // 2.获取日期

    LocalDateTime now = LocalDateTime.now();

    // 3.拼接key

    String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));

    String key = USER_SIGN_KEY + userId + keySuffix;

    // 4.获取今天是本月的第几天

    int dayOfMonth = now.getDayOfMonth();

    // 5.获取本月截止今天为止的所有的签到记录,返回的是一个十进制的数字 BITFIELD sign:5:202203 GET u14 0

    List<Long> result = stringRedisTemplate.opsForValue().bitField(

            key,

            BitFieldSubCommands.create()

                    .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)

    );

    if (result == null || result.isEmpty()) {

        // 没有任何签到结果

        return Result.ok(0);

    }

    Long num = result.get(0);

    if (num == null || num == 0) {

        return Result.ok(0);

    }

    // 6.循环遍历

    int count = 0;

    while (true) {

        // 6.1.让这个数字与1做与运算,得到数字的最后一个bit位  // 判断这个bit位是否为0

        if ((num & 1) == 0) {

            // 如果为0,说明未签到,结束

            break;

        }else {

            // 如果不为0,说明已签到,计数器+1

            count++;

        }

        // 把数字右移一位,抛弃最后一个bit位,继续下一个bit位

        num >>>= 1;

    }

    return Result.ok(count);

}

额外篇-关于bitmap解决缓存穿透方案

回顾缓存穿透:

发起了一个数据库不存在的,redis里边也不存在的数据,通常你可以把他看成一个攻击

我们那时候采取的操作是缓存空值,将不存在的id当作空值缓存起来。

问题是如果采用这种操作,短时间如果有大量的不同id请求,不仅会在redis中储存大量无效数据,而且第一次请求同样会到达数据库,给数据库造成一定压力。

所以我们如何解决呢?

我们可以将数据库的数据,所对应的id写入到一个list集合中,当用户过来访问的时候,我们直接去判断list中是否包含当前的要查询的数据,如果说用户要查询的id数据并不在list集合中,则直接返回,如果list中包含对应查询的id数据,则说明不是一次缓存穿透数据,则直接放行。

现在的问题是这个主键其实并没有那么短,而是很长的一个 主键,哪怕你单独去提取这个主键,但是在11年左右,淘宝的商品总量就已经超过10亿个,所以如果采用以上方案,这个list也会很大,所以我们可以使用bitmap来减少list的存储空间

我们可以把list数据抽象成一个非常大的bitmap,我们不再使用list,而是将db中的id数据利用哈希思想,比如:

id % bitmap.size  = 算出当前这个id对应应该落在bitmap的哪个索引上,然后将这个值从0变成1,然后当用户来查询数据时,此时已经没有了list,让用户用他查询的id去用相同的哈希算法, 算出来当前这个id应当落在bitmap的哪一位,然后判断这一位是0,还是1,如果是0则表明这一位上的数据一定不存在,  采用这种方式来处理,需要重点考虑一个事情,就是误差率,所谓的误差率就是指当发生哈希冲突的时候,产生的误差。

简单来说,将数据库id取余看看是哪一位,将这一位的数据改为1,请求id到达的时候,根据相同的算法计算偏移量,看看是零还是1

这就是布隆过滤器的基本原理。

5.UV统计

5.1 UV统计-HyperLogLog

首先我们搞懂两个概念:

1. UV

全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。

2.PV:

全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。

通常来说UV会比PV大很多,所以衡量同一个网站的访问量,我们需要综合考虑很多因素,所以我们只是单纯的把这两个值作为一个参考值

UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖,那怎么处理呢?

Hyperloglog(HLL):

Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:https://juejin.cn/post/6844903785744056333#heading-0

Redis中的HLL是基于string结构实现的,单个HLL的内存**永远小于16kb**,**内存占用低**的令人发指!作为代价,其测量结果是概率性的,**有小于0.81%的误差**。不过对于UV统计来说,这完全可以忽略。

简单来说,在有细小误差的情况下,HLL不管存多大的数,内存占比都不会超过16KB.

5.2 UV统计-测试百万数据

测试思路:我们直接利用单元测试,向HyperLogLog中添加100万条数据,看看内存占用和统计效果如何

代码实现:

由于我们一次性的开辟一个1000000的空间太大,所以我们只开辟一个1000大小的new String,并且每1000次记录一次值,这样循环往复,知道100万数据存储完毕

测试:

先查看测试前redis的内存大小,使用info memory命令

运行程序

测试通过

因为储存的数据有0.81的误差,所以真实储存的数值大小为997593

再次查看内存占用请况

发现运行前后多出了14376B,除于1024约等于14KB,满足占比不超过16KB.

最后:

今天的分享就到这里。如果我的内容对你有帮助,请点赞评论收藏。创作不易,大家的支持就是我坚持下去的动力!(๑`・ᴗ・´๑)

http://www.xdnf.cn/news/945289.html

相关文章:

  • 解决网页导出PDF部分内容被遮挡问题
  • 性能优化中,多面体模型基本原理
  • 使用centos服务器和Let‘s Encypted配置SpingBoot项目的https证书
  • JVM如何优化
  • 深度解析 Linux 内核参数 net.ipv4.tcp_rmem:优化网络性能的关键
  • DeFi模式:去中心化金融架构与流动性池设计
  • Dify 社区版本地docker compose部署记录
  • Docker环境下安装 Elasticsearch + IK 分词器 + Pinyin插件 + Kibana(适配7.10.1)
  • uni-app学习笔记三十五--扩展组件的安装和使用
  • A2A JS SDK 完整教程:快速入门指南
  • Linux线程互斥与竞态条件解析
  • LeetCode Hot100刷题——三数之和
  • 2025企业级采购系统深度评测:AI技术如何助力采购成本直降40%?
  • Python训练营-Day26-函数专题1:函数定义与参数
  • 从实验室到产业:IndexTTS 在六大核心场景的落地实践
  • 影子栈指针是什么?
  • 原型模式深度解析:Java设计模式实战指南与克隆机制优化实践
  • 一种使用 PowerToys 的键盘管理器工具重新映射按键实现在 Windows 上快捷输入字符的方式
  • 在Spring Boot中集成RabbitMQ的完整指南
  • vue3+vite+pnpm项目 使用monaco-editor常见问题
  • 数据结构篇--分离链表vs线性探测
  • Redis的发布订阅模式与专业的 MQ(如 Kafka, RabbitMQ)相比,优缺点是什么?适用于哪些场景?
  • laravel8+vue3.0+element-plus搭建方法
  • Hugging Face、魔塔社区(MOTA)与OpenRouter:AI模型平台深度对比与实战指南
  • (七) 深度学习进阶:现代卷积神经网络技术解析与应用实践
  • <STC32G12K128入门第十九步>QT串口ISP更新上位机
  • Spring 框架(1)
  • 题山采玉:Day3
  • 3D Web轻量化引擎HOOPS Communicator赋能一线场景,支持本地化与动态展示?
  • 如何手撸一个最小化操作系统:从 0 到 1 掌握汇编-文件管理-内存页表-文件系统-上下文切换算法 MIT 经典教程 结合豆包ai