深入学习Redis-常见业务场景开发实战


1. 达人探店案例

1.1 点赞功能

@PutMapping("/like/{id}")
public Result likeBlog(@PathVariable("id") Long id) {
    // 修改点赞数量
    blogService.update()
            .setSql("liked = liked + 1").eq("id", id).update();
    return Result.ok();
}

一个基本的点赞功能如上代码所示

这样做的后果是每次都访问数据库,最终压力都全部会打到数据库上,而且点赞没有去重判断的功能一人一单类。

@Override
public Result queryBlogById(Long id) {
    //1.查询blog
    Blog blog = this.query().eq("id", id).one();
    if (blog == null) {
        return Result.fail("博客不存在!");
    }
    //2.查询blog有关的信息
    this.getUser(blog);
    isBolgLiked(blog);
    return Result.ok(blog);
}

private void getUser(Blog blog){
    User user = userService.query().eq("id", blog.getUserId()).one();
    blog.setName(user.getNickName());
    blog.setIcon(user.getIcon());
}

@Override
public Result queryBlogById(Integer current) {
    // 根据用户查询
    Page<Blog> page = query()
            .orderByDesc("liked")
            .page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
    // 获取当前页数据
    List<Blog> records = page.getRecords();
    // 查询用户
    records.forEach(blog->{
        getUser(blog);
        isBolgLiked(blog);
    });
    // 查询blog是否被点赞
    return Result.ok(records);
}

@Override
public Result likeBlog(Long id) {
    //1.判断当前是否点赞
    UserDTO user = UserHolder.getUser();
    Long userId = user.getId();
    String key = RedisConstants.BLOG_LIKED_KEY+id;
    Boolean isLiked = stringRedisTemplate.opsForSet().isMember(key, userId);
    //2.如果未点赞
    if(!BooleanUtil.isTrue(isLiked)){
        //数据库的点赞数+1
        boolean success = update().setSql("liked = liked +1").eq("id", id).update();
        //将用户信息保存到redis中
        if(success){
            stringRedisTemplate.opsForSet().add(key,userId.toString());
        }
    }else{
        //3.已经点赞,取消点赞,点赞数-1
        boolean success = update().setSql("liked = liked -1").eq("id", id).update();
        //把用户从redis中的set集合中删除
        if(success){
            stringRedisTemplate.opsForSet().remove(key,userId.toString());
        }
    }
    return Result.ok();
}

private void isBolgLiked(Blog blog){
    UserDTO user = UserHolder.getUser();
    Long userId = user.getId();
    String key = RedisConstants.BLOG_LIKED_KEY+blog.getId();
    Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId);
    blog.setIsLike(BooleanUtil.isTrue(isMember));
}

1.2 点赞排行榜

在上面的实现中,我们是基于无序的set进行实现的,然而我们想要实现的功能是按照点赞时间来排序,得到点赞top5的用户

List Set SortedSet
排序方式 按照添加顺序排序 无法排序 根据score值排序
唯一性 不唯一 唯一 唯一
查找方式 按索引查找或者首尾查找 根据元素查找 根据元素查找
@Override
public Result likeBlog(Long id) {
    //1.判断当前是否点赞...
    Double score = stringRedisTemplate.opsForZSet().score(key, userId);
    //2.如果未点赞
    if(score == null){
        //数据库的点赞数+1
        //将用户信息保存到redis中 zadd key value score
        if(success){
      stringRedisTemplate.opsForZSet().add(key,userId.toString(),System.currentTimeMillis());
        }
    }else{
        //3.已经点赞,取消点赞,点赞数-1
        //把用户从redis中的set集合中删除
        if(success){
            stringRedisTemplate.opsForZSet().remove(key,userId.toString());
        }
    }
    return Result.ok();
}

private void isBolgLiked(Blog blog){
    ...
    Double score = stringRedisTemplate.opsForZSet().score(key, userId);
    blog.setIsLike(score != null);
}

TopN点赞实现

@Override
public Result queryBlogLikes(Long id) {
    String key = RedisConstants.BLOG_LIKED_KEY+id;
    //查询TOP5
    Set<String> topN = stringRedisTemplate.opsForZSet().range(key, 0, 4);
    if(topN == null || topN.isEmpty()){
        return Result.ok(Collections.emptyList());
    }
    //解析相关信息
    List<Long> ids = topN.stream().map(Long::valueOf).collect(Collectors.toList());
    List<UserDTO> users = userService.listByIds(ids)
            .stream()
            .map(user -> BeanUtil.copyProperties(user, UserDTO.class))
            .collect(Collectors.toList());
    //数据脱敏
    return Result.ok(users);
}

2. 好友关注案例

2.1 关注和取关

@Resource
private FollowMapper followMapper;

