沉梦手记 沉梦手记
首页
  • 基础篇
  • 集合篇
  • 并发篇
  • JVM
  • 新特性
  • 计算机网络
  • 操作系统
  • 数据结构与算法
  • 数据库基础
  • MySql
  • Redis
  • 达梦数据库
  • Spring
  • SpringBoot
  • Mybatis
  • Shiro
  • 设计须知
  • UML画图
  • 权限校验
  • 设计模式
  • API网关
  • 网络通信
  • 消息队列
  • SpringCloud
  • 分布式事务
  • 云存储
  • 搜索引擎
  • 前端环境搭建
  • HTML与CSS
  • JS学习
  • Axios入门
  • Vue Router入门
  • Pinia入门
  • Vue3入门
  • Vue3进阶
  • 黑马Vue3
  • 开发工具篇
  • 工具库篇
  • 开发技巧篇
  • 工具类系列
  • 运维与部署
  • 音视频处理
  • 随笔
  • 博客搭建
  • 网站收藏箱
  • 断墨寻径摘录
  • 费曼学习法
  • 脚手架搭建
  • 瑞吉外卖
  • 黑马点评
  • vue-blog
  • 沉梦接口开放平台
  • 用户中心
  • 聚合搜索平台
  • 仿12306项目
  • 壁纸小程序项目
  • RuoYi-Vue
Github (opens new window)

沉梦听雨

时间是最好的浸渍剂,而沉淀是最好的提纯器🚀
首页
  • 基础篇
  • 集合篇
  • 并发篇
  • JVM
  • 新特性
  • 计算机网络
  • 操作系统
  • 数据结构与算法
  • 数据库基础
  • MySql
  • Redis
  • 达梦数据库
  • Spring
  • SpringBoot
  • Mybatis
  • Shiro
  • 设计须知
  • UML画图
  • 权限校验
  • 设计模式
  • API网关
  • 网络通信
  • 消息队列
  • SpringCloud
  • 分布式事务
  • 云存储
  • 搜索引擎
  • 前端环境搭建
  • HTML与CSS
  • JS学习
  • Axios入门
  • Vue Router入门
  • Pinia入门
  • Vue3入门
  • Vue3进阶
  • 黑马Vue3
  • 开发工具篇
  • 工具库篇
  • 开发技巧篇
  • 工具类系列
  • 运维与部署
  • 音视频处理
  • 随笔
  • 博客搭建
  • 网站收藏箱
  • 断墨寻径摘录
  • 费曼学习法
  • 脚手架搭建
  • 瑞吉外卖
  • 黑马点评
  • vue-blog
  • 沉梦接口开放平台
  • 用户中心
  • 聚合搜索平台
  • 仿12306项目
  • 壁纸小程序项目
  • RuoYi-Vue
Github (opens new window)
  • 脚手架搭建
  • 瑞吉外卖

  • 黑马点评

    • Redis实战笔记
    • 登录与缓存优化
    • 优惠券秒杀
    • 分布式锁
    • 秒杀优化与消息队列
    • 社交功能与Feed流
      • 7. 你是怎么实现发送笔记和点赞的功能?
        • 1. 发布笔记
        • 2. 查看笔记
        • 3. 点赞功能
        • 4. 点赞排行榜
      • 8. 你是怎么实现好友关注和粉丝功能的?
        • 1. 关注和取消关注
        • 2. 共同关注
        • 3. Feed 流实现方式
        • 拉模式(读扩散)
        • 推模式(写扩散)
        • 推拉结合(读写混合)
        • 4. 推送到粉丝收件箱(Feed 流分页)
        • 5. 实现滚动分页查询收件箱
    • GEO与签到统计
    • 面试问答整理
    • 电商场景分析
    • 项目技术亮点
  • vue-blog

  • 沉梦接口开放平台

  • 用户中心

  • 聚合搜索平台

  • 仿12306项目

  • 壁纸小程序项目

  • RuoYi-Vue

  • 项目笔记
  • 黑马点评
沉梦听雨
2023-06-08
目录

社交功能与Feed流

# 社交功能与Feed流

# 7. 你是怎么实现发送笔记和点赞的功能?

# 1. 发布笔记

设置数据库的时候设置了用户 id,但是发布笔记的时候,需要用户姓名、图标等信息。这个时候在实体类就需要加上几个信息,用 @TableField(exist = false) 注解,标识数据库没有的字段。

@PostMapping
public Result saveBlog(@RequestBody Blog blog) {
    // 获取登录用户
    UserDTO user = UserHolder.getUser();
    blog.setUserId(user.getId());
    // 保存探店博文
    blogService.save(blog);
    // 返回id
    return Result.ok(blog.getId());
}
1
2
3
4
5
6
7
8
9
10

还有一个上传图片,也是发送一个请求。我这里只是放在本地,实际开发中图片一般会放在 Nginx 上或者是云存储上。

