朋友圈式的Timeline设计方案

一、前言

几乎每个人都会发朋友圈、微博,一个看似简单的发布功能,实际在背后是经过精细设计的组织架构。这里有一篇关于新浪微博的架构设计的演讲,主要是讲了通过redis+缓存的使用,来实现发微博的实时性和高效性。

二、Timeline

目前来说,大多数发朋友圈、发微博这种架构的设计都是Timeline的方式。以微博为例,每个用户都有一个自己的Timeline,用户查看微博时,只从自己的Timeline上获取数据。而如果我们是使用普通的sql查询,那么查询可能就是select weibo from table where table.userId in (select userId from guanzhu where guanzhu.userId = 'me')。也就是先查询我所关注的人,再查询所有我关注的人发的微博,然后以时间排序返回。而且因为数据的变化会非常的快,根本没有办法去做缓存,并且刷微博又是一个非常高频的动作,因此普通的sql查询必然无法满足。

而Timeline方案,其实就是以空间换时间的一种方案,假设现在姚晨(1000万粉丝)发布了一条微博,在姚晨的视角里,她已经发布完成了,但是实际上此时并不是她的所有粉丝都能立刻看到这条微博。这个发布动作做了异步处理,此时正在向关注她的微博的粉丝的Timeline上推送(当然微博也不可能真的向她所有粉丝推送,可能会去除一些长期不上线的用户以及僵尸粉)。

因此Timeline方案最复杂的地方就在于发微博这个环节而不是刷新微博这个环节了。发微博相对于刷新微博是非常低频次的,并且只要自己能实时看到就行,并不要求其他人都能实时看到,大大降低了复杂度。

三、使用redis配合完成Timeline的设计。

redis是一款内存数据库,但是它的数据也可以持久化的磁盘上。因为是内存数据库,它最擅长的就是高频次的读取和写入。并且redis支持多种数据结构,其中list数据结构非常符合Timeline的需求。

在redis中,可以创建命名的list(系统中现在使用了zset去保存timeline,比list更优),并且可以对list做push、pop、range操作。比如id为28的用户,他的timeline可以是名为u28的list,然后使用push来向用户的Timeline增加新的推文、使用range来获取指定范围内的推文。

下面的php代码演示了如何从Timeline上获取推文信息

/**
 * 获取一页时间轴上的推文id
 * @param $userId 用户id
 * @param $pageIndex 页码
 * @param $pageSize  页大小
 * @return array 一页postId
 */
public function getTimeline($userId,$pageIndex,$pageSize){
    $end = (1 - $pageIndex) * $pageSize - 1;
    $start = $end - $pageSize + 1;

    $result = $this->redis->lRange($this->getUserTimelineKey($userId),$start,$end);

    return $result;
}

/**
 * 获取用户时间轴上推文的总数
 * @param $userId 用户id
 * @return int 总数
 */
public function getTimelinePostCount($userId){
    return $this->redis->lSize($this->getUserTimelineKey($userId));
}

四、php如何异步推送到其他用户的Timeline

php的机制决定了它无法完成异步操作(一个php文件执行完成之后线程会直接被销毁),因此这里同样是采用了Redis当做任务队列来解耦合,实现异步操作。一个用户发布推文之后,使用下面的setPostTask($pushUserIds,$postId,$userId),将发布推文放到任务队列中然后直接返回。在这里,对用户自己的Timeline做了特殊处理,发推文之后立马给自己的timeline同步推送,其他用户的timeline是异步推送。

        /**
         * 设置推送任务
         * @param $pushUserIds 要推送的userId
         * @param $postId      推文内容
         * @return mix 任务推送状态
         */
        public function setPostTask($pushUserIds,$postId,$userId){

            //为了用户第一时间看到自己发的,这里特殊处理用户自己发的推文
            $this->redis->rPush($this->getUserTimelineKey($userId),$postId);

            $userIds = array();
            foreach($pushUserIds as $value){
                array_push($userIds,$value['userId']);
            }
            $data['u'] = $userIds;
            $data['p'] = $postId;
            $str = \json_encode($data);
            return $this->redis->rPush($GLOBALS['redis_post'],$str);
        }

于此同时,一个以cli模式运行的php脚本从任务队列中顺序获取所有的推送任务,向其他用户的Timeline上推送。

/**
 * 获取推送推文的任务
 * @return mix 推送结果
 */
public function getPostTask(){
    return $this->redis->lPop($GLOBALS['redis_post']);
}

/**
 * 将推文id推送到所有用户的timeline中
 * @TODO 现在是同步的方式
 * @param $pushUserIds 要推送的用户id
 * @param $postId 推文id
 * @return boolean  true操作成功
 */
public function pushToTimeline($pushUserIds,$postId){
    foreach($pushUserIds as $value){
        $result = $this->redis->rPush($this->getUserTimelineKey($value),$postId);
    }
    return true;
}

cli模式运行的php脚本代码:

<?php
/**
 * 从Redis队列中轮询要执行的任务,并执行推送
 *
 */
require_once('vendor/autoload.php');
use DB\PostDB;

$postDB = new PostDB();
$postContent = "";
while(true){

    while( ($postContent = $postDB->getPostTask()) != FALSE ){
        $post = json_decode($postContent,true);
        var_dump($post['u']);
        $postDB->pushToTimeline($post['u'],$post['p']);
    }

    sleep(1);

}

即使这个推送会花费一些时间,但是没有任何用户的体验受到了影响,发布推文的用户timeline因为是特殊处理,所以他能立马看到自己发的推文,其他用户虽然无法立刻看到,但是其他用户对“立刻看到”的需求几乎没有(“立刻看到”指的是刚发布的一瞬间,绝大多数人对于延迟几秒看到根本无所谓)。并且这个架构可以很轻松的横向扩展。

五、一些其他问题

5.1 内存很贵,尽量压缩

redis是内存数据库,内存相对于磁盘的价格要贵上很多倍,因此存在redis中的内容要尽量的小。比如存的是Json,那么Json的字段要尽量的小(直接使用1,2,3,4,5这种也完全可以,即使损失了可读性),同时Json也可以考虑改成谷歌的Protocol Buffers等等。

5.2 timeline里应该存什么

使用Redis存储了每个用户的Timeline,在我的实现中,timeline中存储的是每篇推文的id,然后获取到id之后再去mysql数据库中查询,但是这样的设计虽然大大降低了查询的复杂度,但是并没有降低mysql的IO负荷,如果把推文内容直接存储到Timeline中呢?这样的话如果用户要删除一篇推文,如何对应的删除其他用户的timeline中的推文?这个地方一直是我无法解决的疑惑。

这里有一篇关于twitter使用redis解决timeline的方案,这篇方案中使用的不是list来保存用户的post,而是zset,zset中每条数据由推文的id和时间戳组成,通过时间来排序。仔细考虑一下,list因为是根据推送时间来排序的,可能出现后发的推文出现在靠前的位置(当然我认为是可以通过隐藏描述显示来解决这个问题,因为错位几秒并不影响)。

所以理论上来说,上面那篇文章介绍的方案和我的方案是一致的,只是我把推文的源放在mysql上,twitter直接是存储在redis中来获取更快的速度。