@Override
@Transactional
public Result follow(Long followUserId, Boolean isFollow) {
    UserDTO loginUser = UserHolder.getUser();
    //取决于你传的参数,到底是关注还是取关
    if(BooleanUtil.isTrue(isFollow)){
        Follow follow = new Follow();
        follow.setUserId(loginUser.getId());
        follow.setFollowUserId(followUserId);
        save(follow);
    }else{
        //取关,删除 delete from tb_follow where user_id = ? and follower_id = ?
        followMapper.cancelFollow(loginUser.getId(),followUserId);
    }
    return Result.ok("操作成功");
}
<select id="followOrNot" resultType="int">
    select count(id) from tb_follow where user_id = #{userId} and follow_user_id = #{followUserId}
</select>
@Override
public Result followOrNot(Long followUserId) {
    UserDTO loginUser = UserHolder.getUser();
    Long userId = loginUser.getId();
    //查询是否关注
    int result = followMapper.followOrNot(userId, followUserId);
    return Result.ok(result != 0);
}

2.2 共同关注

共同关注实际上就是将{用户关注用户的集合}∩{目标用户关注用户的集合},显示出来就可以了

这里的思路是使用redis中的set这种数据结构

@Override
@Transactional
public Result follow(Long followUserId, Boolean isFollow) {
    UserDTO loginUser = UserHolder.getUser();
    //取决于你传的参数,到底是关注还是取关
    String key = "follows:"+ loginUser.getId().toString();
    if(BooleanUtil.isTrue(isFollow)){
        Follow follow = new Follow();
        follow.setUserId(loginUser.getId());
        follow.setFollowUserId(followUserId);
        save(follow);
        stringRedisTemplate.opsForSet().add(key,followUserId.toString());
    }else{
        //取关,删除 delete from tb_follow where user_id = ? and follower_id = ?
        Boolean isSuccess = followMapper.cancelFollow(loginUser.getId(), followUserId);
        if(BooleanUtil.isTrue(isSuccess)){
            stringRedisTemplate.opsForSet().remove(key,followUserId.toString());
        }
    }
    return Result.ok("操作成功");
}
public Result commonFollow(Long otherUserId) {
    Long userId = UserHolder.getUser().getId();
    String key4ThisUser = "follows"+userId;
    String key4OtherUser = "follows"+otherUserId;
    //使用redis
    Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key4ThisUser, key4OtherUser);
    if(intersect == null || intersect.isEmpty()){
        return Result.ok(Collections.emptyList());
    }
    List<Long> userIds = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
    List<UserDTO> userDTOs = userService.listByIds(userIds).
            stream().
            map(user -> BeanUtil.copyProperties(user, UserDTO.class))
            .collect(Collectors.toList());
    //数据脱敏
    return Result.ok(userDTOs);
}

2.3 关注推送

feed流实现方案分析

关注推送也叫做Feed流,直接翻译为投喂,为用户持续地提供沉浸式的体验,通过无限下拉刷新获取新的信息

实现的有两种模式

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

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

智能排序:利用智能算法屏蔽掉用户不感兴趣,推送用户感兴趣的内容来吸引用户

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

基于Timeline的模式的实现

  • 拉取(pull)模式 :这也叫做读扩散

如图所示,首先张三和李四发布了两条消息,这两条消息除了自身的数据内容之外,还带有一个时间戳,用来标识这个消息产生的时间,当赵六与张三和李四建立了关注的关系后,就可以通过创建消息摘要(就是将消息的概要信息,比如说谁发送的,啥时候推送的,而不是直接将消息数据复制一份到收件箱中,每一次数据的读取都需要到对应的发件箱中读取消息)的方式,将张三和李四所发布的消息拉取到赵六的收件箱中,在收件箱做时间戳的排序,供给用户查看

这种模式的优缺点:

它首先是节省内容的,因为同一个消息内容,在存储中只占一份,不会导致信息的冗余

会导致一定的延迟,这是因为每次都需要去拉取对应的信息

  • 推送(push)模式:写扩散

这种模式的特点是,没有发件箱的这个角色存在,因此需要在收件箱中都保存一份副本。

首先它的延迟是很低的,因为不需要用户进行实时拉取,一旦接收到推送的消息就可以使用

内存浪费,相同的内容可能会存储相当多次

  • 推送/拉取模式:读写混合模式

简单来说就是对用户进行分类,对于那些活跃用户,为了提高他们的体验,降低延时,使用推送模式

对于普通用户,为了避免内存的浪费,使用拉取模式

基于推模式实现关注推送功能

需要采用滚动分页模式进行实现,记录每一次查询的最后一条,下一次查询从这记录的最后一条开始

那么最后一条记录什么呢,SortedSet可以记录每一条记录的score,同时具有范围查询的功能,因此我们可以记录下查询的最后一条元素的score,去查询一定范围内的score的记录,同时维护这个最后一条元素score,最终实现滚动查询