# 2. 查看笔记

在 Service 类中创建对应方法之后,在 Impl 类中实现。我们查看用户探店笔记的时候,需要额外设置用户名和其头像,由于设置用户信息这个操作比较通用,所以这里封装成了一个方法。

@Override
public Result queryById(Integer id) {
    Blog blog = getById(id);
    if (blog == null) {
        return Result.fail("笔记不存在或已被删除");
    }
    queryBlogUser(blog);
    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());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 3. 点赞功能

需求:

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

实现步骤:

  1. 修改点赞功能,利用 Redis 中的 set 集合来判断是否点赞过,未点赞则点赞数 +1,已点赞则点赞数 -1
  2. 修改根据 id 查询的业务,判断当前登录用户是否点赞过,赋值给 isLike 字段
  3. 修改分页查询 Blog 业务,判断当前登录用户是否点赞过,赋值给 isLike 字段

点赞时会将当前登录用户的 ID 存入对应博客的 set 集合中,这个 set 集合中的元素即为点赞该博客的所有用户 ID。因此,在存入 set 集合中时,key 的格式为 blog:liked:id,其中 id 为博客的 ID;value 的值为点赞该博客的用户 ID。这样,在查询博客时,可以使用 RedisTemplate 从该 set 集合中查找当前登录用户的 ID 是否存在,以判断用户是否点赞了该博客。

# 4. 点赞排行榜

当我们点击探店笔记详情页面时,应该按点赞顺序展示点赞用户,比如显示最早点赞的 TOP5,形成点赞排行榜,就跟 QQ 空间发的说说一样,可以看到有哪些人点了赞。

之前的点赞是放到 Set 集合中,但是 Set 集合不能排序,所以这个时候,我们就可以改用 SortedSet(Zset)。

集合区别对比:

List Set SortedSet
排序方式 按添加顺序排序 无法排序 根据 score 值排序
唯一性 不唯一 唯一 唯一
查找方式 按索引查找或首尾查找 根据元素查找 根据元素查找
@Override
public Result likeBlog(Long id) {
    // 1. 获取当前用户信息
    Long userId = UserHolder.getUser().getId();
    // 2. 如果当前用户未点赞,则点赞数 +1,同时将用户加入set集合
    String key = BLOG_LIKED_KEY + id;
    // 尝试获取score
    Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());
    // 为null,则表示集合中没有该用户
    if (score == null) {
        // 点赞数 +1
        boolean success = update().setSql("liked = liked + 1").eq("id", id).update();
        // 将用户加入set集合
        if (success) {
            stringRedisTemplate.opsForZSet().add(key, userId.toString(), System.currentTimeMillis());
        }
        // 3. 如果当前用户已点赞,则取消点赞,将用户从set集合中移除
    } else {
        // 点赞数 -1
        boolean success = update().setSql("liked = liked - 1").eq("id", id).update();
        if (success) {
            // 从set集合移除
            stringRedisTemplate.opsForZSet().remove(key, userId.toString());
        }
    }
    return Result.ok();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

这是博客系统点赞功能的优化版本实现代码。与之前使用 set 数据结构不同的是,这里使用了 Redis 的 zset 数据结构来存储点赞用户。

zset 是一个有序的、不重复的元素集合,能够存储元素和元素对应的分值(在博客系统中使用分值记录用户点赞时间),可以使用 RedisTemplate 来操作 zset 集合。在这个实现版本中,点赞时会将当前登录用户的 ID 和当前时间的毫秒值存入 zset 集合中,这个 zset 集合中的每一个元素都是一个用户的 ID 和用户点赞的时间。因此,尝试获取 score 时就可以判断当前用户是否已经点赞了该博客以及点赞时间。

在存入 zset 集合时,key 的格式为 blog:liked:id,其中 id 为博客的 ID;value 的值为点赞该博客的用户 ID;score 的值为点赞该博客的时间戳。

在查询博客时,可以使用 RedisTemplate 从该 zset 集合中查找当前登录用户的 ID 是否存在,以及对应的 score 是否过期。如果已经点赞并且未过期,则将对应的结果设置到博客对象的 isLike 属性中。

# 8. 你是怎么实现好友关注和粉丝功能的?

# 1. 关注和取消关注

当我们进入到笔记详情页面时,会发送一个请求,判断当前登录用户是否关注了笔记博主:

  • 请求网址:http://localhost:8080/api/follow/or/not/2
  • 请求方法:GET

当我们点击关注按钮时,会发送一个请求,实现关注/取关:

  • 请求网址:http://localhost:8080/api/follow/2/true
  • 请求方法:PUT

Controller:

@RestController
@RequestMapping("/follow")
public class FollowController {
    @Resource
    private IFollowService followService;

