十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

黑马点评好友关注模块:Redis Set实现共同关注与关注接口实战

黑马点评好友关注模块:Redis Set实现共同关注与关注接口实战 黑马点评项目做到第8集“好友关注”的时候很多人才真正意识到点评类App的复杂度不在于商品展示也不在于缓存穿透而在于人和人之间的关系链。这一集做的是两件事关注和取关某个博主以及查看自己和对方的共同关注。但这两件事背后牵连出来的却是Feed流推送、粉丝体系、推荐逻辑一整条线。这篇笔记会把这一集涉及的接口设计、Redis数据结构选型、事务边界、以及几个高频踩坑点一次性整理清楚。如果你正在刷黑马点评实战篇、准备写进简历项目或者单纯想搞明白“社交关系链到底该用什么数据结构来存”这篇内容应该能帮你省下不少排查时间。1. 好友关注模块业务定位与整体设计思路1.1 这个模块在整个黑马点评项目里处于什么位置黑马点评这个项目做到这里前面已经完成了商户查询缓存、优惠券秒杀、达人探店这些核心模块整体上已经是一个“能跑通主流程”的点评平台了。但你会发现这些功能本质上都是人与内容的交互比如看一家店、领一张券、点赞一篇探店笔记。而“好友关注”这个模块才是项目从“人找内容”切换到“内容找人”的分水岭。为什么这么说因为点评类和社交类产品最关键的体验是你关注的博主发布了新笔记你要能第一时间看到。这就引出了后面黑马点评实战篇里非常重要的一节“关注推送”Feed流。而Feed流能不能做好完全取决于这一集的关注关系有没有设计清楚。关注关系是数据库里的一条记录是Redis里的一个Set集合同时也是后续推送逻辑的“寻址依据”。这一集如果只做了增删改查就没意思了真正值得花时间想清楚的是这套关系数据要怎么存才能让后面的功能不返工。对于正在准备简历项目的人来说这个模块也是个很不错的亮点。相比单纯的CRUD它天然带上了“数据结构设计”和“Redis应用”这两个技术点面试时能展开讲的东西很多。1.2 关注关系在数据库里长什么样先看基础的表结构。黑马点评里关注功能的落库表是tb_follow核心字段其实只有四个字段名类型说明idbigint主键雪花IDuser_idbigint当前用户IDfollow_user_idbigint被关注的用户IDcreate_timetimestamp关注创建时间这张表的设计逻辑和“点赞表”“收藏表”几乎一样本质上是一张“用户关系中间表”。一个用户关注了很多人一个人也会被很多人关注这就是典型的多对多关系。多对多关系在关系型数据库里必须拆成中间表tb_follow干的就是这个活。这里我建议你额外做一件事给user_id和follow_user_id加上一个联合唯一索引。也就是UNIQUE KEY uk_user_follow (user_id, follow_user_id)。原因是用户在快速点击“关注”按钮时前端有可能会重复提交如果没有这层兜底数据库里就会插入多条相同的关注记录后面查关注列表、做共同关注时数据会莫名其妙重复。加了唯一索引之后重复插入直接报错业务层再捕获这个异常做提示比查一次再插一次要可靠得多。1.3 为什么还要用Redis的Set再存一份这是这一集里面最值得琢磨的一个设计决策。你可能会有疑问既然tb_follow表已经把关注关系存下来了查询关注列表直接查数据库不就行了为什么还要在Redis里维护一份答案就在“共同关注”这个功能上。假设用户A关注了用户B我们想展示“A和B共同关注了哪些人”。如果只靠数据库最直接的做法是查两次关注列表然后在内存里做交集或者写一个带INNER JOIN的SQL让数据库去算交集。这两种方式不是不行但都有点别扭第一种在数据量上来之后内存遍历的开销不小第二种在MyBatis-Plus里写起来并不舒服而且SQL的可读性也比较差。Redis的Set集合天生支持交集运算一行SINTER命令就能搞定。所以黑马点评的思路是每个用户维护一个Setkey为follows:{userId}这个Set里存的是该用户关注的所有用户ID计算共同关注时直接对两个Set执行SINTER。这个设计不只是为了做一个共同关注它对后面的Feed流同样重要。当用户发了一条探店笔记系统需要知道他有哪些粉丝、要把这条笔记推给谁如果在Redis里维护好关注关系这个查询就是O(1)级别的操作。所以这里用Redis不是炫技是后续功能的地基。2. 关注与取关接口代码一步步拆开看2.1 Controller层接口设计好友关注这一集里Controller层的接口一共三个语义非常清晰接口方法路径功能PUT/{id}/{isFollow}关注或取关指定用户GET/or/not/{id}判断当前用户是否已关注指定用户GET/common/{id}查询当前用户与指定用户的共同关注这里注意一下路径参数的设计。/{id}/{isFollow}这个写法把业务动作直接放在了URL里isFollow为true表示关注为false表示取关。这种设计的好处是前端只需要用一个接口就能处理“关注/取关”两种状态切换按钮时直接改一个布尔值就行。很多项目会把关注和取关拆成两个接口但在这里合并反而更简洁因为关注和取关本质上就是同一条记录的存在与否合并在语义上是完整的。2.2 isFollow判断当前用户是否已关注目标用户这个接口的逻辑很简单查一下tb_follow表里有没有user_id 当前登录用户ID且follow_user_id 目标用户ID的记录。有就返回已关注没有就返回未关注。代码基本长这样public Result isFollow(Long followUserId) { // 1. 获取当前登录用户ID Long userId UserHolder.getUser().getId(); // 2. 查询是否关注 Integer count query() .eq(user_id, userId) .eq(follow_user_id, followUserId) .count(); return Result.ok(count 0); }这里有两个细节值得讲。第一个是UserHolder。黑马点评项目里用ThreadLocal封装了一个UserHolder登录用户的UserDTO在拦截器里被放进去业务层随时可以取到当前用户。这里不能直接从前端传userId否则任何用户都能传入别人的ID来伪装成别人这是典型的越权漏洞。所有和“当前用户”相关的操作都必须从服务端上下文获取。第二个是“判断是否关注”这个操作项目里查的是数据库而不是Redis。有的同学觉得既然Redis里已经存了一份关注关系这里直接用SISMEMBER follows:{userId} {followUserId}不是更快吗确实更快但教学项目里查库有一个好处数据库是最终数据源即使Redis里的数据因为某些原因丢了这里依然能查出来。实际生产环境可以结合缓存来加速但如果对一致性要求高查库是更稳的做法。2.3 follow关注与取关的原子操作follow接口是这一集的核心。先看简化版的代码逻辑public Result follow(Long followUserId, Boolean isFollow) { // 1. 获取当前登录用户ID Long userId UserHolder.getUser().getId(); String key follows: userId; // 2. 判断是关注还是取关 if (Boolean.TRUE.equals(isFollow)) { // 2.1 关注写入数据库 Follow follow new Follow(); follow.setUserId(userId); follow.setFollowUserId(followUserId); boolean isSuccess save(follow); // 2.2 数据库写成功后再写入Redis if (isSuccess) { stringRedisTemplate.opsForSet().add(key, followUserId.toString()); } } else { // 3.1 取关删除数据库记录 boolean isSuccess remove(new LambdaQueryWrapperFollow() .eq(Follow::getUserId, userId) .eq(Follow::getFollowUserId, followUserId)); // 3.2 删除Redis中对应的成员 if (isSuccess) { stringRedisTemplate.opsForSet().remove(key, followUserId.toString()); } } return Result.ok(); }这个代码看着简单但它背后的顺序问题很讲究。项目里是先操作数据库数据库成功之后再操作Redis。为什么是这个顺序因为数据库是事实来源source of truthRedis是加速层。如果先写Redis再写数据库Redis写成功但数据库写入失败就会出现Redis里有关注关系、数据库里却没有的情况后面的共同关注、Feed流推送全都会出问题。有没有必要加Transactional这里我建议你谨慎。save()和remove()这两个数据库操作确实可以通过事务包起来但问题是Redis操作不在事务范围内。如果你在方法上加了Transactional数据库操作失败会回滚但Redis操作不会因为事务回滚而撤销。也就是说事务只能保证数据库这一侧的一致性Redis和数据库之间的最终一致性还是得靠“顺序补偿”来解决。所以在教学项目里不加事务其实是合理的但你要清楚这样设计的代价如果Redis操作失败数据库记录和Redis数据会不一致这时候需要靠日志或者定时任务去补偿。实际开发中更稳妥的做法是先把数据库操作做成功然后Redis的更新操作即使失败也不要影响主流程记录一条错误日志后续通过重试机制把Redis里的数据补上。如果你在面试中被问到“Redis和数据库怎么保证一致性”把这一套逻辑讲清楚比单纯背一个“先更新数据库再删缓存”的结论要有说服力得多。3. 共同关注一行SINTER解决的老大难问题3.1 需求理解什么是共同关注业务上有什么用共同关注的业务含义很简单我和你共同关注了哪些人。你点进某个博主的个人主页看到一个“共同关注”列表里面是你们俩都关注的那些账号。这个功能在社交产品里非常常见它有两个作用一是降低用户的社交压力。你看到一个陌生博主如果发现你们有3个共同关注你对他的信任感会明显上升这3个人就是社交关系里的“信任桥梁”。二是产品可以通过共同关注做关系链推荐。既然你和A都关注了B那么A还关注了C系统就可以把C推荐给你。黑马点评在项目里展示的是最基础的共同关注查询但它背后的推荐逻辑和这个是一致的。3.2 用Redis Set实现共同关注的完整流程实现的核心就是Redis的SINTER命令。两个Set做交集得到的就是共同关注的用户ID集合。核心代码public Result followCommons(Long id) { // 1. 获取当前用户ID Long userId UserHolder.getUser().getId(); // 2. 拼接两个Redis key String key1 follows: userId; String key2 follows: id; // 3. 求交集 SetString intersect stringRedisTemplate.opsForSet().intersect(key1, key2); // 4. 空集合直接返回 if (intersect null || intersect.isEmpty()) { return Result.ok(Collections.emptyList()); } // 5. 解析用户ID并批量查询用户信息 ListLong ids intersect.stream() .map(Long::valueOf) .collect(Collectors.toList()); ListUserDTO userDTOS userService.listByIds(ids) .stream() .map(user - BeanUtil.copyProperties(user, UserDTO.class)) .collect(Collectors.toList()); return Result.ok(userDTOS); }这个代码里有几个细节需要特别说明。第一key2里的id是你正在浏览的那个用户的ID也就是点击“共同关注”时目标用户的ID。这个ID来自前端路由参数。这里不需要校验目标用户是否存在空列表也算正常返回。第二intersect可能为null也可能为空集合。当其中一个用户还没有关注任何人时对应的Set在Redis里根本不存在SINTER会返回空集合不会报错。但代码里还是要做一次判空一方面是为了防止后续stream()出现NPE另一方面是提前返回空列表能省掉一次无意义的数据库查询。第三拿到交集后不能直接把ID返回给前端因为前端需要的是用户头像和昵称需要拿ID去t_user表批量查用户信息。这里用了listByIds一条SQL就能查完所有用户效率比循环查要高很多。3.3 边界情况key不存在、集合为空、ID精度丢失这里把最容易踩的边界情况一次性列出来。一是Redis里压根没有这个Key。比如一个新注册用户从来没关注过任何人那么follows:{userId}这个Key就不存在。SINTER对不存在的Key会视为空集合不会报错这是Redis的容错机制。二是用户被删除后留下的“脏数据”。集合里的ID可能指向一个已经注销的用户listByIds查不到时对应的用户会被过滤掉这是正常的不需要特殊处理。三是ID精度丢失问题。雪花ID是19位的Long类型而JavaScript的Number类型只能精确表示2的53次方以内的整数也就是大约9千万亿。如果把19位的雪花ID直接返回给前端超过安全范围的数字会被四舍五入导致前端拿到的ID和真实ID不一致。你可能会遇到这样一个诡异现象明明关注的是A用户前端显示却是B用户排查到最后发现是ID精度丢了。解决办法是在Jackson配置里把Long类型统一序列化为String这样前端拿到的是字符串不会丢精度。这个坑在评论区、点赞、关注模块里都会出现越早处理越省心。4. 关注列表查询分页、批量查询与数据脱敏4.1 查询关注列表的业务场景关注接口和共同关注接口做完之后一个很自然的延伸是展示某个用户的关注列表。你点进一个博主的主页除了看到他发过的笔记通常还有一个“关注”TAB里面列出了他关注的所有人。这个列表需要支持分页否则一个关注了几千人的大V一次性返回几千条数据前端渲染会卡网络传输也浪费。分页查询关注列表也是一个很常见的面试考点因为它直接涉及数据库查询性能和数据组装方式。黑马点评项目在视频里没有特别展开这个列表接口但把这个功能自己补上对理解整个模块会很有帮助。4.2 从Follow表到UserInfo避免N1的写法最直观的写法是分页查tb_follow表得到当前页的关注记录的follow_user_id列表然后循环查询用户信息。但这里有个经典的N1问题查询一次关注列表得到N条记录然后循环N次查用户表最终执行了1N条SQL。用户量小的时候没感觉用户量上来之后就是性能灾难。正确做法是先分页查出关注ID列表再用listByIds一次性查出所有用户。整个过程只有两条SQLpublic Result queryFollowList(Long id, Integer current) { // 1. 分页查询关注表 PageFollow page lambdaQuery() .eq(Follow::getUserId, id) .page(new Page(current, 10)); // 2. 如果没有关注任何人直接返回 if (page.getRecords().isEmpty()) { return Result.ok(Collections.emptyList()); } // 3. 收集关注用户的ID ListLong ids page.getRecords().stream() .map(Follow::getFollowUserId) .collect(Collectors.toList()); // 4. 批量查询用户信息 ListUserDTO userDTOS userService.listByIds(ids).stream() .map(user - BeanUtil.copyProperties(user, UserDTO.class)) .collect(Collectors.toList()); return Result.ok(userDTOS); }需要注意的是如果当前页查出来的用户ID列表为空listByIds也可能返回空列表所以这里要提前做一次空判断避免后面流式操作出现无效查询。另外分页大小建议设置一个合理的值比如10或者20不要一次查太多。从性能角度还可以进一步优化如果这个列表的访问频率很高可以把用户的基础信息缓存到Redis里或者用缓存预热的方式减少数据库压力。不过对于教学项目先用listByIds把N1问题解决掉就已经达到优化的主要目的了。4.3 数据脱敏与DTO转换这个点虽然简单但很容易忽略。用户表t_user里有password字段如果你直接把User对象返回给前端等于是把密码哈希也暴露了出去这在真实项目里是严重的安全事故。即使你项目里密码是加密存储也绝对不能把整个实体对象直接抛给前端。正确的做法是定义一个UserDTO只包含前端需要的字段比如id、nickName、icon。使用BeanUtil.copyProperties可以把User对象的属性拷贝到UserDTO方便又安全。黑马点评项目里有现成的UserDTO直接用就行。这个习惯在后面的所有接口里都要保持。不仅是关注列表任何返回用户信息的接口都用DTO做一层转换。面试时如果聊到安全设计主动提一句“实体类不会直接返回统一通过DTO脱敏”会是一个加分项。5. 实战踩坑与排查实录5.1 雪花ID到前端后精度丢失查了一天居然是序列化问题这个坑我在做关注功能时遇到得最莫名其妙。当时前端反馈说点进某个用户的关注列表再点其中一个用户跳转到的主页却是另一个人。一开始怀疑是接口查询参数传错了后来发现是ID精度丢失。原因前面提过雪花ID是19位LongJavaScript的Number精度不够后端返回的直接是数字前端拿到时已经被截断了。解决办法是全局配置Jackson序列化器把Long和long类型的字段统一转成String。配置方式很简单写一个Jackson2ObjectMapperBuilderCustomizer的Bean注册ToStringSerializer即可。这样配完之后所有接口返回的Long类型ID都会变成字符串前端不会丢精度。这个配置一定要写在公共配置里让所有接口都生效。不然今天在这个接口修了明天另一个接口又出问题来回折腾非常浪费时间。5.2 Redis和数据库状态不一致取关成功后列表里还有这个人典型场景是这样的用户取关了一个博主数据库记录已经删了前端也提示取关成功但再看“共同关注”列表这个人还在。原因大概率是取关时Redis里的成员没有删掉或者删除失败了。复盘一下follow方法的执行顺序先删数据库记录再删Redis成员。如果第二步因为网络抖动、Redis超时等原因失败了那么数据库里没有关注关系Redis里却还残留着followUserId。SINTER的结果来自Redis就会把这个人算进共同关注里。解决思路是补偿。最简单的做法是每次取关时无论数据库删除成功与否都尝试删除Redis里的成员如果Redis删除失败记录日志后续通过定时任务扫描数据库和Redis的数据做比对修正。更精细的做法是把“删除Redis”改成“删除Redis失败后重试N次仍然失败则丢进MQ由消费者异步清理”。作为学习项目你不一定需要实现完整的补偿方案但一定要有这个意识Redis和数据库是两个独立的存储它们之间的数据一致性是需要专门设计的。在简历上写“解决了Redis与数据库的数据一致性问题”比单纯写“用了Redis”要有价值得多。5.3 从好友关注到Feed流推送这里提前埋好两个扩展点整个“好友关注”模块做完先别急着往下赶进度。把目光放到下一集“关注推送”上你会发现这一集留下的两个Redis Set就是Feed流的地基。第一个扩展点是粉丝列表。当前项目里只维护了follows:{userId}这个“我关注了谁”的集合并没有维护“谁关注了我”的粉丝集合。但在做Feed流推送时用户发布笔记后系统需要快速拿到他的所有粉丝把笔记推送到粉丝的收件箱里。如果现在关注接口里顺手维护一个fans:{userId}集合把当前用户加进去后面发笔记时的粉丝查询就是一次SMEMBERS的事。第二个扩展点是Feed流收件箱。黑马点评中实现关注推送时每个用户维护了一个ZSet类型的收件箱key类似feed:{userId}member是笔记IDscore是发布时间戳。用户A发布笔记后系统遍历A的粉丝列表往每个粉丝的feed:{userId}里写入这条笔记ID。这个过程依赖的正是粉丝列表。也就是说关注模块这里提前把fans:集合建好Feed流推送就少做一半的事。如果你是自己扩展项目建议关注和取关时同时维护两个集合// 关注我关注列表加他他的粉丝列表加我 stringRedisTemplate.opsForSet().add(follows: userId, followUserId.toString()); stringRedisTemplate.opsForSet().add(fans: followUserId, userId.toString()); // 取关两边同步移除 stringRedisTemplate.opsForSet().remove(follows: userId, followUserId.toString()); stringRedisTemplate.opsForSet().remove(fans: followUserId, userId.toString());这样数据库、关注Set、粉丝Set三者保持一致后面的功能会顺畅很多。我在实际刷这个项目时最深的一个体会是好友关注这个模块视频里看起来就三个接口代码量不大但它把“数据怎么存”这件事的重要性放大了。数据库表、Redis Set、DTO、序列化、事务边界任何一个环节没想透后面做Feed流就会返工。如果你正在学这一节建议不要只停留在“把代码敲出来能跑”的程度而是把共同关注的分页、唯一索引、数据一致性补偿这几个点自己动手补上。跑通是入门跑好才是经验。
返回列表