Redis从入门到实战——实战篇(下)

四、达人探店

1. 发布探店笔记

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

  • tb_blog:探店笔记表,包含笔记中的标题、文字、图片等
  • tb_blog_comments:其他用户对探店笔记的评价

步骤①:根据个人情况修改路径

注:建议把nginx.conf文件里的负载均衡删了,重新加载配置

案例:实现查看发布探店笔记的接口

需求:点击首页的探店笔记,会进入详情页面,实现该页面的查询接口:

实现步骤:

①BlogController

java 复制代码
package com.hmdp.controller;


import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.Blog;
import com.hmdp.entity.User;
import com.hmdp.service.IBlogService;
import com.hmdp.service.IUserService;
import com.hmdp.utils.SystemConstants;
import com.hmdp.utils.UserHolder;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.util.List;

/**
 * <p>
 * 前端控制器
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@RestController
@RequestMapping("/blog")
public class BlogController {

    @Resource
    private IBlogService blogService;

    // ... ...

    @GetMapping("/hot")
    public Result queryHotBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
        return blogService.queryHotBlog(current);
    }

    /**
     * 查询blog详情
     * @param id
     * @return
     */
    @GetMapping("/{id}")
    public Result queryBlogById(@PathVariable("id") Long id) {
        return blogService.queryBlogById(id);
    }
}

②IBlogService

java 复制代码
package com.hmdp.service;

import com.hmdp.dto.Result;
import com.hmdp.entity.Blog;
import com.baomidou.mybatisplus.extension.service.IService;

/**
 * <p>
 *  服务类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
public interface IBlogService extends IService<Blog> {

    
    Result queryHotBlog(Integer current);

    Result queryBlogById(Long id);
}

③BlogServiceImpl

java 复制代码
package com.hmdp.service.impl;

import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
import com.hmdp.entity.Blog;
import com.hmdp.entity.User;
import com.hmdp.mapper.BlogMapper;
import com.hmdp.service.IBlogService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.service.IUserService;
import com.hmdp.utils.SystemConstants;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
    @Resource
    private IUserService userService;

    @Override
    public Result queryHotBlog(Integer current) {
        // 根据用户查询
        Page<Blog> page = query()
                .orderByDesc("liked")
                .page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
        // 获取当前页数据
        List<Blog> records = page.getRecords();
        // 查询用户
        records.forEach(this::queryBlogUser);
        return Result.ok(records);
    }

    @Override
    public Result queryBlogById(Long id) {
        // 1. 查询blog
        Blog blog = getById(id);
        if (blog == null) {
            return Result.fail("笔记不存在!");
        }

        // 2. 查询blog有关的用户
        queryBlogUser(blog);

        // 3. 结果返回
        return Result.ok(blog);
    }

    private void queryBlogUser(Blog blog) {
        Long userId = blog.getUserId();
        User user = userService.getById(userId);
        blog.setName(user.getNickName());
        blog.setIcon(user.getIcon());
    }
}

效果:

2. 点赞

在首页的探店笔记排行榜和探店图文详情页都有点赞的功能:

案例:完善点赞功能

需求:

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

实现步骤:

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

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

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

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

①BlogController

java 复制代码
package com.hmdp.controller;


import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.Blog;
import com.hmdp.entity.User;
import com.hmdp.service.IBlogService;
import com.hmdp.service.IUserService;
import com.hmdp.utils.SystemConstants;
import com.hmdp.utils.UserHolder;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.util.List;


@RestController
@RequestMapping("/blog")
public class BlogController {

    @Resource
    private IBlogService blogService;


    @PutMapping("/like/{id}")
    public Result likeBlog(@PathVariable("id") Long id) {
        return blogService.likeBlog(id);
    }


    @GetMapping("/hot")
    public Result queryHotBlog(@RequestParam(value = "current", defaultValue = "1") Integer current) {
        return blogService.queryHotBlog(current);
    }

    /**
     * 查询blog详情
     * @param id
     * @return
     */
    @GetMapping("/{id}")
    public Result queryBlogById(@PathVariable("id") Long id) {
        return blogService.queryBlogById(id);
    }
}

②IBlogService

