你点开一个人的主页,看到“已关注”三个字,再点一下变成“关注”,下拉还能刷出一排“共同关注”。这个功能简单到用户根本不会多想,但作为后端工程师,它比想象中更考验数据结构选型。我最近在一个内部社交项目里复刻了这套逻辑,技术栈选型就是Redis,把关注、取关、共同关注这三件事彻底撸了一遍。这篇不聊虚的,直接讲我怎么设计key、怎么选结构、怎么处理并发和大V热点,以及那些只在生产环境才会暴露的坑。
1. 为什么是Set:从微博关注模型反推Redis数据结构选型
1.1 关注关系本质上就是“集合关系”
先说一个最基础的问题:一个用户的关注列表,到底要用Redis里的哪种结构来存?
你如果直接从业务语义出发,会发现关注关系就是一个典型的集合模型。用户A关注了用户B,那么B的ID就属于“A关注的人”这个集合;反过来,A的ID就属于“B的粉丝”这个集合。集合的优势在于:元素天然唯一,不会出现同一个人被关注两次的脏数据;判断“是否关注过”是一个O(1)的SISMEMBER操作;求共同关注更是一个标准的集合交集运算。
所以最自然的选型就是Set。每一个用户维护两个Set:一个是他的关注列表,一个是他的粉丝列表。关注操作就是往两个集合里各加一个元素,取关就是各删一个元素,共同关注就是取两个集合的交集。整个过程没有复杂的状态机,没有外键约束,纯粹就是集合运算。
我见过有人用List来实现关注列表,理由是“关注要按时间线展示”。这个想法可以理解,但List会带来两个问题。第一,List允许重复元素,你每次插入前都得先查一遍有没有重复,业务上是重复关注,逻辑上还得专门做幂等。第二,List判断“用户A是否关注了B”是O(N)的遍历,而SISMEMBER是O(1)。当关注量到几千上万的时候,这个差距会非常明显。
还有人用Hash,把关注列表设计成一个Hash,field是用户ID,value是关注时间。这个方案比List好,能存时间,也能判断是否存在。但Hash在求共同关注的时候就尴尬了,Redis没有对Hash做交集运算的原生命令,你只能把数据拿出来在业务层算,性能和代码复杂度都不理想。
1.2 Set对比List、Hash:为什么只有Set合适
我把三种结构的特性简单做一个对比,方便你直观感受差异:
| 数据结构 | 元素唯一 | 判断存在 | 集合运算 | 存储关注时间 |
|---|---|---|---|---|
| List | 否,需手动幂等 | O(N) | 不支持 | 不好存 |
| Hash | 是,field唯一 | O(1) | 不支持原生 | 可存value |
| Set | 是,天然唯一 | O(1),SISMEMBER | SINTER、SUNION、SDIFF | 不便存,需配合ZSet |
从表格可以看出来,Set在“元素唯一”“判断存在”“集合运算”这三项上全部占优,唯一缺的是“存储关注时间”。但这个缺口也不是没法补,后面讲到共同关注的时候,我会介绍怎么用ZSet来替代Set,既保留集合运算能力,又能按时间排序。
1.3 key命名与双向关系设计
确定了用Set,接下来就是key的命名。这个看似简单,但命名规则直接决定了后续所有命令的写法、运维的可读性、以及是否需要Hash Tag来支持集群模式。
我的推荐命名规则是这样:
- 关注列表:
user:{uid}:follow - 粉丝列表:
user:{uid}:fans
比如用户ID为10086的用户,他的关注列表就是user:10086:follow,粉丝列表就是user:10086:fans。
为什么要用这种“业务对象:ID:业务属性”的命名格式?因为它在Redis里可以按前缀做扫描和统计,比如你要给一批用户做数据迁移,直接SCAN user:*:follow就能把所有的关注key捞出来。而且Redis Desktop Manager这类可视化工具里,按前缀过滤也特别方便。
双向列表是必须的。你维护关注列表而不维护粉丝列表,那么“我的粉丝列表”“谁关注了我”“互相关注”这些功能就全废了。一个完整的关注动作,本质上是两次写入:
SADD user:10086:follow 20001 SADD user:20001:fans 10086第一次把被关注者20001加入用户10086的关注集合,第二次把关注者10086加入用户20001的粉丝集合。两次写入是同一个业务事件的两个侧面,缺一不可。
2. 关注与取关:命令背后的双写一致性与边界处理
2.1 基础命令:SADD与SREM
关注操作的核心命令就两个:关注用SADD,取关用SREM。
关注时,执行两次写入:
SADD user:10086:follow 20001 SADD user:20001:fans 10086取关时,执行两次删除:
SREM user:10086:follow 20001 SREM user:20001:fans 10086SADD和SREM都是O(1)操作,不管集合里有几万个元素,耗时都在微秒级。这也是Redis方案相比MySQL最直观的优势。用MySQL实现关注关系,你需要维护一张follower表,插入前可能还要查一遍是否已存在,加索引、防重复,查询共同关注时更是要写复杂的JOIN。而Redis这边就是你一行SINTER的事。
这里有一个细节:SADD是有返回值的,返回值是实际新增的元素数量。如果返回0,说明这个元素本来就在集合里,即“重复关注”。业务上你可以忽略,也可以打一条日志。我的建议是打一条WARN级别的日志,因为重复关注往往意味着前端按钮状态异常或者接口被重复提交,值得排查。
2.2 状态校验与并发下的重复操作
关注接口不是无脑执行SADD就行,还有几个业务校验:
第一,不能关注自己。这个在业务层判断就行,uid == targetUid直接返回错误。
第二,不能关注已注销或者被封禁的用户。这种校验在业务层做一个批量存在性判断,没必要把逻辑下沉到Redis命令层。
第三,关注接口的并发重复提交。用户疯狂点关注按钮,可能同一毫秒发来两个请求。Redis的单线程模型下,两个SADD操作是串行执行的,不会出现数据竞争,所以最终结果一定是一致的,集合里不会有重复元素。但问题是,两个请求可能都执行了双写,虽然结果一样,却白白浪费了两次网络往返和两次Redis命令执行。
更稳妥的做法是在业务层做幂等:先SISMEMBER user:10086:follow 20001判断是否已关注,如果已关注就直接返回当前状态,不再执行关注双写。注意,这里存在一个经典的时间差问题:两个请求同时进来,都执行了SISMEMBER,都发现“未关注”,然后都执行SADD。这个场景下,SADD本身保证了数据一致,只是多写了一次,问题不大。真正要防的是“关注”和“取关”两个请求并发到达:一个SADD一个SREM,后执行的覆盖先执行的,最终状态取决于请求到达顺序,和用户的意图可能相反。
这种并发冲突用Redis原生命令很难完美解决,因为“先判断后执行”不是原子的。最简单的方案是引入分布式锁,但锁太重了。更轻量的方案是把这个判断和写入放进一个Lua脚本里,让Redis帮你保证原子性。
2.3 用Lua脚本保证attention双写原子性
我最终在实践中采用了Lua脚本,把“判断状态+双写”合并成一个原子操作。关注脚本长这样:
-- KEYS[1]: 当前用户的关注列表key -- KEYS[2]: 目标用户的粉丝列表key -- ARGV[1]: 当前用户ID -- ARGV[2]: 目标用户ID local exists = redis.call('SISMEMBER', KEYS[1], ARGV[2]) if exists == 1 then return 0 end redis.call('SADD', KEYS[1], ARGV[2]) redis.call('SADD', KEYS[2], ARGV[1]) return 1取关脚本同理,只是把SADD换成SREM。
Lua脚本保证了两件事:一是两个列表的写入在同一个原子操作里完成,不会出现“关注列表写了,粉丝列表没写”的中间状态;二是“判断-写入”整个流程不会被其他客户端插队。
这里要特别提醒一下Lua脚本和Redis Cluster的兼容问题。在Cluster模式下,一个Lua脚本里操作的多个key必须落在同一个hash slot上,否则Redis会直接报错。所以key设计要特别小心,user:10086:follow和user:20001:fans这两个key,看起来没有任何相似之处,hash slot大概率不同。
解决办法有两个。
第一个是使用Hash Tag。把key设计成user:{10086}:follow,这样Redis做hash时会只对花括号里的内容计算slot。但这样设计对“关注列表”来说没问题,因为它天然按用户维度分片。而粉丝列表user:{20001}:fans也按用户维度分片,两个不同的用户ID计算出的slot是不同的,问题还是没解决。
第二个更实用:在Cluster模式下,关注双写不做成多key原子操作。原因在于,即使不原子,最终一致性也能接受——只要在业务侧有补偿机制。具体做法是:主表只写当前用户的关注列表,粉丝列表通过异步消息队列同步到其他节点。或者用Redis的普通事务MULTI/EXEC,虽然不能跨slot,但至少可以确保单个连接上的命令顺序执行。
如果你用的是单机Redis或标准主从架构,完全不需要管这个问题,Lua脚本直接跑就行。但如果上了Cluster,建议提前做好key规划的评估,不要在扩容之后再重构。
2.4 取关时容易被忽略的细节
取关命令看着简单,删除两个集合里的元素就完事,但有几个细节容易漏。
第一,SREM的返回值是实际删除的元素数量。如果返回0,说明用户本来就没有关注对方,业务上可以直接返回“取关失败”或者“已经是取关状态”。我建议把这个情况也记日志,因为这说明前端状态和实际数据不一致了。
第二,取关之后,共同关注缓存要不要清?如果你做了交集结果的缓存,取关操作会直接影响所有包含这两个用户的交集缓存。最简单的策略是在取关后删除涉及的两个用户的共同关注缓存key,不要试图去更新它,直接让缓存失效更省事。
第三,用户注销账号时,他的关注集合和粉丝集合怎么清理?这类操作会涉及大量key的扫描和删除,不建议在用户请求链路里做。离线任务批量清理就好,而且清理之前要想清楚:用户的粉丝集合里可能有几百万个key,每个key都要SREM一次,这个量级不是闹着玩的。
3. 共同关注:从SINTER到大数据量的聚合优化
3.1 最简单的共同关注:SINTER
共同关注是微博关系链里最体现Redis价值的功能。如果只用MySQL,你需要写一个三表关联的查询,把两个用户的关注列表做INNER JOIN,数据量大了之后SQL性能很容易崩。而Redis里一个命令就完事:
SINTER user:10086:follow user:20001:follow返回结果就是两个用户共同关注的人的ID集合。比如10086关注了20001、20002、20003,20001关注了20002、20003、20004,那么SINTER的结果就是20002和20003。
这个命令的时间复杂度是O(N*M),N是第一个集合的大小,M是后续集合的大小。具体来说,Redis会先选最小的集合作为基准,然后遍历这个基准集合,再去检查每个元素是否存在于其他集合中。它的巧妙之处在于:不是把两个集合的元素两两对比,而是用小集合去探测大集合,这样可以显著减少比较次数。
举个例子,用户A关注了100个人,用户B关注了10000个人。SINTER会以A的100人集合为基准,逐个SISMEMBER这100人是否在B的集合里。总共100次O(1)查询,非常快。但如果两个都是万级甚至百万级的大集合,SINTER的性能会直线下降。百万级的集合做交集,Redis在处理时可能阻塞几百毫秒,这在线上是不可接受的。
3.2 大V场景下SINTER的卡顿隐患
我把这个测试数据单独拿出来说一下。假设用户A关注了10万人,用户B关注了10万人,这两个人都是重度的社交控。他们之间求共同关注,SINTER要先遍历较小的集合,再对另一个集合做10万次SISMEMBER。即便每次SISMEMBER是微秒级,10万次也要几十到上百毫秒。而且Redis是单线程的,这个过程中其他所有命令都得排队,整个Redis实例的延迟都会跟着波动。
这个坑在开发和测试环境完全暴露不出来,因为测试数据量太小了。一旦线上出现“用户A和用户B都关注了某个百万粉大V”这种场景,SINTER的耗时就会瞬间拉满。
我的应对思路是把“全部共同关注”降级成“Top N共同关注”。产品上用户通常只看前面几十个共同关注,没有人会去翻几千页的共同关注列表。所以不需要一次性算出全集,只需要返回前50个或者前100个就行。
如果关注列表本身没有排序需求,用Set加随机抽取,把两个集合导出后取交集,再截断前N个。注意这种方案已经回到了业务层计算,但我通常不推荐,因为导出一个大集合需要SMEMBERS,在大集合上同样有阻塞风险,应该用SSCAN。
更好的做法,是引入ZSet,让集合本身自带排序字段,这样既能做集合运算,又能直接按分数取Top N。
3.3 用ZSet给共同关注加上时间排序
ZSet和Set的区别在于,ZSet的每个元素关联一个score。把score设成关注时间的时间戳,那么ZSet天然就是按关注时间排序的关注列表。这其实是把两个需求合并到了一起:既要集合运算,又要时间排序。
初始化命令:
ZADD user:10086:follow 1700000000 20001 ZADD user:10086:follow 1700000100 20002注意这里的语法是ZADD key score member,score在前,member在后,和SADD的SADD key member不一样。写错顺序会得到一个错误的数据结构。
计算共同关注并保存:
ZINTERSTORE user:10086:common:20001 2 user:10086:follow user:20001:followZINTERSTORE会生成一个新的ZSet,这个新ZSet的score是两个集合score之和。也就是说,如果A在时间T1关注了C,B在时间T2关注了C,那么新集合里C的score是T1+T2。这里有个反直觉的点:共同关心的“共同关注时间”并没有业务含义。但至少新ZSet是按分数排序的,虽然这个分数是两个时间戳之和,不是真正的关注时间,可排序趋势是对的:两个用户关注同一个人的时间都越晚,分数越大,排序越靠后。对于产品端“按相关度排序”的需求,这个分数完全可用。
如果你想直接拿到Top N,不用ZINTERSTORE一步到位,可以两个集合都取出部分数据做交集,但那需要两次ZRANGEBYSCORE加一次业务层交集,效率反而低。ZINTERSTORE一次性算完再ZREVRANGE取前50个,在数据量不是特别离谱的情况下是够用的。
ZSet方案比较适合关注量在几万到几十万量级的用户,再往上就不要实时算了,走缓存。
3.4 交集缓存与TTL策略
不管用SINTER还是ZINTERSTORE,计算共同关注都是有一定开销的操作,不能每次请求都实时算。尤其对于流量大的接口,一定要加缓存。
我的缓存策略是:以两个用户ID为维度做一个共同关注结果缓存。key命名类似user:{uid1}:common:{uid2},value是共同关注的用户ID列表,TTL设置为5到10分钟。这样用户翻“共同关注”这个Tab时可以快速命中缓存,只有TTL过期或者关注关系发生变化时才重新计算。
关键问题是:什么时候主动失效缓存?
两个用户的关注关系只要有一方变化,共同关注结果就可能变化。所以在关注和取关的Lua脚本里,除了双写两个集合,还应该顺带删除或者延迟删除相关的共同关注缓存。但注意,关注脚本里能拿到的key是user:10086:follow和user:20001:fans,你可能不知道这两个用户和哪些其他用户有共同关注缓存。最粗暴但可靠的做法是:删除和当前用户相关的所有共同关注缓存key。如果用户关注了1000个人,理论上他有几百上千个“共同关注”缓存key,全删一遍成本很高。
更可取的方案是给缓存key加一个固定前缀,只删除最近可能被访问的Top N个缓存。具体做法是:在关注/取关事件发生后,把异步清理任务丢到消息队列里,由消费者去扫描并清理该用户相关的缓存。这个方案实现成本稍高,但不会阻塞主流程,也能保证最终一致。
如果不想上消息队列,还有一个偷懒但好用的方案:不要主动失效,只依赖TTL。把TTL设短一点,比如3分钟。用户操作产生的影响最多3分钟后可见,对绝大多数产品来说,这个延迟完全可以接受。这种方式牺牲了一点即时性,但换来了极致的简单。
4. 从Demo到生产:热点key、持久化与内存成本的实战权衡
4.1 热点key怎么抗
微博场景下,最典型的热点key就是大V的粉丝集合。一个千万级粉丝的明星,他的user:9527:fans这个key会被海量的请求同时访问。每次有人浏览他的主页,后台可能都要执行一次SISMEMBER user:9527:fans {当前用户ID},判断当前用户是否关注了他。
这种极端热点key,会让Redis单分片CPU飙升,甚至拖垮整个实例。Redis明明有几十个分片,其他key都很空闲,但这个明星的粉丝列表把所有压力都压在一个分片上,这就是典型的hot key问题。
应对方案有几个层级。第一层是加多级缓存,热点key的判断结果是一对一的关系——“我是否关注了他”,这个结果在较短时间内不会变化,可以放在进程内缓存或者Redis的value缓存里,加一个30秒的过期时间。第二层是把一个大key拆成多个小key,比如user:9527:fans:0到user:9527:fans:9十个分片,按用户ID取模定位分片,SISMEMBER时只需要查一个分片。但这个方案对“判断是否关注”有效,对“求交集”就会复杂很多,因为求交集需要把所有分片都拉出来合并。
我的建议是:先做本地缓存,命中率上去之后,热点key的QPS会下降几个数量级。比如一个热点key每秒被请求1万次,加一个30秒的本地缓存,同一个用户ID的请求在30秒内只会穿透到Redis一次,实际打到Redis的QPS可能降到几百。这个优化性价比极高,代码改动也就是一个Caffeine或者Guava Cache的事。
4.2 缓存还是存储:Redis持久化选型
Redis的Set方案如果只用来做“缓存”,那数据丢了可以从数据库恢复,问题不大。但很多人一开始用得很爽,直接把Redis当成了唯一存储,MySQL根本没建表。这时候如果Redis宕机,或者重启,内存数据全部丢失,用户的关注关系就全没了。这是事故级别的故障。
我的建议是明确一个原则:Redis作为主要查询和计算层,MySQL/数据库作为最终一致性的存储层。关注和取关请求先写Redis,双写成功之后立即返回前端成功;同时把该事件投递到一个消息队列,异步落库。这样Redis性能优势保留,数据库只承担异步写的压力,不会成为性能瓶颈。
Redis自身的持久化策略也很关键。如果坚持用Redis做主要存储,那么至少开启AOF持久化,并配置成appendfsync everysec。这个配置的性能影响很小,每秒刷盘一次,最多丢一秒的写操作。用RDB做冷备,每天定时执行BGSAVE,保留最近7天的备份。如果Redis实例所在的机器彻底挂了,可以快速从RDB加AOF恢复,数据丢失控制在秒级。
还有一种更稳妥的做法:让Redis的数据可以重建。如果只把Redis当作纯缓存,那么Redis重启后,只需要等消息队列里的历史事件重放一遍,就能重建全量缓存。这个方案听起来复杂,实际执行起来就是把“异步落库”的事件同时再发一份给“缓存重建消费者”,在Redis启动后按顺序重放。我自己的项目里就是用这个方案,才敢放心地把几千万条关注关系全放在Redis里。
4.3 与MySQL的一致性:先写谁
如果你的项目已经选了“Redis+MySQL”双写,那必然要面对一个问题:先写哪个?这个问题没有标准答案,但只要选了,就要接受对应的牺牲。
经验做法:先写Redis,再异步落库。原因很清楚——把最新的状态先暴露给查询链路,用户点关注后立刻能看到“已关注”的状态,体验最好。异步落库就算延迟几秒甚至几十秒,只要最终写进去,数据就一致了。如果先写库再写Redis,用户点完关注后,如果Redis的更新慢了一步,刷新页面可能又变回未关注状态,体验就很差。
但异步落库有一个风险:如果在落库之前Redis发生故障导致数据丢失,那么部分关注关系就永久丢了。为了规避这个风险,我在业务上允许“丢失最多最近几秒钟操作”,这符合大多数社交产品对关注关系这种非绝对强一致场景的容忍度。如果你的业务要求严格不丢,那就得在Redis写入后立即同步落库,牺牲一些用户体验,同时在DB写入失败时回滚Redis操作,保证一致性。
在实际操作中我建议不要做同步双写,因为同步双写意味着请求的耗时取决于最慢的那次写,通常是MySQL。把Redis的实时性优势和MySQL的可靠性优势结合起来,用消息队列做解耦,是这类型社交功能里最成熟的落地模型。
4.4 内存与key规模规划
Redis是内存数据库,内存就是成本。设计这个方案时必须对内存量级有个预期,否则上线后内存告警会让人焦头烂额。
先估算单个Set的内存。如果用户ID都是正整数,Redis的Set底层在没有达到一定规模之前会用intset(整数集合)编码,每个整数占8字节,加上集合头部开销,一个10万成员的集合大约占1MB左右。但如果集合用hashtable编码,每个元素的overhead会高很多,一个10万成员的集合可能要占3到5MB。
这里有个容易被忽视的点:intset编码下,SREM删除元素后,如果整数数量降到阈值以下,Redis会自动做编码转换回intset。但频繁写删除会导致编码反复切换,带来不必要的CPU开销。如果你的业务会高频取关,可以在初始化时直接用一个带随机分布的占位元素触发hashtable编码,之后这个key就稳定在hashtable模式,避免反复编码转换。不过,这个优化只对超大集合有必要,普通用户的集合一直用intset反而更省内存。
我算过一笔账:假设平台有1万个“重度用户”,每人关注1000人,那就是1000万个关注关系。每个关系在Redis里至少要存一个成员的ID,同时还要在粉丝列表里再存一份,所以总成员数是2000万。用intset编码,200个字节一条关系,总共大约4GB内存。如果用户量翻十倍,就是40GB,两个副本加主从同步,内存开销要往100GB以上规划。
所以在上线前一定要回答三个问题:有多少用户、每个用户平均关注多少人、需要保留几份副本。内存预估和Redis集群分片规划都要以这个数据为基础,而不是等告警了再补救。
我自己跑下来的体会是,Redis+Set这套方案不需要过度设计,它足够简单,也能应对绝大多数场景。真正让它出问题的,往往不是Redis本身,而是没有在结构选型阶段想清楚数据增长的上限。只要把key规划好、把热点key的降级方案做好、把持久化和MySQL之间的关系理顺,这个方案在千万级用户体量内都能跑得很稳。