博主是刚把“好友关注”这个模块啃完,趁热乎把笔记整理出来。如果你也在跟黑马点评这套项目,或者正在准备简历上写“关注/粉丝/共同关注”这类社交关系功能,这篇应该能帮你省不少事。我用的是实战视角来拆,不讲套话,直接记录我在做这个模块时的技术选型思考、代码落地细节,还有真正让我卡住过的几个点。
先说结论:好友关注这个功能,表面上看起来就是“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表大概是这样的:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| user_id | bigint | 关注发起方ID |
| follow_user_id | bigint | 被关注方ID |
| create_time | datetime | 关注时间 |
| update_time | datetime | 更新时间 |
然后注意到一个业务规则:同一个用户不能重复关注同一个人。于是要把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是用户ID,value是关注对象的ID,但它保存的是一个映射关系,你要拿“某个用户关注的所有人”,得把整个Hash全量拿出来,这个开销和后续的集合操作都没有Set优雅。
我个人做过一个简单的Benchmark心理账:同一台Redis实例,用Set做关注列表,A用户关注了B之后,再判断A是否关注B,SISMEMBER一条命令毫秒级返回;如果用数据库表,先要走一次索引,再回表一次,平均延迟在1到2毫秒不等,看着不高,放到每秒几千次点击的场景里就撑不住了。
2.2 双向集合的设计:关注和粉丝各存一份
用Redis Set建模关注关系时,不是建一个名为“follow关系”的大集合就完事。真正合理的设计是给每个用户维护两个集合:一个存他关注的人,一个存他的粉丝。
用具体key设计来举例,假设当前用户ID是1001,关注的博主ID是2002:
- key为
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再做数据库,好处是用户发起的“是否已关注”的后续查询都走Redis,Redis先更新的情况下,后续查询立刻能看到最新的状态,体感很快。风险是如果数据库写入失败,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_deleted=1的旧记录,你还得先恢复或者重新插入,逻辑就复杂了。
用伪代码来把关注/取关的核心逻辑表示得直观一点:
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,代码写出来也非常简短:
Set<String> intersect = redisTemplate.opsForSet() .intersect(followKey, targetFollowKey);这里有个常见的命名混淆要特别注意:共同关注指的是“这两个用户各自都关注了谁”,并不是“谁关注了我同时又关注了他”。理解清楚这一点,你才不会在取key的时候把fans集合和follow集合搞混。
4.2 关注列表和粉丝列表能直接拿全量Set返回吗
掌握了Set查询的方便之后,新手容易掉进一个误区:既然SMEMBERS follow:1001能拿到用户1001关注的所有人ID,那我直接把这些人全部返回给前端不就行了?
从功能演示角度当然可以,但从真实业务角度不行。原因有两个:
第一,一个全网顶级博主可能有几千万粉丝,把这些ID全量返回给前端,网络传输和前端渲染都扛不住。第二,前端展示列表时不可能只展示“关注了谁”这样的ID,它还要展示每个用户的头像、昵称、简介,这些资料不在Redis的Set里,而在数据库的用户表里。你先把几万个粉丝ID查出来,再逐个去数据库补用户信息,这就是典型的N+1查询,性能灾难。
所以常规做法是分页查询:从Redis中先取出某一页范围内的用户ID集合,然后再查数据库批量补齐用户详情。Redis本身提供了SSCAN命令,可以分批次遍历大集合,避免一次性把所有数据拉出来,但它的游标分页和普通的页码分页不完全对应,所以在真实项目里,如果你需要按关注时间倒序展示列表,很多人反而会选择用数据库分页查关系表,再批量回填用户信息。这也印证了一个观点:Redis适合做关系判断和集合运算,但做列表的复杂分页排序时,不一定非要强求它。
黑马点评这个模块在这个点上的处理方式是基于Set的特性,直接返回的是所有关注用户ID,查询用户详情之后再做填充。作为教学阶段这是够用的。但如果你想把这套东西写进简历并面对面试官追问,一定要能说出“实际生产环境会走分页,避免全量返回”这个优化方向。
4.3 给查询链路做一次体检:哪些操作绕了远路
我把关注模块涉及到的所有查询场景画了一遍,能归成这几类:
| 场景 | Redis命令 | 时间复杂度 | 正确性风险 |
|---|---|---|---|
| 我关注的所有用户 | SMEMBERS follow:userId | O(n),n为关注数 | 大量关注时返回数据过大 |
| 我是否关注了某用户 | SISMEMBER follow:userId targetId | O(1) | 无 |
| 用户粉丝数 | SCARD fans:userId | O(1) | 数值可能和数据库不完全一致,需要容忍短时间差异 |
| 用户关注数 | SCARD follow:userId | O(1) | 同上 |
| 我和某用户的共同关注 | SINTER follow:userId follow:targetId | O(n+m) | 大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流的常用数据模型,是给每个用户的收件箱创建一个ZSet,member存笔记ID,score存时间戳。博主发布笔记时,遍历他的粉丝列表,把笔记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序列化方式改成StringRedisSerializer,value如果是对象再考虑JSON序列化,这样你在RedisDesktopManager一类的工具里看到的key就是干干净净的follow:1001,排查问题会顺畅很多。
我个人在调试时踩过的另一个坑是:本地启动服务后,用前端页面测试关注接口,发现第二次点“已关注”按钮取消关注时,后端收到的isFollow一直是false,后来排查发现是前端按钮的状态没根据接口返回值去刷新,也就是说,前端拿到的“已关注”状态是页面加载时的旧状态。这个问题不在后端,但如果你是前后端一起调试,要留意到“状态刷新”这一步得依赖查询接口实时拉取,不能把状态常驻在前端变量里。
这套模块整体做下来,我最深的感触是它的教学节奏很聪明,关注功能只是表面,真正让你练的是“双向集合维护”和“关系存储选型”这两种能力。把这两个内化了,下一个遇到点赞、收藏、订阅这类功能时,你会条件反射地想:这个是不是也可以用类似的Set结构来表达?能想到这里,这个实战就算没白做。