java 复制代码
package com.hmdp.service;

import com.hmdp.dto.Result;
import com.hmdp.entity.Blog;
import com.baomidou.mybatisplus.extension.service.IService;


public interface IBlogService extends IService<Blog> {

    
    Result queryHotBlog(Integer current);

    Result queryBlogById(Long id);

    Result likeBlog(Long id);
}

③BlogServiceImpl

java 复制代码
package com.hmdp.service.impl;

import cn.hutool.core.util.BooleanUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
import com.hmdp.entity.Blog;
import com.hmdp.entity.User;
import com.hmdp.mapper.BlogMapper;
import com.hmdp.service.IBlogService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.service.IUserService;
import com.hmdp.utils.RedisConstants;
import com.hmdp.utils.SystemConstants;
import com.hmdp.utils.UserHolder;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;
 
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
    @Resource
    private IUserService userService;
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 分页查询热点笔记
     * @param current
     * @return
     */
    @Override
    public Result queryHotBlog(Integer current) {
        // 根据用户查询
        Page<Blog> page = query()
                .orderByDesc("liked")
                .page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
        // 获取当前页数据
        List<Blog> records = page.getRecords();
        // 查询用户和是否点赞
        records.forEach(blog -> {
            this.queryBlogUser(blog);
            this.isBlogLiked(blog);
        });

        return Result.ok(records);
    }

    /**
     * 查询笔记详情
     * @param id
     * @return
     */
    @Override
    public Result queryBlogById(Long id) {
        // 1. 查询blog
        Blog blog = getById(id);
        if (blog == null) {
            return Result.fail("笔记不存在!");
        }

        // 2. 查询blog有关的用户
        queryBlogUser(blog);

        // 3. 查询blog是否被点赞了
        isBlogLiked(blog);

        // 4. 结果返回
        return Result.ok(blog);
    }

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

        blog.setIsLike(BooleanUtil.isTrue(isMember));
    }

    private void queryBlogUser(Blog blog) {
        Long userId = blog.getUserId();
        User user = userService.getById(userId);
        blog.setName(user.getNickName());
        blog.setIcon(user.getIcon());
    }

    /**
     * 点赞或取消点赞笔记
     * @param id
     * @return
     */
    @Override
    public Result likeBlog(Long id) {
        // 1. 获取登录用户
        Long userId = UserHolder.getUser().getId();

        // 2. 判断当前登录用户是否已经点赞
        String key = RedisConstants.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());
            }
        }

        // 5. 结果返回
        return Result.ok();
    }
}

注:@Resource按名称匹配,@Autowired按类型匹配

3. 点赞排行榜

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

|------|------------|--------|---------------|
| | List | Set | SortedSet |
| 排序方式 | 按添加顺序排序 | 无法排序 | 根据score值排序 |
| 唯一性 | 不唯一 | 唯一 | 唯一 |
| 查找方式 | 按索引查找或首尾查找 | 根据元素查找 | 根据元素查找 |

①修改之前的点赞逻辑,以及笔记查询逻辑

java 复制代码
package com.hmdp.service.impl;

import cn.hutool.core.util.BooleanUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
import com.hmdp.entity.Blog;
import com.hmdp.entity.User;
import com.hmdp.mapper.BlogMapper;
import com.hmdp.service.IBlogService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.service.IUserService;
import com.hmdp.utils.RedisConstants;
import com.hmdp.utils.SystemConstants;
import com.hmdp.utils.UserHolder;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.List;


