ARTICLE DETAIL

资讯详情

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

Redis Set实现好友关注:关注/粉丝/共同关注与Feed流关系存储实战

Redis Set实现好友关注:关注/粉丝/共同关注与Feed流关系存储实战 博主是刚把“好友关注”这个模块啃完趁热乎把笔记整理出来。如果你也在跟黑马点评这套项目或者正在准备简历上写“关注/粉丝/共同关注”这类社交关系功能这篇应该能帮你省不少事。我用的是实战视角来拆不讲套话直接记录我在做这个模块时的技术选型思考、代码落地细节还有真正让我卡住过的几个点。先说结论好友关注这个功能表面上看起来就是“A关注B”“A取消关注B”“查看我的关注列表/粉丝列表”这么几个接口但真正写起来之后才会意识到它其实是一个很典型的“关系链存储”问题。用关系型数据库做当然能跑但当你开始考虑“共同关注”“我关注的人里有哪些也关注了他”“给用户推荐可能认识的人”这些进阶场景时关系型数据库的写法会变得越来越笨重。而黑马点评这个实战项目把好友关注放在Redis篇章里讲背后是有明确技术动机的。这篇笔记围绕一个核心思路展开关注关系用Redis的Set结构来存每个用户维护“我关注的人”和“我的粉丝”两个集合关注/取关操作同时在两套存储里生效查询场景里共同关注直接走集合交集运算。这套模型看起来简单但越往深挖越能感受到它对后续功能的好处尤其是下一阶段常见的Feed流好友动态推送几乎就是为它量身定做的。不管你是在校生做毕业设计还是工作后想在简历上增加一个拿得出手的Redis实战模块看这一篇就够了。1. 好友关注的业务拆解为什么表结构方案在真实场景里撑不住1.1 先把这个功能的真实需求讲清楚在做任何技术方案之前第一步永远是搞清楚产品到底要什么。可以对照你熟悉的社交产品来理解比如微博、比如各类内容社区只要是带“关注”功能的产品解剖开来看需求其实就是这么几类用户A点击“关注”按钮把用户B纳入自己的关注列表。用户A再次点击同一个按钮取消对用户B的关注。用户A可以查看自己关注了哪些人关注列表/我关注的人。用户A可以查看有哪些人关注了自己粉丝列表。在用户B的主页展示“我是否已经关注他”这样前端按钮才能显示“关注”或“已关注”。进一步演化的需求我和某个用户之间的“共同关注”也就是我们都关注了哪些人。黑马点评这类项目里最常见的一个变体是“博主与粉丝”的语境普通用户可以关注博主博主主页会展示粉丝数、关注数用户点进博主主页时能看到自己是否已关注还能看到自己和他有多少个共同关注。在数据库层面设计一张关注关系表正常情况下就三个字段id主键、user_id关注发起方、follow_user_id被关注方。不管业务上是叫博主还是叫用户本质是同一套模型。1.2 数据库表方案最朴素的写法和它的坑用关系型数据库来做关注功能新手最常见的第一版SQL表大概是这样的字段名类型说明idbigint主键自增user_idbigint关注发起方IDfollow_user_idbigint被关注方IDcreate_timedatetime关注时间update_timedatetime更新时间然后注意到一个业务规则同一个用户不能重复关注同一个人。于是要把user_id和follow_user_id做成联合唯一索引防止脏数据。这个设计在数据量小、纯练手的阶段是没毛病的。但你把它放到真实场景里就会发现问题每次判断“我是否关注了这个博主”都要对这张表做一次count或者limit 1查询查看粉丝列表要按被关注方分组去查查看关注列表要按关注发起方去查共同关注更是要用join或者in子查询去碰撞。一旦单表数据量过千万联合索引的维护成本、查询成本都会上来而且很多场景其实只需要一个“是与否”的布尔判断不需要落到行记录里。更麻烦的问题是社交关系里的高频操作非常依赖于集合运算。比如我想知道两个用户各自关注的人里有多少交集在关系型数据库里你得先把两个集合查出来再在应用内存里做交集或者用SQL做一些复杂关联而这一操作如果发生在Redis里一行SINTER就搞定了。1.3 实战项目里为什么点名要上Redis黑马点评的项目语境里前面已经演示过缓存穿透、缓存击穿、缓存雪崩的解决方案也用过Redis做分布式锁。到“好友关注”这里本质上是想让学习者体会到另一个重要的存储设计思路不是所有数据都需要进关系型数据库也不是所有数据都适合进关系型数据库。判断依据很简单访问模式是“点查行记录”还是“集合操作”。点赞、关注这种功能一次操作只是往一个集合里加一个元素或者删一个元素状态判断是“在不在集合里”这就是典型的集合操作。订单、支付流水这种需要强事务、需要按条件组合查询、需要跟其他业务表做关联这才更适合关系型数据库。“好友关注”模块被放进实战篇核心不是为了让你会调几个Redis命令而是让你建立一种判断力新需求来了你能分辨出该用哪种存储来承接。基于我对项目的理解它采用的方案是“Redis做关系存储主战场 数据库表做辅助记录”。你会发现Redis里存的是关系本体数据库存的是关系发生的时间、来源页面等业务上下文查找关系和判断状态优先走Redis只有列出完整列表需要展示更多属性时才回表补充。这套做法在真实企业项目里非常常见。2. 基于Redis Set的关注关系模型数据结构选型的关键依据2.1 为什么偏偏是Set而不是List或者Hash先说结论关注关系天然是“无序集合 去重 集合运算”这三个特征的结合体而这三点和Redis的Set结构完全对上。无序集合关注列表的展示顺序通常按照关注时间倒序这个“时间倒序”可以在存储层做到也可以在业务层查询时再排本身不要求底层结构维护一个严格的顺序。去重同一个用户不可能关注同一个人两次Set天然就是去重的不需要额外去判断。集合运算共同关注的本质是交集我是否关注过他、他是否关注过我本质是成员判断也就是isMember这些运算在Set结构上是时间复杂度O(1)级别的。可能有人会问List也能去重不就行了吗不行List本身不去重每次插入前你得自己判断一遍这个判断是O(n)的一旦一个用户的关注列表很长每次加关注前都扫描一遍性能很难看。Hash也能存储key是用户IDvalue是关注对象的ID但它保存的是一个映射关系你要拿“某个用户关注的所有人”得把整个Hash全量拿出来这个开销和后续的集合操作都没有Set优雅。我个人做过一个简单的Benchmark心理账同一台Redis实例用Set做关注列表A用户关注了B之后再判断A是否关注BSISMEMBER一条命令毫秒级返回如果用数据库表先要走一次索引再回表一次平均延迟在1到2毫秒不等看着不高放到每秒几千次点击的场景里就撑不住了。2.2 双向集合的设计关注和粉丝各存一份用Redis Set建模关注关系时不是建一个名为“follow关系”的大集合就完事。真正合理的设计是给每个用户维护两个集合一个存他关注的人一个存他的粉丝。用具体key设计来举例假设当前用户ID是1001关注的博主ID是2002key为follow:1001的Set集合存放所有用户1001关注的人SADDfollow:10012002。key为fans:2002的Set集合存放所有关注了2002的人SADDfans:20021001。这种双向冗余设计在操作时多写了一条命令但换来的是查询时的单向直达。用户1001想看自己的关注列表直接读follow:1001整个集合博主2002想看自己的粉丝列表直接读fans:2002整个集合用户1001关注博主2002之后想看两人是否互相关注分别判断1001是否在fans:2002里、2002是否在follow:1001里各一次SISMEMBER就能得出结论。在真实项目里为了不搞混key前缀的含义很多人会在注释和命名规范上把前缀统一为follow:userId和fans:userId或者用带模块名的完整前缀比如social:follow:{userId}、social:fans:{userId}来避免跟其他业务模块撞key。黑马点评这类教学项目常规使用的是短前缀阅读源码时注意区分方向别把关注集合和粉丝集合搞反。2.3 key粒度与过期时间的取舍经验Set集合的key是按用户ID维度拆的所以一个Redis里会存在大量key每个key的value又是一个集合。这种设计下要特别注意key的过期时间问题。关注关系是长期有效的用户可能一个博主的粉丝列表里待几年。如果给这些key设置过期时间一旦过期Redis里就找不到关注关系了业务上就会判断成“未关注”这是不可接受的。所以你在看这个模块的代码时会发现项目几乎没有对这些关系key设置TTL让它们长期驻留在Redis中。但这会带来另一个问题Redis内存占用会持续增长。所以真实项目里不一定所有关系都放Redis可能会做冷热分层也可能对不活跃用户的关系做持久化清理。教学项目一般不做那么深但你面试时要能答上这句关系型数据常驻Redis需要在内存容量和命中率之间做取舍必要时引入冷热分离或淘汰策略。数据库表在这里的角色也很清楚Redis里存Set关系DB里留一份follow记录主要用于后台管理、数据报表、对账恢复。就算Redis里的key因某种原因被误删还能根据数据库表做回放重新构建关系集合。这就是我前面说的“Redis做读路径主存储、DB做持久化兜底”。3. 关注和取关接口的实现细节事务边界与一致性怎么设计3.1 关注/取关是同一个接口还是两个接口先解决最基础的问题前端按钮在“关注”和“已关注”之间切换后端是设计成两个接口还是一个接口根据当前状态自动翻转正规项目里用的方案通常是两个动作方向都有对应接口甚至一个接口带一个action参数而不会让后端去猜当前状态。原因是关注和取消关注虽然看起来是互逆操作但它们在业务上可能伴随不同的副作用。举例来说用户关注博主之后可能需要给博主发一条站内通知粉丝数要加一甚至要触发一个欢迎私信而取消关注之后的逻辑是清理通知、粉丝数减一两套副作用完全不同硬塞进一个翻转逻辑里后期会非常难维护。黑马点评里给的接口设计也走的是这种风格关注操作PUT/api/follow/{id}/{isFollow}含义是把当前登录用户和目标用户的关系设置为关注或不关注。这里的isFollow像一个布尔状态位前端根据目标用户主页上“我是否已经关注他”的结果来传值。为true表示要关注为false表示要取关。这个接口设计的好处是前端语义清晰后端拿到isFollow后直接走关注分支或取关分支不需要反向推断。如果这个动作执行成功再回去刷新关系状态前端按钮就切换成对应形态。3.2 写Redis和写数据库的顺序到底该谁先谁后这是关注接口里最让我纠结、也最值得写进笔记的地方。我们先确定事实关注操作要更新两套存储一套是Redis的Set集合另一套是数据库的follow关系表。先写Redis再做数据库好处是用户发起的“是否已关注”的后续查询都走RedisRedis先更新的情况下后续查询立刻能看到最新的状态体感很快。风险是如果数据库写入失败Redis里已经多了一个关注关系而持久层没有记录两边就不一致了。先写数据库再做Redis好处是数据库操作成功了这个行为才算数有事务兜底Redis只是缓存层可以随时根据数据库重建。风险是如果数据库响应慢接口耗时变长极端情况下Redis更新失败用户看到的关注状态是旧的。真实业务里怎么选我的结论是看你的Redis是“数据主存储”还是“缓存层”。如果只是把Redis当缓存那就应该以数据库为准先更新数据库再更新Redis并且Redis的更新失败要做补偿比如定时任务扫描不一致数据。但黑马点评这个模块里根据项目常见设计的语境Redis更多承担的是关系数据的快速读写层数据库用来做持久化。那我要保证的就是操作顺序不要丢数据。最佳实践其实是使用事务性消息思路或本地日志表做最终一致性但教学项目不会设计这么重。简化之后的、很多实际项目在用的方式是“Redis先执行 数据库延迟补偿”或者“数据库先执行 Redis可靠性重试”。我自己的代码落地时选的是数据库操作和Redis更新分两步但把更新Redis这一步放在主流程里catch到异常时做一次retry并记录错误日志方便人工介入。注意这里有一个很隐蔽的坑如果在数据库和Redis之间不做任何幂等保护并发重复请求会导致什么问题比如用户1001疯狂点击关注按钮两个请求同时打到后端都会执行“查询未关注 - 执行插入 - 更新Redis”结果数据库里因为联合唯一索引只会成功一条另一条会报DuplicateKeyException但Redis的SADD是幂等的两条都会成功返回。所以Redis状态是对的但接口却会暴露异常信息给用户。处理方法也有两种流派一个是在业务层对userId和followUserId做分布式锁二是把数据库的重复插入异常捕获后当作“已经关注成功”返回而不是往上抛。我建议在学习阶段把分布式锁加上等到理解了锁的开销再做优化。3.3 实现关注/取关的步骤拆解与代码要点关注逻辑可以拆成以下步骤获取当前登录用户ID注意要从登录态中获取而不是前端传参。判断当前登录用户是否与目标用户是同一个人业务上不允许自己关注自己要直接拒绝。判断isFollow的值。为true时执行关注流程为false时执行取关流程。关注流程往当前用户的关注集合中SADD目标用户同时往目标用户的粉丝集合中SADD当前用户ID。取关流程从当前用户的关注集合中SREM目标用户同时从目标用户的粉丝集合中SREM当前用户ID。同步写数据库的follow记录数据库以一对唯一的user_id和follow_user_id作为去重约束。有一个细节是很多新手容易漏掉的数据库表里做取关操作的时候到底应该是物理删除还是逻辑删除我建议是物理删除。因为关注关系不涉及审计追溯用户取关之后这条关系已经没有存在价值了物理删掉还能控制表数据量。如果你为了留痕用逻辑删除那下一次用户再次关注时表里可能存在一条is_deleted1的旧记录你还得先恢复或者重新插入逻辑就复杂了。用伪代码来把关注/取关的核心逻辑表示得直观一点public Result follow(Long followUserId, Boolean isFollow) { // 1. 获取当前登录用户 Long userId UserHolder.getUser().getId(); if (userId.equals(followUserId)) { return Result.fail(不能关注自己); } String followKey RedisConstants.FOLLOW_KEY userId; String fanKey RedisConstants.FANS_KEY followUserId; if (Boolean.TRUE.equals(isFollow)) { // 关注双边集合各自写入 boolean isExist redisTemplate.opsForSet() .isMember(followKey, followUserId.toString()); if (isExist) { return Result.fail(请勿重复关注); } redisTemplate.opsForSet().add(followKey, followUserId.toString()); redisTemplate.opsForSet().add(fanKey, userId.toString()); // 数据库插入记录尽量放在Redis操作后并做补偿 saveFollowRecord(userId, followUserId); } else { // 取关双边集合各自移除 redisTemplate.opsForSet().remove(followKey, followUserId.toString()); redisTemplate.opsForSet().remove(fanKey, userId.toString()); // 数据库删除记录 removeFollowRecord(userId, followUserId); } return Result.ok(); }4. 共同关注和关注列表的查询链路从交集运算到分页聚合4.1 共同关注如何一行代码查出来进入页面场景用户A打开博主B的主页页面上除了展示B的粉丝数还会展示一行小字“你和他有xx个共同关注”点击能查看具体是哪些人。在数据库表方案下要查共同关注先查出我关注的用户ID集合再查出B关注的用户ID集合然后两者在内存里做交集。关注人数少时无所谓一旦某个大V关注了几千人甚至上万人内存里做集合碰撞会带来明显的无谓开销。但如果关系是存在Redis Set里的共同关注这个需求就变成了标准的Redis集合交集运算一条命令SINTER follow:1001 follow:2002这条命令返回的Set就是两个集合的交集也就是用户1001和用户2002共同关注的人。如果还要知道共同关注的人数用一个SCARD命令包一下就行。如果用Spring Data Redis代码写出来也非常简短SetString intersect redisTemplate.opsForSet() .intersect(followKey, targetFollowKey);这里有个常见的命名混淆要特别注意共同关注指的是“这两个用户各自都关注了谁”并不是“谁关注了我同时又关注了他”。理解清楚这一点你才不会在取key的时候把fans集合和follow集合搞混。4.2 关注列表和粉丝列表能直接拿全量Set返回吗掌握了Set查询的方便之后新手容易掉进一个误区既然SMEMBERS follow:1001能拿到用户1001关注的所有人ID那我直接把这些人全部返回给前端不就行了从功能演示角度当然可以但从真实业务角度不行。原因有两个第一一个全网顶级博主可能有几千万粉丝把这些ID全量返回给前端网络传输和前端渲染都扛不住。第二前端展示列表时不可能只展示“关注了谁”这样的ID它还要展示每个用户的头像、昵称、简介这些资料不在Redis的Set里而在数据库的用户表里。你先把几万个粉丝ID查出来再逐个去数据库补用户信息这就是典型的N1查询性能灾难。所以常规做法是分页查询从Redis中先取出某一页范围内的用户ID集合然后再查数据库批量补齐用户详情。Redis本身提供了SSCAN命令可以分批次遍历大集合避免一次性把所有数据拉出来但它的游标分页和普通的页码分页不完全对应所以在真实项目里如果你需要按关注时间倒序展示列表很多人反而会选择用数据库分页查关系表再批量回填用户信息。这也印证了一个观点Redis适合做关系判断和集合运算但做列表的复杂分页排序时不一定非要强求它。黑马点评这个模块在这个点上的处理方式是基于Set的特性直接返回的是所有关注用户ID查询用户详情之后再做填充。作为教学阶段这是够用的。但如果你想把这套东西写进简历并面对面试官追问一定要能说出“实际生产环境会走分页避免全量返回”这个优化方向。4.3 给查询链路做一次体检哪些操作绕了远路我把关注模块涉及到的所有查询场景画了一遍能归成这几类场景Redis命令时间复杂度正确性风险我关注的所有用户SMEMBERS follow:userIdO(n)n为关注数大量关注时返回数据过大我是否关注了某用户SISMEMBER follow:userId targetIdO(1)无用户粉丝数SCARD fans:userIdO(1)数值可能和数据库不完全一致需要容忍短时间差异用户关注数SCARD follow:userIdO(1)同上我和某用户的共同关注SINTER follow:userId follow:targetIdO(nm)大V场景耗时偏高可做异步处理其中最容易绕远路的操作是“用户详情页显示粉丝数、关注数”。很多人会先查数据库的user表再根据user表里的follow_count字段去展示数值但这个字段在关注/取关操作里需要额外维护很容易出现并发下数据不准的问题。直接走Redis的SCARD反而更精确——因为Set里存的才是关系的实时真相。但要注意如果数据库里的follow记录才是最后兜底数据那Redis里的SCARD和数据库里的count在极端情况下可能短暂不一致。你要做的不是追求绝对一致而是明确告诉自己对账机制用户看到粉丝数略微延迟没有关系但关注/取关之后的个人主页数量必须最终一致。5. 延伸一步好友关注天然通向Feed流时间线5.1 为什么说关注关系是Feed流的基石在一个内容社区产品里用户关注一个博主真正目的不是“关注”这个动作本身而是希望之后能看到这个博主发布的动态、笔记、视频。所以好友关注模块做完之后几乎所有项目都会顺理成章地进入下一个功能Feed流也就是好友动态推送。你把用户1001关注的所有博主ID放进follow:1001这个Set当博主2002发布一条新笔记时要怎么让用户1001在自己的首页看到最核心的问题就是Redis从follow:1001集合中确认“用户1001是我的粉丝”然后把笔记ID推送到他的收件箱。所以第8章的“好友关注”实际上在给后续的Feed流做铺垫它是一种数据骨架。如果你只学了关注接口就停下来你会觉得这个模块很简单但当你开始做Feed流、需要推送给所有粉丝时你才意识到之前维护双向Set集合有多重要。5.2 顺着思路继续走如果要推动态该用什么结构关注关系我们用Set存。但如果要在每个用户的收件箱里保存他关注的所有博主发布的动态并且按时间倒序展示Set就不够用了因为动态列表必须要按发布时间排序不能再是无序集合。所以Feed流的常用数据模型是给每个用户的收件箱创建一个ZSetmember存笔记IDscore存时间戳。博主发布笔记时遍历他的粉丝列表把笔记ID逐个写入每个粉丝的收件箱ZSet里。这时候你会发现你需要的粉丝列表正好就是fans:博主ID这个Set关注关系存储的设计在喂Feed流时发挥了直接作用。从Set到ZSet再到后面的“拉模式”“推模式”“推拉结合模式”每一步都建立在前面的关系数据之上。学到这里你会形成一个整体认识不要孤立地看某个Redis数据结构它们是配合在一起解决一个完整业务链路的。5.3 好友关注模块写在简历上的常见追问先自己盘一遍做完这个模块后面试官大概率会顺着你的项目经历提出几个经典追问我先把高频问题列出来你在复习时建议先自己模拟回答一遍为什么关注关系用Redis Set回答要点去重、O(1)成员判断、支持集合运算。关注列表和粉丝列表在Redis和数据库里各存一份数据一致性怎么保证回答要点写路径做顺序控制、依赖数据库唯一索引兜底、失败做日志补偿。如果Redis宕机了关注关系能不能恢复回答要点数据库follow记录还在可以设计回放机制从数据库重建Redis的Set。为什么要用粉丝集合而不仅是关注集合回答要点双向关系才能支持查看粉丝列表、判断是否互关、给后续Feed流推送提供粉丝列表。关注数/粉丝数统计用Redis还是数据库回答要点Redis的SCARD实时且开销小对账可以使用数据库备份。把这些想清楚之后你再看熟悉的那套“判断是否关注、点击后翻转、查看共同关注”流程基本就不会再含糊了。另外写代码时会踩到一个非常实际的坑就是Redis key的序列化方式。集成Redis时如果用的是JDK序列化key会带上各种转义前缀你肉眼排查数据时看到一串乱码SISMEMBER还老查不到。这个模块里建议把RedisTemplate的key序列化方式改成StringRedisSerializervalue如果是对象再考虑JSON序列化这样你在RedisDesktopManager一类的工具里看到的key就是干干净净的follow:1001排查问题会顺畅很多。我个人在调试时踩过的另一个坑是本地启动服务后用前端页面测试关注接口发现第二次点“已关注”按钮取消关注时后端收到的isFollow一直是false后来排查发现是前端按钮的状态没根据接口返回值去刷新也就是说前端拿到的“已关注”状态是页面加载时的旧状态。这个问题不在后端但如果你是前后端一起调试要留意到“状态刷新”这一步得依赖查询接口实时拉取不能把状态常驻在前端变量里。这套模块整体做下来我最深的感触是它的教学节奏很聪明关注功能只是表面真正让你练的是“双向集合维护”和“关系存储选型”这两种能力。把这两个内化了下一个遇到点赞、收藏、订阅这类功能时你会条件反射地想这个是不是也可以用类似的Set结构来表达能想到这里这个实战就算没白做。
返回列表