《引言》
(下)篇将记录 Redis 实战篇 最后的一些学习内容,希望大家能够点赞、收藏支持一下 Thanks♪ (・ω・)ノ,谢谢大家。
传送门(上):Redis 实战篇 ——《黑马点评》(上)
传送门(中):Redis 实战篇 ——《黑马点评》(中)
传送门(下):当-前-页
四、挚友关注
1.关注和取关
在业务中,用户之间存在着常见的关注功能,通过关注 up主来及时的获取其更新的内容。我们可以关注或取关对应的用户。
想要实现关注与取关功能,起首会在访问页面时查询当前用户与展示的用户之间的关系并展示出来,点击后根据现有关系发送请求举行关注或是取关。
- public Result isFollow(Long followUserId) {
- Long userId = UserHolder.getUser().getId();
- Integer count = query().eq("user_id", userId).eq("follow_user_id", followUserId).count();
- return Result.ok(count > 0);
- }
复制代码 查询用户之间的关系,先获取当前用户的 Id,通过其举行查询得到个数,如果查询得到的个数大于 0 则存在返回 true 表示状态为已关注,反之则为 false 表示未关注。
- public Result follow(Long followUserId, Boolean isFollow) {
- Long userId = UserHolder.getUser().getId();
- if (isFollow){
- //关注
- Follow follow = new Follow();
- follow.setUserId(userId);
- follow.setFollowUserId(followUserId);
- boolean isSuccess = save(follow);
- }else {
- //取消关注
- remove(new QueryWrapper<Follow>()
- .eq("user_id",userId).eq("follow_user_id",followUserId));
- }
- return Result.ok();
- }
复制代码 该方法用于举行关注与取关利用,当点击按钮发送请求后,根据传来的 isFollow 值来判断当进步行的利用是关注或是取关。关注利用需要现将其存入数据库中,取关就是根据 Id 从数据库中删除相应的数据。
点击关注后结果如下所示,上方提示利用成功,同时按钮状态变为取消关注,再次点击后取消关注。
2.共同关注
想要实现检察共同关注功能,需要先美满检察其他用户主页的功能,其主要通过查询用户及其发布的博客来展示在用户的主页上。
- @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);
- }
复制代码 根据用户 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);
- }
复制代码 根据用户 Id 查询用户的博客 信息并返回。
点击其他用户头像可以检察其个人信息,结果如下所示
接下来,就需要实现检察共同关注的功能。而我们可以借助 Redis 中 Set 数据类型的 SINTER 方法来检察两个 Set 集合中的交集部分,我们利用这一点,就可以简单地实现共同关注功能。
起首,我们需要在关注用户时将信息同步的存入到 Redis 的对应用户的集合中,其中存入 Redis 中的key 前缀为 follows 后接当前用户 Id 以作区分 。以是在原先的关注功能中关注后存入 Redis 中,同样的,在取消关注的利用成功后,将 Redis 中的数据也删除掉。
- public Result follow(Long followUserId, Boolean isFollow) {
- Long userId = UserHolder.getUser().getId();
- String followKey = "follows:" + userId;
- if (isFollow){
- //关注
- Follow follow = new Follow();
- follow.setUserId(userId);
- follow.setFollowUserId(followUserId);
- boolean isSuccess = save(follow);
- if(isSuccess){
- //关注成功后,存入 Redis 中
- stringRedisTemplate.opsForSet().add(followKey,followUserId.toString());
- }
- }else {
- //取消关注
- boolean isSuccess = remove(new QueryWrapper<Follow>()
- .eq("user_id",userId).eq("follow_user_id",followUserId));
- if (isSuccess) {
- stringRedisTemplate.opsForSet().remove(followKey, followUserId.toString());
- }
- }
- return Result.ok();
- }
复制代码 接着,我们就要去实现查询共同关注的功能:
- public Result followCommons(Long id) {
- //获取当前用户
- Long userId = UserHolder.getUser().getId();
- String key = "follows:" + userId;
- String key2 = "follows:" + id;
- //求交集
- Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key, key2);
- if (intersect == null || intersect.isEmpty()){
- return Result.ok(Collections.emptyList());
- }
- //解析 Id
- List<Long> ids = intersect.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);
- }
复制代码 获取当前用户 Id 后,拼接对应的字符串,分别对应自己与正在检察的用户,后用 intersect 方法求取交集,由于其中可能包含多个 Id,通过 stream 流的方式可以高效便捷的处理查询得到的 Id 并通过其举行查询得到对应的用户信息返回,用于展示在页面上。
最终结果如下所示
3.关注推送
在我们关注某一用户后,当其再次发布新的博客后,系统应将其推送给全部关注他的用户,例如微信的朋侪圈。这种方式也称作 Feed 流,可以将内容自动推送给用户。Feed 流有两种常见的模式:
① TimeLine:对内容举行简单的筛选,按照内容发布时间排序,常用于挚友或关注。例如朋侪圈
- 优点:信息全面,不会有缺失。并且实现也相对简单
- 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
② 智能排序:利用智能算法屏蔽掉违规的、用户不感兴趣的内容,推送用户感兴趣信息来吸引用户。例如抖音。
- 优点:投喂用户感兴趣信息,用户粘度很高,轻易着迷
- 缺点:如果算法不精准,可能起到反作用
这里选择使用 TimeLine 模式来实现关注后推送最新博客的功能,而这种模式的实现方式又分为三种:
- 拉模式:也称做读扩散,被关注者会将消息先存入发件箱中,关注者获取消息时会从发件箱中拉取消息到收件箱中。但这种模式在用户关注大量用户的情况下拉取消息时的延迟较大。
- 推模式:也称作写扩散,被关注者会直接将消息推送到关注者的收件箱中,关注者只需检察收件箱即可。但这种模式在用户被大量用户关注的情况下会发送大量消息,导致内存的大量斲丧。得当用户量少,没有大量粉丝的用户的场景。
- 推拉联合模式:根据具体的情况来分配给不同用户不同的模式。对于访问频繁、活跃的用户,对其使用推模式,保证其第一时间获取消息;对于不常常活跃的用户,对其使用拉模式,只有在其登录上线时主动的拉取发件箱的消息到收件箱中。得当用户量巨大,存在拥有大量粉丝的用户的场景。
在相识了上述的三种实现方式的优劣后,选择通过推模式来实现关注推送的功能。而在选择 Redis 中的数据结构来保存消息时,尤其是消息会随着时间不停的更新的情况下,不会选择使用 List 而是使用 SortedSet,由于 List 不支持滚动分页查询,其只支持角标或是首尾查询;而 SortedSet 可以通过分数(score)来排序,支持范围查询和分页查询,更得当处理消息随时间更新的情况。
● 第一步:需要先改造原先的发布博客的部分代码,在发布后将对应博客的 Id 发送给全部粉丝来实现推送。
- 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> follows = followService.query().eq("follow_user_id", user.getId()).list();
- // 4.推送笔记
- for (Follow follow : follows) {
- Long userId = follow.getId();
- String key = "feed:" + userId;
- stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMill
- }
- // 5.返回id
- return Result.ok(blog.getId());
- }
复制代码 起首,获取当前登录用户的 Id 并保存博客信息。接着查询当前用户的所用粉丝信息,循环推送给每一个粉丝。其中 key 为 feed:+ 粉丝 Id 构成,对应每一个粉丝都有一个收件箱,value 为博客的 Id,分数为当前的时间戳,便于在展示时举行排序。
● 第二步:在粉丝查询关注的用户更新的博客时,要实现对于博客内容的分页查询,而由于收件箱中的博客是按照时间来举行排序的,每当有新的内容时,按照角标查询得到的内容可能就会出现重复内容的错误,以是就需要通过分数来举行查询,记录每次查询的最后位置,从该位置继承查询,如许就克制了出现重复内容的错误。
Redis 中的对应指令
ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count]
max:分数的最大值
min:分数的最小值
[WITHSCORES]:可选,查询内容是否带上分数
LIMIT:
offset:偏移量,从小于等于最大值的第 N + 1 个元素开始查。
count:查询的数量
其中,我们只需关注 max 和 offset 这两个参数即可。min 默认为 0,count 按照规定确定。max 是当前查询时的时间戳,之后的查询中为上一次查询的数据中最小的得分;offset 在第一次查询时为 0,之后的查询中为 1,表示跳过前次查询的最后一个数据,同时如果出现查询出来得分相同的数据这种状况时,重复出现 n 次,偏移量(offset)就为 n 次。
- @Data
- public class ScrollResult {
- private List<?> list;
- private Long minTime;
- private Integer offset;
- }
复制代码 起首,创建一个类来存储用于返回滚动分页查询的数据。
- @Override
- public Result queryBlogOfFollow(Long max, Integer offset) {
- //1.获取当前用户
- Long userId = UserHolder.getUser().getId();
- //2.查询收件箱
- String key = FEED_KEY + userId;
- Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
- .reverseRangeByScoreWithScores(key, 0, max, offset, 3);
- //3.解析数据
- if (typedTuples == null || typedTuples.isEmpty()){
- return Result.ok();
- }
- List<Long> ids = new ArrayList<>(typedTuples.size());
- long minTime = 0;
- int os = 1;
- for (ZSetOperations.TypedTuple<String> tuple : typedTuples) {
- ids.add(Long.valueOf(tuple.getValue()));
- long time = tuple.getScore().longValue();
- if (time == minTime) {
- os++;
- }else {
- minTime = time;
- os = 1;
- }
- }
- //4.根据 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) {
- //查询笔记状态
- queryBlogUser(blog);
- isBlogLiked(blog);
- }
- //5.封装并返回
- ScrollResult r = new ScrollResult();
- r.setList(blogs);
- r.setOffset(os);
- r.setMinTime(minTime);
- return Result.ok(r);
- }
复制代码 传入的两个参数分别对应 Redis 命令中的 max 和 offset,获取到当前用户的 id 与前缀举行拼接后查询收件箱得到对应关注用户的更新博客的 id 集合。判断数据非空后对集合举行处理,主要是将其中的 id 存入新的集合中,并用 os 统计其中具有相同最小时间的数据的个数作为下一次查询的 offset。
接着就是根据博客的 id 从数据库中举行查询得到全部博客,但 MP 中普通的 listById 方法是根据 in 来举行查询的,并不能举行排序,以是需要通过 order by 来排序,其中的 idStr 通过 StrUtil 中的 join 方法拼接后得到。
- private void queryBlogUser(Blog blog) {
- Long userId = blog.getUserId();
- User user = userService.getById(userId);
- blog.setName(user.getNickName());
- blog.setIcon(user.getIcon());
- }
- private void isBlogLiked(Blog blog) {
- if (UserHolder.getUser() == null) {
- //用户未登录时无需查询是否点赞
- return;
- }
- Long userId = UserHolder.getUser().getId();
- String key = BLOG_LIKED_KEY + blog.getId();
- Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
- blog.setIsLike(score != null);
- }
复制代码 最后,全部的博客的需要举行状态的判断,用于在页面中展示正确的信息,判断后将数据分装进先前定义好的实体类中返回。
最终结果如下所示
五、附近商户
1.Redis 中的 GEO 数据结构
GEO 就是 Geolocation 的简写形式,代表地理坐标。Redis 在 3.2 版本中加入了对 GEO 的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
- GEOADD:添加一个地理空间信息,包含:经度(longitude) 、纬度(latitude) 、值(member)
- GEODIST:盘算指定的两个点之间的距离并返回
- GEOHASH:将指定 member 的坐标转为hash字符串形式并返回
- GEOPOS:返回指定 member 的坐标
- GEORADIUS:指定圆心、半径,找到该圆内包含的全部 member,并按照与圆心之间的距离排序后返回。
- GEOSEARCH:在指定范围内搜索 member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。
- GEOSEARCHSTORE:与 GEOSEARCH 功能一致,不外可以把结果存储到一个指定的 key。
利用 Redis 中 GEO 这种数据结构,可以简单的实现查询附近商铺这种与地理位置信息有关的需求,并且可以根据用户当前位置获取附近商铺的信息。
2.附近商户搜索
在原先的页面中检察商铺时选择根据距离举行查询时就需要根据地理位置来举行搜索,并在每个商铺右边显示出相距的距离巨细,我们可以使用 Redis 中的 GEO 来十分简单的实现这个功能。
- @Test
- void loadShopData() {
- //1.查询店铺信息
- List<Shop> list = shopService.list();
- //2.根据类型分组,得到类型id与店铺的映射关系(stream流)
- Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
- for (Map.Entry<Long, List<Shop>> entry : map.entrySet()) {
- Long typeId = entry.getKey();
- String key = "shop:geo:" + typeId;
- List<Shop> value = entry.getValue();
- List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
- //3.写入redis
- for (Shop shop : value) {
- locations.add(new RedisGeoCommands.GeoLocation<>(
- shop.getId().toString(),
- new Point(shop.getX(), shop.getY())));
- }
- stringRedisTemplate.opsForGeo().add(key, locations);
- }
- }
复制代码 而我们需要先将数据库中商铺的信息存入 Redis 中,并且根据商铺的类型(typeId)分别举行存储,如许在查询时也克制了需要先分类再举行查询,直接按照类型举行查询即可。
(注意:由于版本的问题,Redis 中 GEO 类型的一些命令是 6.2 版本后提供的,以是需要保证 SpringBoot 中 SpringDataRedis 的版本支持这些命令)
- @Override
- public Result queryShopByTypeId(Integer typeId, Integer current, Double x, Double y) {
- // 检查输入坐标是否为null,如果是,则执行类型分页查询
- 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());
- }
- // 计算查询的起始和结束范围
- int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
- int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
- ...
复制代码 在 Impl 层中,起首对传入的坐标举行判断,不为空则接着盘算分页的参数。
- ...
- // 拼接Redis地理信息键名
- String key = SHOP_GEO_KEY + typeId;
- // 使用Redis地理信息命令,查询指定坐标附近的商店
- GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo()
- .search(key,
- GeoReference.fromCoordinate(new Point(x, y)),
- new Distance(5000),
- RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end));
- // 如果查询结果为空,返回空列表
- 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());
- }
- ...
复制代码 接着,将商铺类型 id 与前缀举行拼接后举行查询得到地理位置信息,其中 Distance 表示查询的范围。对得到的结果举行非空判断,然后检查结果是否小于起始值,由于这个分页查询是先查询出总数据后按照范围取出数据展示,如果起始值大于总数,则会造成程序堕落。
- ...
- // 初始化商店ID列表和距离映射
- List<Long> ids = new ArrayList<>(list.size());
- Map<String, Distance> distanceMap = new HashMap<>(list.size());
- // 遍历结果列表,提取商店ID和距离信息
- list.stream().skip(from).forEach(result -> {
- String shopIdStr = result.getContent().getName();
- ids.add(Long.valueOf(shopIdStr));
- Distance distance = result.getDistance();
- distanceMap.put(shopIdStr, distance);
- });
- String idStr = StrUtil.join(",", ids);
- // 根据商店ID查询商店信息
- 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());
- }
- // 返回
- return Result.ok(shops);
- }
复制代码 最后将商店 Id 列表和距离初始化后遍历查询结果并将其存入先前初始化好的列表中。再利用 query 方法根据商铺 Id 查询商铺信息,最后将数据返回即可。
最终结果如下所示
六、用户签到
1.BitMap
签到功能作为一个耳熟能详的功能常常出现在一些游戏和软件中,作为其中的常客,其主要是通过一些签到奖励来激励用户登录。使用数据库实现时其每个用户每天的签到情况会产生大量的数据,而在 Redis 中可以使用 String 类型来实现 BitMap(位图)这种思路,来简单的存储用户的签到信息。
通过上面的结构,我们可以简单的用少量的内存存储大量的签到信息。、
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 出现的位置
其中用到的主要是 SETBIT 和 BITFIELD。
SETBIT:
利用如上图所示,需要注意需要选择 Binary 才能看到对应的格式。
BITFIELD:
其中 type 表示获取的数量,offset 表示从第几个开始。
2.实现签到功能
通过 Redis 来实现签到功能,不需要任何参数,只需通过用户 Id 与当前时间举行拼接后作为 key 来存储即可。
- 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
- stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
- return Result.ok();
- }
复制代码 起首获取当前登录用户的 Id 以及当前时间。拼接后再获取今天是本月的第几天,用于确定在 BitMap 中签到的位置,且注意 getDayOfMonth 方法获取的数是从 1 开始的,而 BitMap 的下标是从 0 开始的,以是方法结果需要 - 1 后再写入。
最终结果如下所示
发送签到请求后成功的写入到了 Redis 中。注意需要在请求头中添加 authorization 参数否则请求会失败。
3.统计连续签到次数
· 想要统计从本月开始到今天为止的连续签到次数,就需要先先获取本月到今天为止的全部签到信息,然后遍历信息,统计连续签到的次数。其中,可以使用位运算来实现遍历功能,完成连续签到次数的统计。
- 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();
- List<Long> result = stringRedisTemplate.opsForValue()
- .bitField(
- key,
- BitFieldSubCommands.create()
- .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0)
- );
- ...
复制代码 起首还是获取到当前登录的用户 Id 和当前时间,拼接后查询从本月的第一天到今天的全部签到信息。其中 bitField 中包含两个参数,一个是 key,一个是子命令。子命令中设置范围。
- ...
- if (result == null || result.isEmpty()){
- return Result.ok(0);
- }
- Long num = result.get(0);
- if (num == null){
- return Result.ok(0);
- }
- int count = 0;
- while(true){
- if((num & 1) == 0) {
- //为 0 未签到,结束
- break;
- }else {
- //不为 0 已签到,计数器++
- count++;
- }
- //将数字无符号右移一位抛弃最后一个 bit 位
- num >>>= 1;
- }
- return Result.ok(count);
- }
复制代码 之后对查询得到的数据举行非空校验,通过后开始遍历。与 1 举行与运算获取最后一位并对其举行判断,为 0 则直接退出循环,不为 0 则计数后将数字右移移除最后一位数继承循环。结束统计后直接返回计数结果。
最终结果如下所示
测试后可以看到成功统计出了本月的连续签到次数。
七、UV 统计
起首,需要相识什么是 UV:
- UV:全称 Unique Visitor,也叫独立访客量,是指通过互联网访问、欣赏这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
- PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
想要统计这种大量的数据,就需要使用到 Redis 中的 HyperLogLog 了。其基于 String 类型实现,其占用的内存极小,而代价就是其测量的结果是概率性的,但对于 UV 统计这种大数据量来说误差是可以忽略的。
根据上图可知,HyperLogLog 不会将重复的内容统计。
(后续的测试 o(´^`)o跳过)
【下】完结
传送门(上):Redis 实战篇 ——《黑马点评》(上)
传送门(中):Redis 实战篇 ——《黑马点评》(中)
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。 |