个人主页北极的代码欢迎来访作者简介java后端学习者❄️个人专栏苍穹外卖日记SSM框架深入JavaWeb✨命运的结局尽可永在不屈的挑战却不可须臾或缺前言我们继续对黑马点评的项目进行学习这一章节主要学习的是关注用户的相关功能。摘要本文介绍了社交平台用户关注功能的完整实现方案。核心内容包括1. 数据库设计使用tb_follow表存储关注关系2. 实现关注/取消关注功能通过Redis Set存储关注列表3. 共同关注功能利用Set求交集实现4. 采用推模式的Feed流方案使用ZSet实现滚动分页推送。文章详细分析了各功能的技术实现要点包括Controller层参数处理、Service层业务逻辑、Redis数据结构选择等并对比了不同方案的优缺点。该实现兼顾了功能完整性和性能考量适合作为中小型社交平台的关注系统基础架构。关注/取消关注- 用户可以关注感兴趣的博主共同关注- 查看当前用户与博主的共同关注好友关注推送Feed流- 接收关注用户发布的笔记动态一、数据库设计tb_follow 表结构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 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 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变量。整个过程的两个关键步骤是映射绑定PathVariable(id)告诉Spring去URL的路径里找到名叫id的变量也就是{id}这个位置的值。类型转换Long告诉Spring把拿到的字符串比如 10086转换成一个Long类型的数字比如 10086L再交给 Java 代码使用。图解示例假设前端发来的请求URL是/follow/123/truejavaPutMapping(/{id}/{isFollow}) public Result follow( PathVariable(id) Long followUserId, // 步骤1: 找到 123 → 步骤2: 转换为 123L → 赋值给 followUserId PathVariable(isFollow) Boolean isFollow // 步骤1: 找到 true → 步骤2: 转换为 true → 赋值给 isFollow )执行结果就是followUserId变量的值是123LisFollow变量的值是true几个关键点变量名不重要你可以用PathVariable(id) Long abc效果一样abc变量最终的值也是123L。变量名是给开发者自己看的。转换有规则Spring 支持大部分常见类型的自动转换如int、long、boolean、Date等。如果URL里的值无法转换比如给Long传了个abc就会报错。类型不匹配时如果把URL里的{id}值 123 赋给PathVariable(id) String followUserId类型转换这一步就不做followUserId的值就直接是字符串123。所以严格来说是类型转换但它和“参数绑定” (PathVariable) 经常一起出现共同完成了从“URL字符串”到“Java对象”的转换工作。这个用法在Spring MVC里非常常见不仅仅是处理路径变量处理普通请求参数时RequestParam也是同样的原理。3.2 Service层实现java Service public class FollowServiceImpl extends ServiceImplFollowMapper, 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 QueryWrapperFollow() .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:userIdValue为用户关注的所有博主ID三、共同关注功能实现4.1 Controller层javaGetMapping(/common/{id}) public Result followCommons(PathVariable Long id) { return followService.followCommons(id); }4.2 Service层实现javaOverride public Result followCommons(Long id) { // 1. 获取当前用户 Long userId UserHolder.getUser().getId(); String key1 follows: userId; String key2 follows: id; // 2. 求交集 SetString intersect stringRedisTemplate.opsForSet() .intersect(key1, key2); if (intersect null || intersect.isEmpty()) { return Result.ok(Collections.emptyList()); } // 3. 解析ID集合String - Long ListLong ids intersect.stream() .map(Long::valueOf) .collect(Collectors.toList()); // 4. 查询用户信息 ListUserDTO 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:当前用户ID与follows:博主ID的交集即为共同关注关键优化需要在关注/取关时同步维护Redis数据避免直接查询数据库提高性能五、关注推送Feed流5.1 Feed流方案对比Feed流有三种实现模式方案写比例读比例延迟实现难度使用场景拉模式读扩散低高高复杂很少使用推模式写扩散高低低简单用户量少、无大V推拉结合中中低很复杂过千万用户量黑马点评选用推模式因为项目规模较小推模式实现简单、时效性好。5.2 数据结构选型ZSet为什么使用SortedSet✅ 元素具有唯一性不重复推送✅ 可按时间戳排序score存储时间戳✅ 支持滚动分页避免传统分页的数据重复/遗漏问题对比List的问题如果使用List按角标分页当有新数据插入时角标会变化导致查询重复或遗漏。而ZSet按score范围查询可以避免此问题。5.3 发布笔记时推送javaOverride 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. 查询所有粉丝 ListFollow 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 问题新数据插入导致角标偏移数据重复或遗漏滚动分页实现javaOverride public Result queryBlogOfFollow(Long max, Integer offset) { // 1. 获取当前用户 Long userId UserHolder.getUser().getId(); String key feed: userId; // 2. 查询收件箱滚动分页 SetZSetOperations.TypedTupleString typedTuples stringRedisTemplate.opsForZSet() .reverseRangeByScoreWithScores(key, 0, max, offset, 2); if (typedTuples null || typedTuples.isEmpty()) { return Result.ok(); } // 3. 解析数据 ListLong ids new ArrayList(); long minTime 0; int os 1; for (ZSetOperations.TypedTupleString 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); ListBlog 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当前时间戳上次的最小时间戳min00offset0上次最小值相同分数个数count335.5 常见问题与优化问题1MySQL查询顺序错乱现象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 关键技术点读写分离关注关系存MySQL社交关系存Redis缓存同步关注/取关时同步更新Redis Set推模式发布时主动推送给粉丝降低查询延迟滚动分页避免传统分页的数据重复问题顺序保持MySQL使用ORDER BY FIELD保持Redis排序6.3 优缺点分析优点推模式实现简单粉丝查询延迟低Redis ZSet支持高效的排序和分页Set交集实现共同关注简单高效缺点推模式对存储压力大每个粉丝存一份大V场景下写放大问题严重未使用推拉结合优化适合百万级用户的方案这个用户关注功能的完整实现涵盖了社交产品的核心需求适合作为学习Redis在社交场景应用的入门案例。结语如果对你有帮助请点赞关注收藏你的支持就是我最大的鼓励