关注关系几乎是所有内容型产品的标配功能:微博的关注与粉丝、B站的UP主粉丝团、直播间的关注主播、电商店铺的收藏关注,底层都是同一套数据模型。这类数据的特点很鲜明:写多读也多、查询模式固定、数据量随用户规模呈爆炸式增长。一个千万粉丝的头部博主,粉丝列表如果直接落在MySQL里分页查询,深分页的性能会非常糟糕。所以绝大多数中大型系统都会把关注关系的核心读写路径放到Redis里,本文就来详细拆解这套方案的设计与实现。

一、数据结构设计:双Set方案
关注关系本质上是两张表:正表(我关注了谁)和反表(谁关注了我)。在Redis里对应的也是两个集合。约定两个Key的设计规则:follow:{userId}存放用户关注的人,fans:{userId}存放关注该用户的粉丝。当用户A关注用户B时,需要同时执行两个SADD操作;取消关注时对应执行两个SREM。这种双向写入保证了任意方向的查询都能O(1)复杂度命中。
选用Set而不是Hash或List是有原因的。Set天然去重,重复关注不会产生脏数据;SISMEMBER判断关系的时间复杂度是O(1),非常适合“我是否关注了TA”这类高频查询;SINTER可以直接求两个用户的共同关注,这是List和Hash都做不到的。如果使用ZSet替代Set,还能额外获得按关注时间排序的能力,后面会展开讲。
// 用户A关注用户B
func Follow(rdb *redis.Client, uidA, uidB int64) error {
pipe := rdb.Pipeline()
pipe.SAdd(ctx, fmt.Sprintf("follow:%d", uidA), uidB)
pipe.SAdd(ctx, fmt.Sprintf("fans:%d", uidB), uidA)
_, err := pipe.Exec(ctx)
return err
}
// 判断A是否关注了B
func IsFollowing(rdb *redis.Client, uidA, uidB int64) (bool, error) {
return rdb.SIsMember(ctx, fmt.Sprintf("follow:%d", uidA), uidB).Result()
}
有一个细节需要注意:判断“是否关注”时要查follow集合,判断“是否是我的粉丝”时要查fans集合,不要混用。有些初学者习惯用SISMEMBER fans:{A} B来回答“A是否关注了B”,这在单向关注的产品里语义就反了。另外双向写入必须放在同一个Pipeline或事务里,避免出现A的关注列表有B、但B的粉丝列表没有A这种不一致状态。
二、核心接口的实现
关注系统对外通常暴露四类接口:关注、取关、粉丝列表分页、共同关注。前两个上面已经给出示例,这里重点说粉丝列表分页。Set本身是无序的,SMEMBERS虽然能拿全量但无法分页,Redis 6.2之后提供了SSCAN的游标遍历,可以配合COUNT参数做分页,但SSCAN返回的顺序不保证稳定,翻页时可能出现重复或遗漏。如果业务对顺序没有要求(比如粉丝广场随机展示),SSCAN足够用;如果要求按关注时间倒序展示“最新粉丝”,就应该换用ZSet。
用ZSet实现时,member存粉丝ID,score存关注时间戳,ZRANGEBYSCORE或者ZRANGE配合REV参数就能实现稳定的时间序分页,用上一页最后一条的score做游标即可,天然避免深分页问题。共同关注用SINTER可以直接算出我和某个用户共同关注的人,如果要扩展到“你关注的人里也关注了TA”,则需要组合SINTERSTORE先把中间结果落到临时Key,再做后续判断,记得用完之后DEL掉临时Key,并给它设置过期时间兜底。
// ZSet方案:按时间倒序取粉丝列表,lastTs为上一页末尾的时间戳
func FansByTime(rdb *redis.Client, uid int64, lastTs float64, size int64) ([]string, error) {
key := fmt.Sprintf("fans:z:%d", uid)
return rdb.ZRevRangeByScore(ctx, key, &redis.ZRangeByScore{
Max: fmt.Sprintf("(%f", lastTs), // 严格小于lastTs
Offset: 0,
Count: size,
}).Result()
}
// 共同关注
func CommonFollow(rdb *redis.Client, uidA, uidB int64) ([]string, error) {
return rdb.SInter(ctx,
fmt.Sprintf("follow:%d", uidA),
fmt.Sprintf("follow:%d", uidB)).Result()
}
分页游标用score而不用offset,这一点很重要。粉丝量达到百万级时,offset方式的ZRANGE会随着翻页深度线性变慢,而score游标始终只扫size个元素,性能恒定。前端需要把每页最后一条的时间戳带回作为下一页参数,接口设计上多一个cursor字段即可平滑兼容。
三、缓存与数据库的一致性处理
Redis再快也是内存存储,宕机或主从切换时可能丢数据,所以关注关系通常仍然要落库作为最终存储,Redis承担加速层角色。推荐的写入顺序是:先更新MySQL,再更新Redis。如果反过来先写缓存再写库,一旦写库失败,缓存里就存在一条持久化的假关注,用户看到关注成功但数据库查无此记录,排查起来非常麻烦。先写库再写缓存,最坏情况只是缓存少了一条数据,可以通过补偿任务修复。
缓存更新失败的问题可以用几个手段兜底:给关注相关的Key设置合理过期时间,让不一致窗口有上限;后台定时任务对账,抽样比对数据库与Set的基数差异,发现差异后以数据库为准修复缓存;写操作使用重试队列,缓存操作失败时投递到消息队列异步重试。还有一种更彻底的方案是只写数据库,缓存全部通过订阅binlog(比如用Canal)来异步刷新,业务代码完全不用关心缓存一致性,代价是架构复杂度上升,适合团队已有binlog订阅基础设施的情况。
大V粉丝量的问题也值得提前考虑。百万粉丝的大Key在做迁移、过期删除时可能阻塞Redis,建议粉丝集合按粉丝ID取模拆分成固定数量的子Key,例如fans:{uid}:0到fans:{uid}:15共16片,读取时先根据粉丝ID定位分片再查询。拆分会增加判断关系的成本,需要权衡使用,普通用户不拆、超过阈值才拆的动态策略是比较常见的折中做法。
四、方案对比与选型建议
纯数据库方案的优点是数据强一致、支持任意维度的复杂查询和统计,缺点是高并发下扛不住,深分页慢,热点用户的粉丝查询容易拖垮库。纯Redis方案性能极好,接口简洁,但存在数据丢失风险且不支持模糊统计。生产环境的主流选择是数据库为真源、Redis为读加速的混合架构,关注和取关这类写操作双写,关系判断和列表读取全部走缓存,命中率高的场景下数据库压力可以忽略不计。
还有一个容易被忽略的边界:互相关注(好友关系)。实现上只需要在关注成功后判断SISMEMBER follow:{B} A是否成立,成立则双方互关,可以额外维护一个friend:{uid}集合,或者直接在业务层用两次判断实现。取关时同样要同步清理互关状态。把互关判断做在写路径上而不是读路径上,可以避免每次查询好友列表时的双倍请求量。整体来看,这套双集合加ZSet的方案在绝大多数社交场景里都够用,真正的难点不在数据结构,而在于缓存一致性、大Key治理这些工程细节上,把这两块设计好,关注模块就能稳定支撑高并发了。