    // 判断当前用户是否关注了该博主
    @GetMapping("/or/not/{id}")
    public Result isFollow(@PathVariable("id") Long followUserId) {
        return followService.isFollow(followUserId);
    }

    // 实现取关/关注
    @PutMapping("/{id}/{isFollow}")
    public Result follow(@PathVariable("id") Long followUserId, @PathVariable("isFollow") Boolean isFollow) {
        return followService.follow(followUserId, isFollow);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

FollowServiceImpl:

@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> implements IFollowService {

    @Override
    public Result isFollow(Long followUserId) {
        // 获取当前登录的userId
        Long userId = UserHolder.getUser().getId();
        LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
        // 查询当前用户是否关注了该笔记的博主
        queryWrapper.eq(Follow::getUserId, userId).eq(Follow::getFollowUserId, followUserId);
        // 只查询一个count就行了
        int count = this.count(queryWrapper);
        return Result.ok(count > 0);
    }

    @Override
    public Result follow(Long followUserId, Boolean isFollow) {
        // 获取当前用户id
        Long userId = UserHolder.getUser().getId();
        // 判断是否关注
        if (isFollow) {
            // 关注,则将信息保存到数据库
            Follow follow = new Follow();
            follow.setUserId(userId);
            follow.setFollowUserId(followUserId);
            save(follow);
        } else {
            // 取关,则将数据从数据库中移除
            LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
            queryWrapper.eq(Follow::getUserId, userId).eq(Follow::getFollowUserId, followUserId);
            remove(queryWrapper);
        }
        return Result.ok();
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

首先,通过 UserHolder 类获取当前登录用户的信息。

在查询是否关注时,使用 LambdaQueryWrapper 封装查询条件,查询当前用户是否关注了指定的用户。如果查询结果数量大于 0,则表示当前用户已经关注了该用户,否则未关注该用户。

在关注和取关时,根据传入的 isFollow 参数来判断当前用户的操作,并将关注和取关的信息保存到数据库中,并使用 Redis 来缓存关注用户的信息。这里为了方便,未对 Redis 缓存的数据进行有效期管理,当数据发生变化时需要对 Redis 缓存进行更新。

# 2. 共同关注

  • 查看共同关注请求网址:http://localhost:8080/api/follow/common/undefined
  • 请求方法:GET
  • 查看用户自己的笔记并分页请求网址:http://localhost:8080/api/blog/of/user?&id=2&current=1
  • 请求方法:GET
@GetMapping("/of/user")
public Result queryBlogByUserId(@RequestParam(value = "current", defaultValue = "1") Integer current, @RequestParam("id") Long id) {
    LambdaQueryWrapper<Blog> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(Blog::getUserId, id);
    Page<Blog> pageInfo = new Page<>(current, SystemConstants.MAX_PAGE_SIZE);
    blogService.page(pageInfo, queryWrapper);
    List<Blog> records = pageInfo.getRecords();
    return Result.ok(records);
}
1
2
3
4
5
6
7
8
9

实现方式:

在 set 集合中,有交集、并集、补集的 API,可以把二者关注的人放入到 set 集合中,然后通过 API 查询两个 set 集合的交集。

所以,在关注博主的同时,需要将数据放到 set 集合中,方便后期我们实现共同关注。当取消关注时,也需要将数据从 set 集合中删除。

@Override
public Result followCommons(Long id) {
    // 获取当前用户id
    Long userId = UserHolder.getUser().getId();
    String key1 = "follows:" + id;
    String key2 = "follows:" + userId;
    // 对当前用户和博主用户的关注列表取交集
    Set<String> intersect = stringRedisTemplate.opsForSet().intersect(key1, key2);
    if (intersect == null || intersect.isEmpty()) {
        // 无交集就返回个空集合
        return Result.ok(Collections.emptyList());
    }
    // 将结果转为list
    List<Long> ids = intersect.stream().map(Long::valueOf).collect(Collectors.toList());
    // 之后根据ids去查询共同关注的用户,封装成UserDto再返回
    List<UserDTO> userDTOS = userService.listByIds(ids).stream().map(user ->
            BeanUtil.copyProperties(user, UserDTO.class)).collect(Collectors.toList());
    return Result.ok(userDTOS);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 3. Feed 流实现方式

Feed 流的实现有两种模式:

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

这里针对好友的操作,采用的是 Timeline 方式,只需要拿到我们关注用户的信息,然后按照时间排序即可。

采用 Timeline 模式,有三种具体的实现方案:

  1. 拉模式
  2. 推模式
  3. 推拉结合

# 拉模式(读扩散)

该模式的核心含义是:当张三和李四、王五发了消息之后,都会保存到自己的发件箱中。如果赵六要读取消息,那么他会读取他自己的收件箱,此时系统会从他关注的人群中,将他关注人的信息全都进行拉取,然后进行排序。

  • 优点:比较节约空间,因为赵六在读取信息时,并没有重复读取,并且读取完之后,可以将他的收件箱清除
  • 缺点:有延迟,当用户读取数据时,才会去关注的人的发件箱中拉取信息。假设该用户关注了海量用户,那么此时就会拉取很多信息,对服务器压力巨大

image.png (opens new window)

# 推模式(写扩散)

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

  • 优点:时效快,不用临时拉取
  • 缺点:内存压力大,假设一个大 V 发了一个动态,很多人关注他,那么就会写很多份数据到粉丝那边去

image.png (opens new window)

# 推拉结合(读写混合)

推拉模式是一个折中的方案,兼具推和拉两种模式的优点。

站在发件人这一边,如果是普通人,那么我们采用写扩散的方式,直接把数据写入到他的粉丝收件箱中,因为普通人的粉丝数量较少,所以这样不会产生太大压力。但如果是大 V,那么他是直接将数据写入一份到发件箱中去,再直接写一份到活跃粉丝的收件箱中。

站在收件人这边来看,如果是活跃粉丝,那么大 V 和普通人发的都会写到自己的收件箱里;但如果是普通粉丝,由于上线不是很频繁,所以等他们上线的时候,再从发件箱中去拉取信息。

# 4. 推送到粉丝收件箱(Feed 流分页)

我这里用的是推模式,因为并没有那么多数据。

需求:

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

注意:Feed 流中的数据会不断更新,所以数据的角标也会不断变化,所以我们不能使用传统的分页模式。

假设在 t1 时刻,我们取读取第一页,此时 page = 1,size = 5,那么我们拿到的就是 10~6 这几条记录。假设 t2 时刻有发布了一条新纪录,那么在 t3 时刻,我们来读取第二页,此时 page = 2,size = 5,那么此时读取的数据是从 6 开始的,读到的是 6~2,那么我们就读到了重复的数据。所以我们要使用 Feed 流的分页,不能使用传统的分页。

image.png (opens new window)

Feed 流的滚动分页:

  • 我们需要记录每次操作的最后一条,然后从这个位置去开始读数据。
  • 举个例子:我们从 t1 时刻开始,拿到第一页数据,拿到了 10~6,然后记录下当前最后一次读取的记录,就是 6。t2 时刻发布了新纪录,此时这个 11 在最上面,但不会影响我们之前拿到的 6。此时 t3 时刻来读取第二页,第二页读数据的时候,从 6-1=5 开始读,这样就拿到了 5~1 的记录。我们在这个地方可以使用 SortedSet 来做,使用时间戳来充当表中的 1~10。

image.png (opens new window)

核心思路:我们保存完探店笔记后,获取当前用户的粉丝列表,然后将数据推送给粉丝。

@Override
public Result saveBlog(Blog blog) {
    // 获取登录用户
    UserDTO user = UserHolder.getUser();
    blog.setUserId(user.getId());
    // 保存探店博文
    save(blog);
    // 条件构造器
    LambdaQueryWrapper<Follow> queryWrapper = new LambdaQueryWrapper<>();
    // 从follow表中,查找当前用户的粉丝  select * from follow where follow_user_id = user_id
    queryWrapper.eq(Follow::getFollowUserId, user.getId());
    // 获取当前用户的粉丝
    List<Follow> follows = followService.list(queryWrapper);
    for (Follow follow : follows) {
        Long userId = follow.getUserId();
        String key = FEED_KEY + userId;
        // 推送数据
        stringRedisTemplate.opsForZSet().add(key, blog.getId().toString(), System.currentTimeMillis());
    }
    // 返回id
    return Result.ok(blog.getId());
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 5. 实现滚动分页查询收件箱

  • 需求:在个人主页的关注栏中,查询并展示推送的 Blog 信息
  • 具体步骤如下:
    1. 每次查询完成之后,我们要分析出查询出的最小时间戳,这个值会作为下一次的查询条件
    2. 我们需要找到与上一次查询相同的查询个数,并作为偏移量。下次查询的时候,跳过这些查询过的数据,拿到我们需要的数据(例如时间戳 8 6 6 5 5 4,我们每次查询 3 个,第一次是 8 6 6,此时最小时间戳是 6,如果不设置偏移量,会从第一个 6 之后开始查询,那么查询到的就是 6 5 5,而不是 5 5 4)
  • 综上:我们的请求参数中需要携带 lastId 和 offset,即上一次查询时的最小时间戳和偏移量,这两个参数

具体看视频:https://www.bilibili.com/video/BV1cr4y1671t?p=87 (opens new window)

上次更新: 2026/10/10 17:43:40
秒杀优化与消息队列
GEO与签到统计

← 秒杀优化与消息队列 GEO与签到统计→

Theme by Vdoing | Copyright © 2023-2026 沉梦听雨 | MIT License
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式