Redis学习笔记(实战篇4)

一、好友关注

1. 关注和取消关注
(1) FollowController
java 复制代码
//关注
@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);
}
(2) FollowService
java 复制代码
取消关注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();
    }

说明:

字段 含义
userId 当前登录用户(你自己)
followUserId 要关注 / 取关的用户(博主)
tb_follow 专门存关注关系的表
isFollow true= 关注,false= 取关

if (isFollow)关注(新增数据)

java 复制代码
// 新建一个【关注关系】对象
Follow follow = new Follow();
// 设置:关注人是「我」(当前登录用户)
follow.setUserId(userId);
// 设置:被关注的人是「对方」(博主)
follow.setFollowUserId(followUserId);
// 把这条关注记录 保存到数据库
boolean isSuccess = save(follow);
  • Follow follow = new Follow(); 数据库里有一张表叫 tb_follow(关注表),我们要往表里加一条数据,就得先创建一个对应的对象。
  • setUserId(userId) 填数据:关注的人是我(我是主动关注的一方)。
  • setFollowUserId(followUserId) 填数据:我关注的人是他(对方是被关注的博主)。
  • save(follow) MyBatis-Plus 提供的方法,直接把这条关注数据插入数据库

else取关(删除数据)

java 复制代码
remove(new QueryWrapper<Follow>()
    .eq("user_id", userId)          // 条件1:关注人是我
    .eq("follow_user_id", followUserId) // 条件2:被关注人是他
);

new QueryWrapper<Follow>()

删除条件

翻译:我不能乱删,必须告诉我删哪一行

2. 共同关注
(1) 代码实现
java 复制代码
// 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);
}

说明:

① @PathVariable 和 @RequestParam 的区别

@PathVariable(路径参数,写在 URL / 里的), @RequestParam查询参数 ,写在 URL ? 后面的。

注解 位置 例子
@PathVariable URL 路径 / /100/true
@RequestParam URL 问号 ? 后面 ?current=1&id=100

② blogService.query()

→ 翻译:我要查 blog 表(博客表)的数据

→ 等价 SQL:SELECT * FROM blog

③ .page(分页参数)

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

这是MyBatis-Plus 分页方法,两个参数:

  • current:当前页码(比如 1 = 第一页,2 = 第二页)
  • SystemConstants.MAX_PAGE_SIZE :固定值,比如 10 → 一页显示 10 条博客

④ page.getRecords()

→ 翻译:从分页结果里,把「当前页的博客列表」拿出来

→ 结果:[博客1, 博客2, ..., 博客10]

(2) 修改FollowServiceImpl

改造原因:我们需要在用户关注了某位用户后,需要将数据放入到set集合中,方便后续进行共同关注,同时当取消关注时,也需要从set集合中进行删除。set集合可以实现交集、并集、补集的功能。

java 复制代码
@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);
}

说明:

.collect(Collectors.toList())

把转换好的数字 ID,打包成一个列表

3. Feed流实现方案

针对好友的操作,采用的是Timeline的方式,只需要拿到我们关注用户的信息,然后按照时间排序即可,因此采用Timeline的模式。该模式的实现方案有三种:

  • 拉模式

  • 推模式

  • 推拉结合

(1) 拉模式

① 核心逻辑:

当张三、李四和王五发了消息后,都会保存在自己的邮箱中。假设赵六要读取信息,那么它会从读取它自己的收件箱,此时系统会从它关注的人群中,把它关注人的信息全部都进行拉取,然后在进行排序。

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

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

(2) 推模式

① 核心逻辑:

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

② 优点:时效快,不用临时拉取。

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

(3) 推拉模式

推拉模式是一个折中的方案。

① 站在发件人这一端,如果是个普通的人,那么我们采用写扩散的方式,直接把数据写入到它的粉丝中去,因为普通的人它的粉丝关注量比较小,所以这样做没有压力。如果是大V,那么它是直接将数据先写入到一份到发件箱里边去,然后再直接写一份到活跃粉丝收件箱里边去。

