【黑马点评日记】社交平台用户关注功能全解析Feed流相关操作

🔥个人主页:北极的代码(欢迎来访)

🎬作者简介:java后端学习者

❄️个人专栏:苍穹外卖日记SSM框架深入JavaWeb

命运的结局尽可永在,不屈的挑战却不可须臾或缺!

前言:

我们继续对黑马点评的项目进行学习,这一章节主要学习的是关注用户的相关功能。

摘要:

本文介绍了社交平台用户关注功能的完整实现方案。

核心内容包括:1. 数据库设计使用tb_follow表存储关注关系;2. 实现关注/取消关注功能,通过Redis Set存储关注列表;3. 共同关注功能利用Set求交集实现;4. 采用推模式的Feed流方案,使用ZSet实现滚动分页推送。

文章详细分析了各功能的技术实现要点,包括Controller层参数处理、Service层业务逻辑、Redis数据结构选择等,并对比了不同方案的优缺点。该实现兼顾了功能完整性和性能考量,适合作为中小型社交平台的关注系统基础架构。

  1. 关注/取消关注 - 用户可以关注感兴趣的博主

  2. 共同关注 - 查看当前用户与博主的共同关注好友

  3. 关注推送(Feed流) - 接收关注用户发布的笔记动态

一、数据库设计

tb_follow 表结构

sql 复制代码
sql

CREATE TABLE `tb_follow` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_id` bigint(20) NOT NULL COMMENT '用户id',
  `follow_user_id` bigint(20) NOT NULL COMMENT '关联的用户id(被关注者)',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`)
);

对应的实体类:

java 复制代码
java

@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("tb_follow")
public class Follow implements Serializable {
    private static final long serialVersionUID = 1L;
    
    @TableId(value = "id", type = IdType.AUTO)
    private Long id;
    private Long userId;
    private Long followUserId;
    private LocalDateTime createTime;
}

注意主键设置为自增长,简化开发。

二、关注/取消关注功能实现

3.1 Controller层

java 复制代码
java

@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);
    }
}

@PathVariable("id") Long followUserId 这行代码的作用,就是把URL中的 {id} 参数(字符串)提取出来,自动转换成 Long 类型,然后赋值给 followUserId 变量。

整个过程的两个关键步骤是:

  1. 映射(绑定)@PathVariable("id") 告诉Spring,去URL的路径里找到名叫 id 的变量(也就是 {id} 这个位置的值)。

  2. 类型转换Long 告诉Spring,把拿到的字符串(比如 "10086")转换成一个 Long 类型的数字(比如 10086L),再交给 Java 代码使用。

图解示例

假设前端发来的请求URL是:/follow/123/true

java

复制代码
@PutMapping("/{id}/{isFollow}")
public Result follow(
    @PathVariable("id") Long followUserId,    // 步骤1: 找到 "123" → 步骤2: 转换为 123L → 赋值给 followUserId
    @PathVariable("isFollow") Boolean isFollow // 步骤1: 找到 "true" → 步骤2: 转换为 true → 赋值给 isFollow
)

执行结果就是:

  • followUserId 变量的值是 123L

  • isFollow 变量的值是 true

几个关键点
  • 变量名不重要 :你可以用 @PathVariable("id") Long abc,效果一样,abc 变量最终的值也是 123L。变量名是给开发者自己看的。

  • 转换有规则 :Spring 支持大部分常见类型的自动转换,如 intlongbooleanDate 等。如果URL里的值无法转换(比如给 Long 传了个"abc"),就会报错。

  • 类型不匹配时 :如果把URL里的 {id} 值 "123" 赋给 @PathVariable("id") String followUserId,类型转换这一步就不做,followUserId 的值就直接是字符串 "123"

所以,严格来说是类型转换 ,但它和"参数绑定" (@PathVariable) 经常一起出现,共同完成了从"URL字符串"到"Java对象"的转换工作。

这个用法在Spring MVC里非常常见,不仅仅是处理路径变量,处理普通请求参数时,@RequestParam 也是同样的原理。

3.2 Service层实现

java 复制代码
java

