如何打造一个轻量级的社交系统

如何打造一个轻量级的社交系统

简介

随着国外Facebook、Twitter、国内的微博等社交网络网站的崛起,很多公司也推出了类似的社交服务产品,相比与微博这种大型用户社交产品而言,很多公司都推出的类微博Feed流的社交产品,但由于一些公司的用户基数、用户活跃度等原因远没有微博庞大,因此这些产品在数据存储、Feed展示上的技术实现远没有微博的复杂,面对用户量级在1000万左右且旧社交系统中单表已有存量数据为2000多万的情况下,我们如何去打造一个轻量级的社交系统呢?


背景

因技术架构、产品内容升级,原有的社交系统已无法满足新业务,因此重构了一套新的社交系统,并将旧系统的数据迁移到新系统中并完成产品内容迭代。


Feed流相关概念

  • Feed

Feed流中的每一条状态或者消息都是Feed,比如朋友圈中的一个状态就是一个Feed,微博中的一条微博就是一个Feed。

  • Feed流

持续更新并呈现给用户内容的信息流。每个人的朋友圈,微博关注页等等都是一个Feed流。

  • Timeline

Timeline其实是一种Feed流的类型,微博,朋友圈都是Timeline类型的Feed流,但是由于Timeline类型出现最早,使用最广泛,最为人熟知,有时候也用Timeline来表示Feed流。

  • 关注页Timeline(收件箱)

展示已关注用户Feed消息的页面,比如朋友圈,微博的首页等

  • 个人页Timeline(发件箱)

展示自己发送过的Feed消息的页面,比如微信中的相册,微博的个人页等

  • 感兴趣的人

二度好友,我关注的人的好友,我好友的好友,我关注人的关注,我好友的关注

Feed流实现的几种方案

  • 拉模式

方式:

发布Feed时向个人页Timeline写入feedId,读取Feed流时先获取所有的关注列表,在获取每一个关注用户的个人页Timeline,排序后展示。

优点:

写入简单

缺点:

读取复杂

适用场景:

少量用户

  • 推模式

方式:

发布Feed时向所有关注者关注Timeline广播写入feedId,并写入个人页Timeline,读取关注页Feed流时从关注页Timeline读取,读取个人页Feed流时从个人页Timeline读取。

优点:

读取简单

缺点:

读取膨胀

适用场景:

关注数相对平均

  • 推拉结合

方式一 (大V模式):

发布Feed时先写入个人页Timeline,然后判断自己是否是大V用户,如果不是就采用推模式,如果是就结束

读取Feed时先从自己的关注页Timeline读取,然后读取自己关注的大V用户的个人页Timeline,最后合并按照时间排序展示

方式二(活跃模式)

在线推 :向所有在线关注者关注Timeline广播写入feedId,并写入个人页Timeline

离线拉 :在APP启动时启用后台线程根据个人页Timeline最后一个Feed时间去所有关注者拉取新Feed并写入到关注页Timeline

优点:

可实现大V用户场景,活跃用户能最快看到最新信息

缺点:

实现复杂

适用场景:

有大V用户场景


确定Feed流实现方案

旧社交系统中粉丝数量Top 100的用户

序号

被关注人数

131391228646320749420630519292619131…………9897299966100966


结合业务实际的数据量级,我们采用成本相对较低的推模式来实现Feed流,标题中的所谓“轻量”正是指的我们这里没有大V用户,不用去考虑非常复杂的推拉结合的实现模式。


Feed流推模式

发布Feed时向所有关注者关注Timeline广播写入feedId,并写入个人页Timeline读取关注页Feed流时从关注页Timeline读取,读取个人页Feed流时从个人页Timeline读取


推模式下的核心流程

关注用户

如何打造一个轻量级的社交系统

取消关注用户

如何打造一个轻量级的社交系统

发布Feed

如何打造一个轻量级的社交系统

删除Feed

如何打造一个轻量级的社交系统

数据库结构


数据存储采用Mysql


Table:fans_list
Desc:粉丝列表,存储所有的粉丝列表

