架构设计

朋友圈式的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中来获取更快的速度。

4 comments

redis直接存储推文应该是没有问题的。

tl里面只存放id,因为如果存放内容的话,很显然这个数据的冗余太大了,每个用户的tl里面都存储了完整的推文,这个数据量是不可想象的,比如一个有1千万粉丝的用户发一条推文,那么一千万条重复的数据就要进入到redis里,成本太大了。

redis不光存放tl,tl其实放到数据库也是没问题的,因为tl的访问不会很高(访问一次查一次数据库就是了),redis真正的强大是做缓存。从用户的tl里拿到id,然后去redis找这条推文,如果找不到就去数据库找,然后将它放进redis里(这里缓存机制可以把缓存里面最久没有被浏览的弹出去,或者数据库的数据一定时间内被浏览第三次再放入redis等)。之前看过Twitter保存缩略图就是这样,用户上传一张图片Twitter就要生成好几张不同规格的缩略图(比如网站载入一般的时候和全载入的时候显示不同清晰度),成本很大。但其实可以把很长时间没浏览的图片删除,只保留raw图片,如果有用户来翻的话实时用脚本生成一下缩略图。这样省下很多钱。

关于删除推文,其实你不要从数据库删除任何东西,删除会带来很多副作用,你只是把数据标注成is_deleted。这样无论在redis找到的数据,还是在数据库找到的数据,都只要标注成is_deleted就可以,is_deleted的数据不要展示给用户就可以了。然后数据从redis弹出的时候,如果比数据库的数据版本高的话就使用redis的数据更新一下。

嗯。厉害!

我的一开始的想法是直接将推文放到redis,只存一条。然后tl保存这条推文的key,完全脱离SQL数据库。但是这种做法还是有其他问题,总会对数据库做查询。

不过缓存这一块我还没有考虑到,加了缓存可以解决很多的问题。事实上我做的这个系统还完全没有开始做缓存。每次准备加缓存的时候,都会觉得对系统的更改很大。不过现在想来,不加也不行了。越往后拖,积累的任务量越重。。。。。

因为缓存策略是一个很重要的问题。

如果所有的数据都是一个缓存策略,耦合性会很低。只需要在db层稍改代码就行。

但是哪些数据需要做缓存,或者是否需要不同的缓存策略这些我自己都不清楚,数据的实时性要求比较高。之前看过一些书,基本上都有一个观点:不要做提前优化,等真正遇到瓶颈时再针对性的优化。

因为我没有做这方面的经验,所以想等系统正式运行时,分析哪些部分的数据负荷比较大,再针对性的做缓存。

Leave a Comment

电子邮件地址不会被公开。 必填项已用*标注