@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {
    @Resource
    private IUserService userService;
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 分页查询热点笔记
     * @param current
     * @return
     */
    @Override
    public Result queryHotBlog(Integer current) {
        // 根据用户查询
        Page<Blog> page = query()
                .orderByDesc("liked")
                .page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));
        // 获取当前页数据
        List<Blog> records = page.getRecords();
        // 查询用户和是否点赞
        records.forEach(blog -> {
            this.queryBlogUser(blog);
            this.isBlogLiked(blog);
        });

        return Result.ok(records);
    }

    /**
     * 查询笔记详情
     * @param id
     * @return
     */
    @Override
    public Result queryBlogById(Long id) {
        // 1. 查询blog
        Blog blog = getById(id);
        if (blog == null) {
            return Result.fail("笔记不存在!");
        }

        // 2. 查询blog有关的用户
        queryBlogUser(blog);

        // 3. 查询blog是否被点赞了
        isBlogLiked(blog);

        // 4. 结果返回
        return Result.ok(blog);
    }

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

        blog.setIsLike(score != null);
    }

    private void queryBlogUser(Blog blog) {
        Long userId = blog.getUserId();
        User user = userService.getById(userId);
        blog.setName(user.getNickName());
        blog.setIcon(user.getIcon());
    }

    /**
     * 点赞或取消点赞笔记
     * @param id
     * @return
     */
    @Override
    public Result likeBlog(Long id) {
        // 1. 获取登录用户
        Long userId = UserHolder.getUser().getId();

        // 2. TODO 判断当前登录用户是否已经点赞
        String key = RedisConstants.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 TODO 保存用户到Redis的zset集合 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的zset集合移除
            if (isSuccess) {
                stringRedisTemplate.opsForZSet().remove(key, userId.toString());
            }
        }

        // 5. 结果返回
        return Result.ok();
    }
}

②BlogController

java 复制代码
    /**
     * 查询笔记的点赞用户列表TOP5
     * @param id
     * @return
     */
    @GetMapping("/likes/{id}")
    public Result queryBlogLikes(@PathVariable("id") Long id) {
        return blogService.queryBlogLikes(id);
    }

③IBlogService

java 复制代码
Result queryBlogLikes(Long id);

④BlogServiceImpl

java 复制代码
    /**
     * 查询笔记的点赞用户列表TOP5
     *
     * @param id
     * @return
     */
    @Override
    public Result queryBlogLikes(Long id) {
        // 1. 查询top5的点赞用户 zrange key 0 4
        String key = RedisConstants.BLOG_LIKED_KEY + id;
        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查询用户
        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);
    }

五、好友关注

1. 关注和取关

在探店图文的详情页面中,可以关注发布笔记的作者:

案例:实现关注和取关功能

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

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

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

①FollowController

java 复制代码
package com.hmdp.controller;


import com.hmdp.dto.Result;
import com.hmdp.service.IFollowService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

/**
 * <p>
 *  前端控制器
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@RestController
@RequestMapping("/follow")
public class FollowController {
    @Autowired
    private IFollowService followService;


    /**
     * 关注或取关
     * @param followUserId
     * @param isFollow
     * @return
     */
    @PutMapping("/{id}/{isFollow}")
    public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {
        return followService.follow(followUserId, isFollow);
    }


    /**
     * 判断是否关注
     * @param followUserId
     * @return
     */
    @GetMapping("/or/not/{id}")
    public Result isFollow(@PathVariable("id") Long followUserId) {
        return followService.isFollow(followUserId);
    }
}

②IFollowService

java 复制代码
package com.hmdp.service;

import com.hmdp.dto.Result;
import com.hmdp.entity.Follow;
import com.baomidou.mybatisplus.extension.service.IService;

/**
 * <p>
 *  服务类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
public interface IFollowService extends IService<Follow> {

    Result follow(Long followUserId, Boolean isFollow);

    Result isFollow(Long followUserId);
}

③FollowServiceImpl

java 复制代码
package com.hmdp.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.Follow;
import com.hmdp.mapper.FollowMapper;
import com.hmdp.service.IFollowService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.UserHolder;
import org.springframework.stereotype.Service;

/**
 * <p>
 *  服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {

    /**
     * 关注或取关
     * @param followUserId
     * @param isFollow
     * @return
     */
    @Override
    public Result follow(Long followUserId, Boolean isFollow) {
        // 1. 获取登录用户
        UserDTO user = UserHolder.getUser();
        if (user == null) {
            return Result.fail("请先登录");
        }
        Long userId = user.getId();

        // 2. 判断是关注或是取关
        if (isFollow) {
            // 3. 关注,新增数据
            Follow follow = new Follow();
            follow.setUserId(userId);
            follow.setFollowUserId(followUserId);
            save(follow);
        } else {
            // 4. 取关,删除数据 delete from tb_follow where userId = ? and follow_user_id = ?
            remove(new QueryWrapper<Follow>()
                    .eq("user_id", userId)
                    .eq("follow_user_id", followUserId));
        }

        // 5. 结果返回
        return Result.ok();
    }

    /**
     * 判断是否关注
     * @param followUserId
     * @return
     */
    @Override
    public Result isFollow(Long followUserId) {
        // 1. 获取登录用户
        UserDTO user = UserHolder.getUser();
        if (user == null) {
            return Result.fail("请先登录");
        }
        Long userId = user.getId();

        // 2. 查询是否关注
        Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();

        // 3. 判断count是否大于0
        return Result.ok(count > 0);
    }
}