CREATE TABLE `fans_list` (   `id` bigint(20) NOT NULL,   `member_id` varchar(20) NOT NULL COMMENT '用户ID',   `fans_member_id` varchar(20) NOT NULL COMMENT '粉丝用户ID',   `follower_at` bigint(19) DEFAULT NULL COMMENT '关注时间',    PRIMARY KEY (`id`),   KEY `idx_member_id` (`member_id`),   KEY `idx_fans_member_id` (`fans_member_id`)   ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

Table:follower_list

Desc:关注列表,存储所有的关注列表

CREATE TABLE `follower_list` (   `id` bigint(20) NOT NULL,   `member_id` varchar(20) NOT NULL COMMENT '用户ID',   `follower_member_id` varchar(20) NOT NULL COMMENT '关注用户ID',   `follower_at` bigint(19) DEFAULT NULL COMMENT '关注时间',   PRIMARY KEY (`id`),   KEY `idx_member_id` (`member_id`),   KEY `idx_follower_member_id` (`follower_member_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

Table:follower_timeline

Desc:关注页timeline (收件箱) ,存储所有关注用户发送的FeedId

CREATE TABLE `follower_timeline` (   `id` bigint(20) NOT NULL,   `member_id` varchar(20) NOT NULL COMMENT '用户ID',   `follower_member_id` varchar(20) NOT NULL COMMENT '被关注用户ID'   `feed_id` varchar(32) NOT NULL COMMENT '发布的内容id',   `publish_at` bigint(19) NOT NULL COMMENT '发布时间'   PRIMARY KEY (`id`),   KEY `idx_member_id` (`member_id`),   KEY `idx_follower_member_id` (`follower_member_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4

Table:personal_timeline

Desc:个人页timeline (发件箱) ,存储自己发送的FeedId

CREATE TABLE `personal_timeline` (   `id` bigint(20) NOT NULL,   `member_id` varchar(20) NOT NULL COMMENT '用户ID',   `feed_id` varchar(32) NOT NULL COMMENT '发布的内容id',   `publish_at` bigint(19) NOT NULL COMMENT '发布时间',   PRIMARY KEY (`id`),   KEY `idx_member_id` (`member_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4


目前旧系统存储的 关注页timeline (收件箱) 单表数据量已经超过2000万,因此我们将在存储上做了分库分表设计,一共分了4个库,64张表,单表2000万的数据,按照比较理想的平均分配来算单表的数据量会在32万左右,这样在查询的上短期内不会有瓶颈了。

分库分表采用了比较轻的基于客服端实现的Sharding-JDBC框架。

Sharding-JDBC足够轻,使用成本低存储层未来可能会切换到TiDB,TiDB原生支持能够将已分库分表数据的导入进去


如何打造一个轻量级的社交系统


Feed聚合

由于收件箱、发件箱在数据存储上只存储了用户ID、FeedID等基本的索引信息,而Feed在实际显示中会显示很丰富的内容,比如:用户头像、昵称、Feed正文、标题、发布时间、点赞数量、评论数据、转发数量、关注状态、收藏状态等等一系列数据,仅凭已有的ID是不够的,因此在查询到FeedID后,还需要聚合Feed内容。

以查询一个用户的关注列表Feed流(查询收件箱)为例:

根据用户ID查询follower_timeline表,得到用户ID、FeedID通过用户ID、FeedID查询用户系统、Feed系统、评论系统、计数系统、收藏系统 等聚合Feed内容数据


从查询FeedID列表到得到FeedContent列表的过程

如何打造一个轻量级的社交系统

一个真实的Feed Content列表数据如下:

如何打造一个轻量级的社交系统


可以发现当我们得到一个用户ID和FeedID后,仍需要去做大量的数据查询才能拼凑出来实际的Feed流内容,因为在微服务架构下,这些数据都分散在各个系统。


如何高效查询关注页、个人页的FeedID列表?

因为Feed有个特点是它的时效性,一般很少有人去翻看上周、上个月的Feed,所以我们可以使用codis来缓存关注页、个人页最新的N条热数据,当查询的数据在N之内,则直接从codis中返回,当查询的数据在N之外,则查询DB

 /** 上拉加载、下拉刷新相关伪代码 */  //热数据最大存储数量,如果每次查询20条Feed,那么缓存中的500条热数据可以满足前25页的查询 int N = 500;   //上拉加载更多 //根据上一条观看的FeedId来分页查询 List<?> loadMore(String memberId, Long lastId, Integer pageSize){     List<?> value = init(memberId);     //通过lastId定位索引     int idx = indexOf(value, memberId, lastId);     int nextIdx = idx + 1;     //1 超出热数据范围 走DB     if(idx == -1 || nextIdx + pageSize > N){         return findDb(memberId, lastId, pageSize);     }     //2 没有超出热数据范围 命中缓存     return value.size() < nextIdx + pageSize ? value.subList(nextIdx, value.size()) : value.subList(nextIdx, nextIdx + pageSize) } //下拉刷新 获取最新的feed List<?> reflush(String memberId, Integer pageSize) {        //1 超出热数据范围 走DB     if(pageSize > N) {         return findDb(memberId, pageSize);     }       //2 没有超出热数据范围 命中缓存     List<?> value = init(memberId);     return value.size() < pageSize ? value : value.subList(0, pageSize); } public int indexOf(List<?> list, Long lastId) {     for(int i = 0; i < list.size(); i ++) {         if(list.get(i).getId().longValue() == lastId.longValue()) {             return i;         }     }     return -1; } //从DB中初始化Feed到redis List<?> init(String memberId) {     lock(memberId.inter());     String key = ...;     //1 从redis中获取热数据     long count = redis.zcard(key);     //2 没有热数据     if(count == 0) {         List<?> value = findDb(memberId, N);         //3 初始化热数据         for(....)             redis.zadd(key, score, v);         return value;     }     return redis.zrevrange(key, 0, -1); }


根据FeedID列表如何保证在预期时间内从超过10+个系统中聚合Feed内容?

每个微服务都提供高效的批量查询RPC接口,如:根据用户ID列表批量获取多个用户信息、根据FeedID列表批量获取Feed点赞数量等,对每个接口的RT有要求使用线程池并行调用RPC接口获取数据,采用ThreadPoolExecutor.invokeAll方法批量执行Task,并设定总的超时时间,对所有线程总的执行时间有要求

//定义线程池  ThreadPoolExecutor executor = new ThreadPoolExecutor(                 10,                 100,                 5l,                 TimeUnit.SECONDS,                 new LinkedBlockingQueue<Runnable>()); //task的数量根据实际业务来定义 //task1 通过Rpc调用获取数据 Callable<Object> task1 = new Callable<Object>() {     public Object call()  {         try {             //RPC call         } finally {         }         return null;     } }; //task2 通过Rpc调用获取数据 Callable<Object> task2 = new Callable<Object>() {     public Object call()  {         try {             //RPC call         } finally {         }         return null;     } }; //task3 通过Rpc调用获取数据 Callable<Object> task3 = new Callable<Object>() {     public Object call()  {         try {             //RPC call         } finally {         }         return null;     } }; //执行所有任务,并设定总超时时间为5秒 executor.invokeAll(Arrays.asList(task1, task2, task3), 5l, TimeUnit.SECONDS);


如何确保在聚合Feed过程中不会因为其中某一个任务接口响应过慢而导致整个Feed数据不完整而影响展现?

将拉取数据任务划分等级,分为必要数据非必要数据,必要数据如果缺失则整个Feed拉取失败,非必要数据如果缺失则采用降级容错策略填充数据,如:用户头像、昵称、Feed内容为必要数据, 点赞数量、评论数量、标签等数据为非必要数据,可根据实际业务采用默认值、空值填充策略


图为线上业务最近一小时聚合Feed的RPC接口调用次数、响应时间相关数据,每个RPC接口在一次查询20条Feed的情况下,每个服务均部署了2台,系统调用链超过10+个服务,其整体聚合的时间在50ms上下,由此可见整个Feed聚合过程、多线程调用各个RPC链路的性能还是非常可观的

如何打造一个轻量级的社交系统

您可能还会对下面的文章感兴趣: