简介
随着国外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链路的性能还是非常可观的