@Service
public class FollowServiceImpl extends ServiceImpl<FollowMapper, Follow> 
                               implements IFollowService {
    
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    
    @Override
    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);
    }
    
    @Override
    public Result follow(Long followUserId, Boolean isFollow) {
        // 获取当前登录用户
        Long userId = UserHolder.getUser().getId();
        String key = "follows:" + userId;
        
        if (isFollow) {
            // 关注:新增数据
            Follow follow = new Follow();
            follow.setUserId(userId);
            follow.setFollowUserId(followUserId);
            boolean isSuccess = save(follow);
            
            if (isSuccess) {
                // 将关注用户ID存入Redis Set集合
                stringRedisTemplate.opsForSet().add(key, followUserId.toString());
            }
        } else {
            // 取关:删除数据
            boolean isSuccess = remove(new QueryWrapper<Follow>()
                .eq("user_id", userId)
                .eq("follow_user_id", followUserId));
            
            if (isSuccess) {
                // 从Redis Set集合中移除
                stringRedisTemplate.opsForSet().remove(key, followUserId.toString());
            }
        }
        
        return Result.ok();
    }
}

QueryWrapper 是 MyBatis-Plus 提供的条件构造器,用于动态构建SQL查询条件,避免手写SQL字符串。

常用方法对比

方法 作用 SQL对应
.eq("列名", 值) 等于 = WHERE 列名 = 值
.ne("列名", 值) 不等于 != WHERE 列名 != 值
.gt("列名", 值) 大于 > WHERE 列名 > 值
.lt("列名", 值) 小于 < WHERE 列名 < 值
.like("列名", 值) 模糊匹配 WHERE 列名 LIKE '%值%'
.in("列名", 集合) 在集合中 WHERE 列名 IN (值1, 值2)

3.3 核心要点

为什么使用Redis Set

  • 为后续"共同关注"功能做准备

  • Set集合支持高效的求交集操作

  • Key格式:follows:userId,Value为用户关注的所有博主ID

三、共同关注功能实现

4.1 Controller层

java

复制代码
@GetMapping("/common/{id}")
public Result followCommons(@PathVariable Long id) {
    return followService.followCommons(id);
}

4.2 Service层实现

java

复制代码
@Override
public Result followCommons(Long id) {
    // 1. 获取当前用户
    Long userId = UserHolder.getUser().getId();
    String key1 = "follows:" + userId;
    String key2 = "follows:" + id;
    
    // 2. 求交集
    Set<String> intersect = stringRedisTemplate.opsForSet()
        .intersect(key1, key2);
    
    if (intersect == null || intersect.isEmpty()) {
        return Result.ok(Collections.emptyList());
    }
    
    // 3. 解析ID集合(String -> Long)
    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);
}

4.3 核心要点

实现原理

  • 利用Redis Set的sinter命令求交集

  • follows:当前用户IDfollows:博主ID 的交集即为共同关注

关键优化

  • 需要在关注/取关时同步维护Redis数据

  • 避免直接查询数据库,提高性能

五、关注推送(Feed流)

5.1 Feed流方案对比

Feed流有三种实现模式:

方案 写比例 读比例 延迟 实现难度 使用场景
拉模式(读扩散) 复杂 很少使用
推模式(写扩散) 简单 用户量少、无大V
推拉结合 很复杂 过千万用户量

黑马点评选用推模式:因为项目规模较小,推模式实现简单、时效性好。

5.2 数据结构选型:ZSet

为什么使用SortedSet?

  • ✅ 元素具有唯一性(不重复推送)

  • ✅ 可按时间戳排序(score存储时间戳)

  • ✅ 支持滚动分页(避免传统分页的数据重复/遗漏问题)

对比List的问题

如果使用List按角标分页,当有新数据插入时,角标会变化,导致查询重复或遗漏。而ZSet按score范围查询可以避免此问题。

5.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. 查询所有粉丝
    List<Follow> fans = followService.lambdaQuery()
        .eq(Follow::getFollowUserId, user.getId())
        .list();
    
    // 4. 推送笔记ID给所有粉丝(推模式)
    for (Follow fan : fans) {
        String key = "feed:" + fan.getUserId();  // 粉丝收件箱
        stringRedisTemplate.opsForZSet().add(
            key, 
            blog.getId().toString(), 
            System.currentTimeMillis()  // 时间戳作为score
        );
    }
    
    return Result.ok();
}