② 现在站在收件人这端来看,如果是活跃粉丝,那么大V和普通的人发的都会直接写入到自己收件箱里边来,而如果是普通的粉丝,由于它们上线不是很频繁,所以等它们上线时,再从发件箱里边去拉信息。

4. 推送到粉丝收件箱
(1) 需求
  • 修改新增探店笔记的业务,在保存blog到数据库的同时,推送到粉丝的收件箱

  • 收件箱满足可以根据时间戳排序,必须用Redis的数据结构实现

  • 查询收件箱数据时,可以实现分页查询

(2) Feed流的分页问题

① 传统分页

  • t1 时刻 :取第 1 页(page=1, size=5),拿到10,9,8,7,6
  • t2 时刻 :新增一条内容11,数据整体后移。
  • t3 时刻 :取第 2 页(page=2, size=5),按传统逻辑从第 6 条开始取,拿到6,5,4,3,2
  • 结果6在第 1 页和第 2 页都出现了,数据重复

② 滚动分页

  • t1 时刻 :首次加载,lastId = ∞(无前置数据),取前 5 条10,9,8,7,6,记录lastId = 6
  • t2 时刻 :新增内容11,插入到最顶部,不影响已记录的6
  • t3 时刻 :加载下一页,传入lastId = 6,从6之后的5开始取,拿到5,4,3,2,1,无重复。
(3) 代码实现
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.查询笔记作者的所有粉丝 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());
}
5. 实现分页查询收邮箱
(1) 需求

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

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

参数 含义 核心目的
lastId 上一次查询结果里的最小时间戳 限定本次查询只加载「比这个时间更早」的 Blog,保证数据按时间倒序加载,避免重复获取
offset 上一次查询返回的Blog 数量 用来跳过已经加载过的数据,实现 "下一页" 的翻页效果
(2) 代码实现

① 定义出来具体的返回值实体类

java 复制代码
@Data
public class ScrollResult {
    private List<?> list;
    private Long minTime;
    private Integer offset;
}

② BlogController

java 复制代码
@GetMapping("/of/follow")
public Result queryBlogOfFollow(
    @RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset){
    return blogService.queryBlogOfFollow(max, offset);
}

③ BlogServiceImpl

java 复制代码
@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);
}

说明:

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

  • List<Long>:创建一个列表 ,里面专门存 数字类型的博客 ID(数据库里的 ID 是数字,不是字符串)
  • ids:列表的名字,意思就是 id 集合
  • new ArrayList<>():创建列表对象
  • typedTuples.size()提前指定列表的大小
    • 比如 Redis 查到了 2 条数据,列表就直接开 2 个位置
    • 作用:优化性能,不用让列表自动扩容

② String idStr = tuple.getValue(); ids.add(Long.valueOf(idStr));

  • tuple:循环中每一个 Redis 包裹(包含博客 ID + 时间戳)
  • tuple.getValue()拿出包裹里的博客 ID(注意:是字符串格式,比如 "1001")
  • Long.valueOf(idStr)把字符串 ID 转成 数字 ID
    • 因为数据库里的 ID 是 Long 数字类型,字符串查不到数据,必须转类型
  • ids.add(...)把转换后的数字 ID,放进刚才创建的列表里

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

1). 问题

Redis 查出的 blogId按时间倒序 的,但 MySQL 的 IN 查询不会保证返回顺序!直接查会导致博客顺序乱掉。

2). 解决方案

ORDER BY FIELD(id, 1,2,3):强制让数据库返回的顺序 和 Redis 中的 id 顺序完全一致,保证前端展示顺序正确。

二、附近商户

1. 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.新功能

2. 导入店铺数据到GEO
(1) Redis GEO 数据结构设计

① 按「商家类型」分组存储

Redis GEO 本身不支持额外的类型筛选条件 (比如只查美食、排除 KTV),所以我们用分组来解决:

  • 给每种商家类型建一个独立的 GEO 集合,Key 格式为:shop:geo:{类型}
    • 美食类:shop:geo:美食
    • KTV 类:shop:geo:KTV
  • 这样用户选「美食」时,直接查询 shop:geo:美食,天然就过滤了类型,只查美食商家。

② GEO 里只存「商家 ID」,不存完整信息

Redis 是内存数据库,存太多数据会占满内存,所以:

  • Value(member) :只存商家的ID(比如海底捞的 ID 是 1,就存 "1")
  • Score:Redis 自动把经纬度转换成 GeoHash 值,用来计算距离和排序
  • 完整的商家信息(名称、地址、评分等)依然存在数据库(MySQL)里,之后用 ID 去查。
(2) 代码实现
java 复制代码
@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);
    }
}

说明:

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

  • groupingBy(Shop::getTypeId) 把所有店铺,按类型 ID分成一组一组;
  • 最终得到一个 Mapkey = 类型 ID,value = 该类型的所有店铺
  • 后续给每个类型创建一个独立的 Redis GEO Key (比如 shop:geo:1 是美食店,shop:geo:2 是 KTV),查询时直接查对应 Key,天然过滤类型。

② 往箱子里「装每个店铺的包裹」

java 复制代码
for (Shop shop : value) {
    locations.add(
        new GeoLocation<>(
            shop.getId().toString(),  // 包裹里的「快递单号」:店铺ID
            new Point(shop.getX(), shop.getY())  // 包裹里的「收货地址」:经纬度
        )
    );
}
  • shop.getId().toString() :把店铺 ID 转成字符串,作为 Redis GEO 里的 member(相当于快递单号,用来后续查询店铺信息)
  • new Point(shop.getX(), shop.getY()):封装店铺的经纬度
3. 实现附近商户功能
(1) ShopController
java 复制代码
@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);
}
(2) ShopServiceImpl
java 复制代码
@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() // GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE
                .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);
    }

说明:

① .page(new Page<>(current, 每页条数))

  • current:前端传的当前页码(比如第 1 页、第 2 页)
  • SystemConstants.DEFAULT_PAGE_SIZE每页默认显示多少条店铺(比如 10 条)

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

Page 分页对象里包含了很多分页信息:

  • 总页数、总条数、当前页、每页条数、数据列表
  • getRecords() = 获取分页查询到的「店铺数据列表」(就是我们要展示给用户的店铺集合)

指定类型 + 5 公里范围内 + 按距离排序 的店铺

参数 代码写法 大白话翻译
第 1 个 key 哪种类型的店铺(比如美食店)
第 2 个 GeoReference.fromCoordinate(x, y) 用户当前坐标圆心
第 3 个 new Distance(5000) 以圆心为中心,画 **5000 米(5 公里)** 的圈
第 4 个 includeDistance() 必须计算并返回每个店铺离用户的距离
第 4 个 limit(end) 最多查 end 条数据(分页用)

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

  • distanceMap:一个空 Map
  • 作用:等会儿存 "店铺 ID → 离你多远"
  • 比如:105 → 1.2km

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

.getValue() 的作用是从 Distance 对象里,提取出纯数字的距离值 (比如 1.2),Distance 对象是 Redis 封装的,包含单位、数值等信息,我们只需要数值给前端展示

相关推荐
鹭天2 小时前
【目标检测学习笔记】 yolov2
笔记·学习·目标检测
talen_hx2962 小时前
《零基础入门Spark》学习笔记 Day 06
笔记·学习·spark
海海不瞌睡(捏捏王子)2 小时前
《计算机网络》再学习
网络·学习·计算机网络
幸福在路上wellbeing2 小时前
Kotlin 核心学习大纲(Android 开发)
android·学习·kotlin
xiaoxiaoxiaolll2 小时前
《自然·通讯》最新:集成光子学中的极简无线收发器,为6G终端低成本高速互联铺路
学习
知识分享小能手2 小时前
MongoDB入门学习教程,从入门到精通,MongoDB索引(5)
数据库·学习·mongodb
中屹指纹浏览器2 小时前
2026多账号运维中的指纹标准化治理与平台风控适配研究
经验分享·笔记
dustcell.2 小时前
企业级高可用电商平台实战项目设计
运维·redis·nginx·docker·web·lvs·haproxy
NULL指向我2 小时前
信号处理学习笔记4:动态调整系数的一阶低通滤波
笔记·学习·信号处理