2. 共同关注

点击博主头像,可以进入博主首页:

①UserController

java 复制代码
package com.hmdp.controller;


import cn.hutool.core.bean.BeanUtil;
import com.hmdp.dto.LoginFormDTO;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import com.hmdp.entity.UserInfo;
import com.hmdp.service.IUserInfoService;
import com.hmdp.service.IUserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import javax.servlet.http.HttpSession;


@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {

    @Resource
    private IUserService userService;

    @Resource
    private IUserInfoService userInfoService;

    // ... ...

    /**
     * 根据id查询用户
     * @param userId
     * @return
     */
    @GetMapping("/{id}")
    public Result queryUserById(@PathVariable("id") Long userId) {
        // 1. 查询用户
        User user = userService.getById(userId);
        if (user == null) {
            return Result.ok();
        }

        // 2. 属性拷贝
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);

        // 3. 返回
        return Result.ok(userDTO);
    }

}

②BlogController

java 复制代码
package com.hmdp.controller;


import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hmdp.dto.Result;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.Blog;
import com.hmdp.entity.User;
import com.hmdp.service.IBlogService;
import com.hmdp.service.IUserService;
import com.hmdp.utils.SystemConstants;
import com.hmdp.utils.UserHolder;
import org.springframework.web.bind.annotation.*;

import javax.annotation.Resource;
import java.util.List;


@RestController
@RequestMapping("/blog")
public class BlogController {

    @Resource
    private IBlogService blogService;

    // ... ...
    
    @GetMapping("/of/user")
    public Result queryBlogByUserId(
            @RequestParam(value = "current", defaultValue = "1") Integer current,
            @RequestParam("id") Long id) {
        // 1. 根据用户查询
        Page<Blog> page = blogService.query()
                .eq("user_id", id)
                .page(new Page<>(current, SystemConstants.MAX_PAGE_SIZE));

        // 2. 获取当前页数据
        List<Blog> records = page.getRecords();

        // 3. 结果返回
        return Result.ok(records);
    }

}

案例:实现共同关注功能

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

①FollowController

java 复制代码
    /**
     * 查询共同关注
     * @param id
     * @return
     */
    @GetMapping("/common/{id}")
    public Result followCommons(@PathVariable("id") Long id) {
        return followService.followCommons(id);
    }

②IFollowService

java 复制代码
Result followCommons(Long id);

③FollowServiceImpl

java 复制代码
    /**
     * 查询共同关注
     * @param id
     * @return
     */
    @Override
    public Result followCommons(Long id) {
        // 1. 获取当前用户
        UserDTO user = UserHolder.getUser();
        if (user == null) {
            return Result.fail("请先登录");
        }
        Long userId = user.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(u -> BeanUtil.copyProperties(u, UserDTO.class))
                .collect(Collectors.toList());

        // 5. 结果返回
        return Result.ok(users);
    }

3. 关注推送

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

Feed流的模式

Feed流产品有两种常见模式:

  • Timeline :不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈
    • 优点:信息全面,不会有缺失。并且实现也相对简单
    • 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
  • 智能排序 :利用智能推荐算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
    • 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
    • 缺点:如果算法不精准,可能起到反作用

本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:

  • 拉模式:也叫做读扩散(内存占用较低,但延迟高)
  • 推模式:也叫写扩散(延迟较低,但内存占用高)
  • 推拉结合:也叫做读写混合,兼具推和拉两种模式的优点