5.4 滚动分页查询

传统分页的问题

text

复制代码
时间线:[新] D, C, B, A [旧]
第1页:D, C
第2页:B, A(本来应该是C, B)
问题:新数据插入导致角标偏移,数据重复或遗漏

滚动分页实现

java

复制代码
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
    // 1. 获取当前用户
    Long userId = UserHolder.getUser().getId();
    String key = "feed:" + userId;
    
    // 2. 查询收件箱(滚动分页)
    Set<ZSetOperations.TypedTuple<String>> typedTuples = 
        stringRedisTemplate.opsForZSet()
            .reverseRangeByScoreWithScores(key, 0, max, offset, 2);
    
    if (typedTuples == null || typedTuples.isEmpty()) {
        return Result.ok();
    }
    
    // 3. 解析数据
    List<Long> ids = new ArrayList<>();
    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. 查询博客详情(保持顺序)
    String idStr = StrUtil.join(",", ids);
    List<Blog> blogs = query()
        .in("id", ids)
        .last("ORDER BY FIELD(id, " + idStr + ")")
        .list();
    
    // 5. 封装返回结果
    ScrollResult result = new ScrollResult();
    result.setList(blogs);
    result.setMinTime(minTime);
    result.setOffset(os);
    
    return Result.ok(result);
}

滚动分页参数说明

参数 第1次查询 第2次查询
max 当前时间戳 上次的最小时间戳
min 0 0
offset 0 上次最小值相同分数个数
count 3 3

5.5 常见问题与优化

问题1:MySQL查询顺序错乱

现象 :Redis取出ID顺序是[5,1,3],但listByIds返回的是[1,3,5]

原因SELECT ... WHERE id IN (5,1,3) 默认按主键升序返回

解决方案

java

复制代码
// 使用ORDER BY FIELD强制保持顺序
.last("ORDER BY FIELD(id, " + idStr + ")")
问题2:大V粉丝量大的性能问题

推模式下,拥有百万粉丝的大V发一条笔记需写入100万次Redis,内存与性能压力巨大。

优化方案

  • 普通用户:继续使用推模式

  • 大V用户:改用拉模式或推拉结合模式(先将笔记存入发件箱,粉丝查询时再拉取)

六、技术要点总结

6.1 Redis数据结构选择

功能 数据结构 原因
共同关注 Set 支持sinter求交集
Feed流 ZSet 支持按时间戳排序 + 滚动分页
关注列表 Set 存储关注ID集合

6.2 关键技术点

  1. 读写分离:关注关系存MySQL,社交关系存Redis

  2. 缓存同步:关注/取关时同步更新Redis Set

  3. 推模式:发布时主动推送给粉丝,降低查询延迟

  4. 滚动分页:避免传统分页的数据重复问题

  5. 顺序保持 :MySQL使用ORDER BY FIELD保持Redis排序

6.3 优缺点分析

优点

  • 推模式实现简单,粉丝查询延迟低

  • Redis ZSet支持高效的排序和分页

  • Set交集实现共同关注简单高效

缺点

  • 推模式对存储压力大(每个粉丝存一份)

  • 大V场景下写放大问题严重

  • 未使用推拉结合优化(适合百万级用户的方案)


这个用户关注功能的完整实现涵盖了社交产品的核心需求,适合作为学习Redis在社交场景应用的入门案例。

结语:如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!

相关推荐
Andy Dennis1 小时前
mcp python-sdk使用记录
python·agent·mcp
zhoutongsheng1 小时前
mysql如何处理表空间碎片问题_执行OPTIMIZE TABLE整理
jvm·数据库·python
狼与自由1 小时前
Harness
python
xiaoshuaishuai82 小时前
C# DeepSeek V4 与 V3对比
开发语言·c#·量子计算
shehuiyuelaiyuehao2 小时前
算法18,二分查找
java·开发语言·算法
IT策士2 小时前
Python mcp研究:入门到精通
开发语言·python·qt
罗技1232 小时前
告别“兼容模式“:Easysearch 有了自己的官方 Python 客户端
开发语言·python
lifewange2 小时前
如何查看本地的数据库里信息
数据库