public Result saveBlog(Blog blog) {
    // 获取登录用户
    Long userId = UserHolder.getUser().getId();
    blog.setUserId(userId);
    // 保存探店博文,推送到粉丝收件箱(redis)
    boolean isSuccess = this.save(blog);
    if(!isSuccess){
        return Result.fail("推送失败!");
    }
    //查询笔记作者的所有粉丝
    List<Follow> allFollowers = followMapper.findAllFollower(userId);
    for (Follow follower : allFollowers) {
        //4.1 获取粉丝ID
        Long followerId = follower.getId();
        //4.2 推送
        String key = "feed:"+followerId;
        stringRedisTemplate.opsForZSet().add(key,blog.getId().toString(),System.currentTimeMillis());
    }
    // 返回id
    return Result.ok(blog.getId());
}
public Result pushByPage(Integer offset, Long lastId) {
    //实现滚动分页查询逻辑
    //1. 获取当前用户
    UserDTO user = UserHolder.getUser();
    Long userId = user.getId();
    //2. 查询收件箱
    String key = RedisConstants.FEED_KEY+userId;
    //3. 解析数据:blogId、minTime、offset
    //Zrevrange key max min LIMIT offset count
    Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0, lastId, offset, 3);
    if(typedTuples == null || typedTuples.isEmpty()){
        return Result.ok(Collections.emptyList());
    }
    //4. 数据脱敏,封装返回
    List<Long> ids = new ArrayList<>(typedTuples.size());
    long minTime = 0;
    int os = 1;
    for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
        //获取id
        String id = typedTuple.getValue();
        //获取时间戳
        long time = typedTuple.getScore().longValue();
        if(time == minTime){
            os++;
        }else{
            minTime = time;
            os = 1;
        }
        ids.add(Long.valueOf(id));
    }
    List<Blog> blogs = this.listByIds(ids);
    //处理博客
    for (Blog blog : blogs) {
        getUser(blog);
        isBolgLiked(blog);
    }
    ScrollResult scrollResult = new ScrollResult();
    scrollResult.setOffset(os);
    scrollResult.setMinTime(minTime);
    scrollResult.setList(blogs);
    return Result.ok(scrollResult);
}

3. 用户签到案例

3.1 BitMap

用0和1来标示业务的状态,这种思路称之为位图

Redis底层是使用String类型的数据结构来实现BitMap的,因此最大的上限是512M,转化为bit就是2^32个bit位,基础指令如:

  • 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出现的位置

3.2 实现签到功能

因为BitMap底层是基于String实现的,因此其相关的操作也都封装在字符串的相关操作中了

@Override
public Result sign() {
    Long userId = UserHolder.getUser().getId();
    LocalDateTime now = LocalDateTime.now();
    //获取当前年份/月份,组装key
    int year = now.getYear();
    int month = now.getMonth().getValue();
    String key = RedisConstants.USER_SIGN_KEY+userId.toString()+year+":"+month;
    //获取当前是月份的第几天,setBit
    int day = now.getDayOfMonth();
    Boolean isSuccess = stringRedisTemplate.opsForValue().setBit(key, day-1, true);
    if(BooleanUtil.isTrue(isSuccess)){
        return Result.ok("签到成功!");
    }
    return Result.fail("签到失败,请重试!");
}

3.3 统计连续签到功能

@Override
public Result signCount() {
    Long userId = UserHolder.getUser().getId();
    //获取天数
    LocalDateTime now = LocalDateTime.now();
    int month = now.getMonthValue();
    int day = now.getDayOfMonth();
    int year = now.getYear();
    String key = RedisConstants.USER_SIGN_KEY+userId.toString()+year+":"+month;
    List<Long> result = stringRedisTemplate.opsForValue().bitField(
            key,
            BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(day - 1)).valueAt(0)
    );
    if(result == null || result.isEmpty()){
        return Result.ok();
    }
    Long resultNum = result.get(0);
    if(resultNum == null || resultNum == 0L){
        return Result.ok();
    }
    int cnt=0;
    while(resultNum!=0){
        if((resultNum &1) == 0L){
            break;
        }
        cnt++;
        resultNum>>>=1;
    }
    return Result.ok(cnt);
}

4.UV统案例

4.1 HyperLogLog用法

  • UV:全称为Unique Visitor,也叫做独立访客量,是指通过互联网访问、浏览这个网页的自然人, 1天内同一个访客多次访问该网站,只记录一次
  • PV:全称为Page View,也叫作页面访问量或者点击量,用户每访问网站的一个页面,记录一次PV,用户多次打开页面,则记录多次PV,往往用来衡量网站的流量

HyperLogLog是从LogLog算法派生出来的概率算法,用于确定非常大的集合的基数,而不需要存储所有值,Redis是基于String结构实现的,单个HLL的内存永远小于16kb,其测量结果是有概率性的,有误差存在

String[] values = new String[1000];
for (int i = 0; i < 1000000; i++) {
    int j = i%1000;
    values[j] = "user"+i;
    if(j == 999){
        stringRedisTemplate.opsForHyperLogLog().add("hl2",values);
    }
}
//统计
Long count = stringRedisTemplate.opsForHyperLogLog().size("hl2");
System.out.println(count);

文章作者: 穿山甲
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 穿山甲 !
  目录