|--------|------|-----------|-------------|
| | 拉模式 | 推模式 | 推拉结合 |
| 写比例 | 低 | 高 | 中 |
| 读比例 | 高 | 低 | 中 |
| 用户读取延迟 | 高 | 低 | 低 |
| 实现难点 | 复杂 | 简单 | 很复杂 |
| 使用场景 | 很少使用 | 用户量少、没有大V | 过千万的用户量,有大V |

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

需求:

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

Feed流的分页问题

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

Feed流的滚动分页

实现步骤:

①BlogController

java 复制代码
    @PostMapping
    public Result saveBlog(@RequestBody Blog blog) {
        return blogService.saveBlog(blog);
    }

②IBlogService

java 复制代码
Result saveBlog(Blog blog);

③BlogServiceImpl

java 复制代码
    @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. 查询笔记作者的所有粉丝
        List<Follow> fans = followService.query().eq("follow_user_id", user.getId()).list();

        // 4. 推送笔记id给所有粉丝
        for (Follow fan : fans) {
            // 4.1 获取粉丝id
            Long userId = fan.getUserId();
            // 4.2 推送给粉丝T
            String key = RedisConstants.FEED_KEY + userId;
            stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
        }

        // 5. 返回id
        return Result.ok(blog.getId());
    }

案例:实现关注推送页面的分页查询

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

滚动分页的查询参数:

  • max:当前时间戳 | 上一次查询的最小时间戳
  • min:0
  • offset:0 | 在上一次的结果中,与最小值一样的元素的个数
  • count:3(与前端约定)

①BlogController

java 复制代码
    /**
     * 查询所关注博主的笔记
     * @param max
     * @param offset
     * @return
     */
    @GetMapping("/of/follow")
    public Result queryBlogOfFollow(@RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset) {
        return blogService.queryBlogOfFollow(max, offset);
    }

②IBlogService

java 复制代码
    Result queryBlogOfFollow(Long max, Integer offset);

③BlogServiceImpl

java 复制代码
    /**
     * 查询所关注博主的笔记
     * @param max
     * @param offset
     * @return
     */
    @Override
    public Result queryBlogOfFollow(Long max, Integer offset) {
        // 1. 获取当前用户
        UserDTO user = UserHolder.getUser();
        if (user == null) {
            return Result.fail("请先登录");
        }
        Long userId = user.getId();

        // 2. 获取收件箱
        String key = RedisConstants.FEED_KEY + userId;
        // ZREVRANGEBYSCORE key Min Max Score LIMIT offset count
        Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
                .reverseRangeByScoreWithScores(key, 0, max, offset, 3);

        // 3. 非空判断
        if (typedTuples == null || typedTuples.isEmpty()) {
            return Result.ok();
        }

        // 4. 解析数据:blogId、minTime(时间戳)、offset
        List<Long> ids = new ArrayList<>(typedTuples.size());
        long minTime = 0;
        int os = 1;
        for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
            // 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;
            }
        }

        // 5. 根据blogId查询blog
        String idStr = StrUtil.join(",", ids);
        List<Blog> blogList = query().in("id", ids)
                .last("ORDER BY FIELD(id, " + idStr + ")")
                .list();

        for (Blog blog : blogList) {
            // 5.1 查询blog的作者
            queryBlogUser(blog);
            // 5.2 查询blog是否被当前用户点赞
            isBlogLiked(blog);
        }

        // 6. 封装返回
        ScrollResult result = new ScrollResult();
        result.setList(blogList);
        result.setOffset(os);
        result.setMinTime(minTime);

        return Result.ok(result);
    }

效果:

六、附近的商户

1. GEO数据结构

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

  • GEOADD:添加一个地理空间信息,包含:经度(longitude)、维度(latitude)、值(member)
  • GEODIST:计算指定的两个点之间的距离并返回
  • GEOHASH:将指定member的坐标转为hash字符串形式并返回
  • GEOPOS:返回指定member的坐标
  • GEORADIUS:指定圆心、半径,找到该园内包含的所有member,并按照与圆心之间的距离排序后返回。6.2以后已废弃
  • GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2新功能
  • GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。6.2新功能

案例:练习Redis的GEO功能

