揭秘社交粉丝关系链实现
本文基于工作中真实项目的个人总结,虽然上线后经历上亿用户和千万DAU验证,但文中有错误和不足,欢迎大家在留言中指出和补充!
1 概述
在我们日常使用的网络社交应用中,必然离不开关注功能,大致为关注某人成为其粉丝,若又被其关注,则互为好友,同时还可以支持特别关注、拉黑等附加功能。
这样一个关系链系统中,如何高效存储和查询海量用户关系是首要解决的问题。
1.1 关系分类
在设计该系统之前,我们需要先要理清楚对应的分类和角色。其中有两个重要角色:follower(关注者)以及 followee(被关注着)。
若 follower 可以直接关注 followee 成为其粉丝,则可以称这类关系为弱好友,如微博;
又若 follower 通过 followee 同意后直接互为好友,可以称这种关系为强好友,如微信。
本文将以社交媒体中更常见的弱好友链系统展开。
1.2 基本设计
正如前文所述,关系系统核心解决的是高效存储和查询用户关系问题。对一个用户而言,我们需要提供三种业务数据的查询:用户关注、粉丝、好友列表。梳理出系统业务大致流程如下:
所以如何高效存储是我们首先要解决的问题。
2 高效存储
2.1 单表方案
虽然在 follower 关注 followee 后,将其关注关系存储到一张 follow_relation 表也可以实现关注、粉丝以及好友的查询,如
CREATE TABLE `follow_rel` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`target_id` int NOT NULL,
`create_time` bigint NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_uid` (`user_id`),
KEY `idx_tid` (`target_id`)
) ENGINE=InnoDB;
1)查询 uid=1 的关注 : select * from follow_rel where user_id = 1
2)查询 uid=1 的粉丝 : select * from follow_rel where target_id = 1
3)查询 uid=1 的好友 : select * from follow_rel where user_id = 1 union select * from follow_rel where user_id = 2
简单的看上去似乎还挺合理,但感觉设计上多少有些问题。
确实,如果我们在用户关系数量不多的情况下,所有关系都维护到一张数据表中,此方案确实没有任何问题,但面对如笔者所在的公司产品上亿 DAU时,可定会涉及到分库分表。如果业务以用户 ID 进行拆分的话,对于上述粉丝查询的方法就无法实现了,因为 uid=1 的粉丝会因为其粉丝各自的 ID 路由到不同的数据表中。
2.2 双表方案
为了解决上面分库分表的问题,我们可以接收到关注动作并记录其关注关系 follow_gz
表中的同时再记录一张针对 followee 的粉丝表 follow_fs
。
1)关注表:
CREATE TABLE `follow_gz` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`target_id` int NOT NULL,
`create_time` bigint NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_uid` (`user_id`)
) ENGINE=InnoDB;
2)粉丝表:
CREATE TABLE `follow_fs` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` int NOT NULL,
`target_id` int NOT NULL,
`create_time` bigint NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_uid` (`user_id`)
) ENGINE=InnoDB;
2.3 方案选择
双表会有数据冗余,但可满足不同维度的查询需求,在数据量大时,还能随意进行水平扩容。
也会带来写放大(单次写操作变成两次)和数据一致性问题(事务造成的性能和成功率下降)等诸多问题,我们可采用异步写粉丝表来实现最终一致性解决。
综上双表是目前来说很好的实践方案,当然技术选型需要根据业务实际需求出发,如果评估产品本身并无达到分库分表两级。
3 高效查询
上文我们介绍了该如何高效存储粉丝关系数据,面对海量数据时,我们虽然进行看分库分表,但还是抵挡不住超高的读 QPS,此时不得不引入缓存方案。
3.1 缓存方案
使用 Redis 作为缓存,不仅性能足够优秀,而且支持多种高级数据结构,如它的 zset 结构便能很好的实现分页查询粉丝列表并按时间排序的需求。具体实现如下:
1)关注
zadd("user_id:follow", time(), target_id)
zadd("target_id:fans", time(), user_id)
2)取消关注
zrem("user_id:follow", target_id)
zrem("target_id:fans", user_id)
3)关系列表
zrange("user_id:follow", offset , offset + limit)
zrange("user_id:fans", offset , offset + limit)
对应好友列表,都已经使用缓存了,如果再从缓存中分别取出某个 uid 的关注和粉丝全部列表进行计算似乎不太合适,故可以 follower 关注 followee 时,检查 followee 是否关注 follower,如有相互关注,则再缓存一条好友集合:
zadd("user_id:friend", time(), target_id)
zadd("target_id:friend", time(), user_id)
3.2 缓存问题
在使用 Redis 作为缓存时,常常无法避免几个常见问题,其中三大问题就不再赘述。
3.2.1 热点问题
虽然 Redis 已经足够快到让我们惊叹,但面对各路大V 或社会热点事件,激增的流量可能也会让系统还未完成自动扩容便宕掉。针对此类数据可以做访问统计,当达到某个阈值后,将数据放到一个与 zookeeper 协调的本地缓存 LocalCache 中,以便支撑更大的流量。
3.2.2 大Key问题
一般产品上都会对关注数有一个固定限制,比如一个用户最多可以关注 2000 个其他用户,这样的限制可以很轻松的将用户关注列表均匀的 Hash 到缓存集群中。但用户的粉丝列表却往往没有这个限制,比如明星的粉丝量很容易就来到千万甚至上亿级别,那又该如何处理呢?
- 新增大 Key 的独立集群并打散处理
- 依旧只缓存固定数量的部分数据,超出后走 DB 补偿
3.2.3 最终一致性
一般情况下,我们通常就在业务主流程先删后写,先写后删进行了缓存数据的同步,但在高并发、大流量的场景,我们也可以采用异步方案,如消息中间件、监听 binlog 日志等方式更新缓存。
4 最后
写到这里,一个用户粉丝关系链系统的实现也就大致讲述完了,当然文章还有很多细节需要继续深挖和思考(PS 先给自己画张饼)。如果大家觉得对你有帮助的话还希望大家动动手指给个免费的一键三连(▽),你的支持是我前进最大的动力。