
你点开一个人的主页看到“已关注”三个字再点一下变成“关注”下拉还能刷出一排“共同关注”。这个功能简单到用户根本不会多想但作为后端工程师它比想象中更考验数据结构选型。我最近在一个内部社交项目里复刻了这套逻辑技术栈选型就是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把关注列表设计成一个Hashfield是用户IDvalue是关注时间。这个方案比List好能存时间也能判断是否存在。但Hash在求共同关注的时候就尴尬了Redis没有对Hash做交集运算的原生命令你只能把数据拿出来在业务层算性能和代码复杂度都不理想。1.2 Set对比List、Hash为什么只有Set合适我把三种结构的特性简单做一个对比方便你直观感受差异数据结构元素唯一判断存在集合运算存储关注时间List否需手动幂等O(N)不支持不好存Hash是field唯一O(1)不支持原生可存valueSet是天然唯一O(1)SISMEMBERSINTER、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、2000320001关注了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 memberscore在前member在后和SADD的SADD key member不一样。写错顺序会得到一个错误的数据结构。计算共同关注并保存ZINTERSTORE user:10086:common:20001 2 user:10086:follow user:20001:followZINTERSTORE会生成一个新的ZSet这个新ZSet的score是两个集合score之和。也就是说如果A在时间T1关注了CB在时间T2关注了C那么新集合里C的score是T1T2。这里有个反直觉的点共同关心的“共同关注时间”并没有业务含义。但至少新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的一致性先写谁如果你的项目已经选了“RedisMySQL”双写那必然要面对一个问题先写哪个这个问题没有标准答案但只要选了就要接受对应的牺牲。经验做法先写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集群分片规划都要以这个数据为基础而不是等告警了再补救。我自己跑下来的体会是RedisSet这套方案不需要过度设计它足够简单也能应对绝大多数场景。真正让它出问题的往往不是Redis本身而是没有在结构选型阶段想清楚数据增长的上限。只要把key规划好、把热点key的降级方案做好、把持久化和MySQL之间的关系理顺这个方案在千万级用户体量内都能跑得很稳。