需求:

  1. 添加下面几条数据
  • 北京南站(116.378248 39.865275)
  • 北京站(116.42803 39.903738)
  • 北京西站(116.322287 39.893729)
  1. 计算北京南站到北京西站的距离
  1. 搜索天安门(116.397904 39.909005)附近10km内的所有火车站,并按照距离升序排序

2. 附近商户搜索

在首页中点击某个频道,即可看到频道下的商户:

按照商户类型做分组,类型相同的商户作为同一组,以typeId为key存入同一个GEO集合中即可

java 复制代码
package com.hmdp;

import com.hmdp.entity.Shop;
import com.hmdp.service.IShopService;
import com.hmdp.utils.RedisConstants;
import com.hmdp.utils.RedisWorker;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.geo.Point;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;

@SpringBootTest
class HmDianPingApplicationTests {

    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private IShopService shopService;

    @Test
    void loadShopData() {
        // 1. 查询店铺信息
        List<Shop> list = shopService.list();

        // 2. 按照商户类型分组 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();
            // 3.2 获取同类型的店铺的集合
            List<Shop> value = entry.getValue();
            String key = RedisConstants.SHOP_GEO_KEY + typeId;
            List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());


            // 3.3 批量写入redis GEOADD key 经度 纬度 member
            for (Shop shop : value) {
                // redisTemplate.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())
                ));
            }
            redisTemplate.opsForGeo().add(key, locations);
        }
    }
}

①SpringDataRedis的2.3.9版本并不支持Redis6.2提供的GEOSEARCH命令,因此我们需要提高其版本,修改POM文件,内容如下:

XML 复制代码
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.data</groupId>
                    <artifactId>spring-data-redis</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>io.lettuce</groupId>
                    <artifactId>lettuce-core</artifactId>
                </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>

②ShopController

java 复制代码
    /**
     * 根据商铺类型分页查询商铺信息
     * @param typeId 商铺类型
     * @param current 页码
     * @return 商铺列表
     */
    @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);
    }

③IShopService

java 复制代码
Result queryShopByType(Integer typeId, Integer current, Double x, Double y);

④ShopServiceImpl

java 复制代码
    /**
     * 根据商铺名称关键字分页查询商铺信息
     * @param typeId
     * @param current
     * @param x
     * @param y
     * @return
     */
    @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 = RedisConstants.SHOP_GEO_KEY + typeId;
        GeoResults<RedisGeoCommands.GeoLocation<String>> results = redisTemplate.opsForGeo()
                .search(
                        key,
                        GeoReference.fromCoordinate(x, y),
                        new Distance(5000),
                        RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end)

                );

        // 4. 解析出shopId
        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(r -> {
            // 4.2 获取店铺id
            String shopIdStr = r.getContent().getName();
            ids.add(Long.valueOf(shopIdStr));
            // 4.3 获取距离
            Distance distance = r.getDistance();
            distanceMap.put(shopIdStr, distance);
        });

        // 5. 根据id查询shop
        String idStr = StrUtil.join(",", ids);
        List<Shop> shopList = query().in("id", ids)
                .last("ORDER BY FIELD(id," + idStr + ")")
                .list();
        for (Shop shop : shopList) {
            shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
        }

        // 6. 结果返回
        return Result.ok(shopList);
    }

七、用户签到

1. BitMap用法

假如有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数组,并以十进制形式返回(只读)
bash 复制代码
BITFIELD_RO mykey GET u4 0  # 只读方式获取位字段
  • BITOP:将多个BitMap的结果做位运算(与、或、异或)
  • BITPOS:查找bit数组中指定范围内第一个0或1出现的位置

2. 签到功能

案例:签到功能

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

提示:因为BitMap底层是基于String数据结构,因此其操作也都封装在字符串相关操作中了。

①UserController

java 复制代码
    /**
     * 用户签到
     * @return
     */
    @PostMapping("/sign")
    public Result sign() {
        return userService.sign();
    }

②IUserService

java 复制代码
    Result sign();

③UserServiceImpl

java 复制代码
    /**
     * 用户签到
     * @return
     */
    @Override
    public Result sign() {
        // 1. 获取当前登录用户
        UserDTO user = UserHolder.getUser();
        if (user == null) {
            return Result.fail("请先登录");
        }
        Long userId = user.getId();

        // 2. 获取日期
        LocalDateTime now = LocalDateTime.now();

        // 3. 拼接key
        String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
        String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;

        // 4. 获取今天是本月的第几天
        int dayOfMonth = now.getDayOfMonth();

        // 5. 写入redis SETBIT key offset 1
        redisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);

        // 6. 结果返回
        return Result.ok();
    }

3. 签到统计

问题1:什么是连续签到次数?

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

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

bash 复制代码
BITFIELD key GET u[dayOfMonth] 0

问题3:如何从后向前遍历每个bit位?

与1做与运算,就能得到最后一个bit位。随后右移1位,下一个bit位就成为了最后一个bit位。

案例:实现签到统计功能

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

①UserController

java 复制代码
    /**
     * 连续签到天数
     * @return
     */
    @GetMapping("/sign/count")
    public Result signCount() {
        return userService.signCount();
    }

②IUserService

java 复制代码
    Result signCount();

③UserServiceImpl

java 复制代码
    /**
     * 连续签到天数
     * @return
     */
    @Override
    public Result signCount() {
        // 1. 获取当前登录用户
        UserDTO user = UserHolder.getUser();
        if (user == null) {
            return Result.fail("请先登录");
        }
        Long userId = user.getId();

        // 2. 获取日期
        LocalDateTime now = LocalDateTime.now();

        // 3. 拼接key
        String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
        String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;

        // 4. 获取今天是本月的第几天
        int dayOfMonth = now.getDayOfMonth();

        // 5. 获取本月截止今天为止的所有签到记录,返回的是一个十进制的数字
        List<Long> result = redisTemplate.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) {
            // 让这个数字与1做与运输,得到数字的最后一个bit位
            // 判断这个bit位是否是0
            if ((num & 1) == 0) {
                // 如果是0,说明未签到,结束
                break;
            } else {
                // 如果不为0,说明已签到,计数器加1
                count++;
            }
            // 把数字右移一位,抛弃最后一个bit位,继续下一个bit位
            num >>>= 1;
        }

        // 7. 结果返回
        return Result.ok(count);
    }

八、UV统计

1. HyperLogLog用法

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

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

HyperLogLog(HLL)是从LogLog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理可以参考:HyperLogLog 算法的原理讲解以及 Redis 是如何应用它的聪明的你可能会马上想到,用 HashMap 这种数 - 掘金

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

2. UV统计

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

java 复制代码
    @Test
    void testHyperLogLog() {
        // 准备数组,装用户数据
        String[] users = new String[1000];
        // 数组角标
        int index = 0;
        for (int i = 1; i <= 1000000; i++) {
            // 赋值
            users[index++] = "user_" + i;
            // 每1000条发送一次
            if (i % 1000 == 0) {
                index = 0;
                redisTemplate.opsForHyperLogLog().add("hll1", users);
            }
        }
        // 统计数量
        Long size = redisTemplate.opsForHyperLogLog().size("hll1");
        System.out.println("size = " + size);
    }
相关推荐
六元七角八分6 分钟前
pom.xml
xml·数据库
Achou.Wang9 分钟前
源码分析 golang bigcache 高性能无 GC 开销的缓存设计实现
开发语言·缓存·golang
虚行16 分钟前
Mysql 数据同步中间件 对比
数据库·mysql·中间件
奥尔特星云大使17 分钟前
mysql读写分离中间件Atlas安装部署及使用
数据库·mysql·中间件·读写分离·atlas
牛马baby38 分钟前
【mysql】in 用到索引了吗?
数据库·mysql·in
杀气丶42 分钟前
L2JBR - 修复数据库编码为UTF8
数据库·sql·oracle
-Xie-1 小时前
Mysql杂志(三十)——索引失效情况
数据库·mysql
为java加瓦1 小时前
Lombok @Data 注解在 Spring Boot 项目中的深度应用与实践指南
java·开发语言·数据库
青山撞入怀11141 小时前
sql题目练习-子查询
java·数据库·sql
程序新视界1 小时前
什么是MySQL分区?
数据库·mysql·dba