玖叶教程网

前端编程开发入门

如何打造一个轻量级的社交系统(社交无用:如何建立有效的社交网络)

简介

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

发表评论:

控制面板
您好,欢迎到访网站!
  查看权限
网站分类
最新留言