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