构建一个类timeline系统的架构设计

最近一直对微博、twitter、微信朋友圈这类软件所提供的类timeline系统架构很有兴趣,也可以叫做时间轴、news feed,或者status update,查阅了不少资料,也结合自己对于架构设计的一些积累认识,尝试着设计了一把。下图是一个简单tweets界面:
首先明确目标,要设计的系统是一个用户数、数据量、并发量足够大的平台,按照一般经验:
1M+ Active User,10T后台数据,3k QPS,peek 10k QPS
这里声明一点,业务需求决定技术演化路线,任何项目开始做要start from the bottom up,千万不要过度设计啊,下文都是尝试的思考
 
这里先介绍一个简化的模型,当系统单机+MySQL时候,我们的表结构设计可以如下
用户表:
| uid | username |
关系表:
| uid | following_uid |
feed表:
| fid | uid | content | create_time |
 
假设A用户关注了B、C。B和C各发表一个feed直接insert feed表即可。
 
A查看自己的timeline的SQL可以表示为:
SELECT uid, content FROM feed WHERE uid IN (SELECT following_uid FROM 关系表 WHERE uid = A) ORDER BY create_time DESC LIMIT N
这里如果QPS较低,总体数据量在1T以下时候,单机+MySQL单纯的靠scale up还可以满足,但是一旦用户数、数据量、并发量成倍的上去的时候,这个模型显然不够用了。
 
一个更加成熟的设计方案如下图所示,
 
 
这里大致分为4个部分,Portal、Pusher、Puller以及存储,下面详细的说明下。
 
存储
先说存储的原因是,随着数据量的增大,我们首选要解决的是一个扩容问题,这个实际在项目kick-off时候容量规划就应该做好,单库单表无法满足我们需求的时候,我们就分库分表。
 
尤其是基于MySQL这种久经考验但是缺少NoSQL特性的数据库,在水平扩展方面没有很好的支持,这里涉及很多问题不多讨论原因,所以最初的分库分表规则要定制好,形成一套规则,以及数据库路由访问层(DAL),防止数据迁移带来的高运维成本。
 
分库可以按照业务重要程度分成不同库服务于不同的业务,例如核心的feed库应该单独一个物理库来存储,而非核心的例如用户资料库可以单独一个物理库+逻辑库解决。
 
核心库在这样一个大型系统,可以分为64库,feed表可以分为256表。采用uid求模的方式来计算该feed应该路由存储到哪个库分片(slice)的哪张表(table)上。还有一种思路是按照时间来分表,这里不做延伸。
 
在分库分表等扩展性上如果想省力气的话可以考虑一些NoSQL产品,例如Cassandra,可以动态的扩容,不过貌似twitter现在已经不用它了,仅供思路扩展。
 
表的存储方面也有一些技巧,一般MySQL允许的单表容量在2000w,所以一般将热表数据锁定在一个2000w行的量级上,而将历史的冷数据干脆archive到一个history表,这些表一般不轻易查询。对于存储引擎来说,热表可以用InnoDB,冷表用MyISAM,定期由DBA维护保持热表的行数稳定不膨胀,提高热表的查询效率。
 
关于事务方面,其实很多场景下,我们要追求性能,就必须舍弃掉一些东西,例如一致性、可用性等等,在这里对于feed这种场景,我们完全可以牺牲一些一致性的苛刻要求,只追求最终一致性来达到我们对于减低系统复杂度,提高性能的目的,所以我们在这种系统内尽量不启用事务,不管是数据库事务,还是分布式事务都不用。
 
 
缓存
按照上面那个复杂的连表查询SQL,当数据量很大的时候,首先uid落不到一个库上更很少可能会是一张表,这种查询必然在大数据情况下产品性能瓶颈,一个常见的解决模式便是利用缓存,用空间换时间的思想,尽量让请求通通落到缓存上,也就是说提高缓存命中率,例如timeline如果能做到90%,那么用户体验就会上升很多。
 
下图是各种存储I/O性能的对比,可以看出内存随机I/O是多么的快。
 
缓存这里指分布式缓存,相对与数据库访问,他们性能是非常高的,单机上万QPS不成问题,而且响应时间基本在<10ms,甚至1、2ms返回。例如memcahe、redis等KV类型的缓存,memcache有成熟的spymemcache client做一致性哈希策略来支持高可用HA场景,而redis提供了丰富的数据结构、持久化策略、主从同步、以及比memcache在某些场景下更加优秀的性能,因此在近几年非常流行,大有取代memcache的趋势。
 
在timeline存储的场景中,可以将用户的所有关注人的feed组成一个list,存储在分布式缓存中,例如redis的存储数据结构选择list,可以用RPUSHX命令更新一组由feed组成的timeline。
 
当然用redis这种开源的NoSQL当缓存,也要有很好的HA策略,例如主从,双写随机读,一致性哈希,缓存预热等,他们在运维成本,性能、客户端轻重角度各有优劣,因此选择最适合自己的方案即可。还有当不可用时候的雪崩效应,会不会击穿数据库等,都需要考虑。
 
另外这里延伸下,pusher的下游是数据源,而cache的更新可以不走pusher,而是在MySQL下面接入一个伪装的从库,接收row based binlog来判断insert、update、delete从而可以实现更新timeline cache的逻辑。
 
 
Portal
这里的Portal提供面向客户的对外服务。
最上层分别是WEB和API的模块,一个类timeline系统一般是web和app端都覆盖的,因此,WEB提供平台端访问直接给前端提供服务;而API可以遵循REST架构风格,通过OAuth2.0协议做权限验证,对外给第三方提供服务。
第二层我叫做Presentation tier,主要负责基础的权限验证,封装不同的view给前端使用。
第三层到达了核心的业务逻辑层。
其实按照一个合理的分层思想,还应该有数据资源层、物理访问层,这幅图其实想更多的表达一个流程图的意义,故没有表现出这两个底层来。
 
 
Pusher
当系统比较简单的时候,Portal可以直接来操作底层数据源MySQL存储,但是别忘了我们要构建的是一个复杂的大规模分布式系统,因此我们要借鉴一些互联网设计的常用模式,这里最适合的模式便是“异步解耦”。
 
一般单机App server(如Tomcat)其QPS可以达到5000左右,这只是理论值,而且不考虑峰值,利用集群模式,将server scale out横向扩展,做到彼此无状态,可以一定程度解决性能瓶颈,但是当客户端要求响应极其苛刻的时候(例如timeline首页要求必须150ms内返回),不能以阻塞客户端、或者牺牲性能为代价,这时候我们可以把异步来解耦,客户端直接返回,由后端服务慢慢消化,在Java中Executors框架遍提供这种单机的模型,一个BlockingQueue,后面是一个线程池(thread pool)按照系统可以接受的吞吐量来处理任务。
 
Pusher是一种重发轻查的方式,如下图所示,它有两个职责,1)存储MySQL,2)fan-out去更新所有活跃用户的timeline cache
 
 
不管#1还是#2,前面都接了一个queue来,这个queue可以是redis也可以是Kestrel,这里说下Kestrel,它是twitter开源的一个消息队列,它的一个去中心化思路对于小型团队秉承简单易用的原则很重要:
 
“Dropping the requirement on cross communication makes it horizontally scale to infinity and beyond: no multicast, no clustering, no "elections", no coordination at all. No talking! Shhh!”
 
 
Delivery可以看做是投递服务,主要是fan-out,所谓fan-out如下图所示,汲取的是“推”的思想,将新的feed更新到粉丝的timeline cache中,就是上一节提到的分布式缓存。
 
当然fan-out也不是所有的粉丝都分发出去,那对于“姚晨”这种有千万粉丝的用户来说,瞬间的负荷就太大了。所以要采用分级的策略,也就是说将粉丝按照活跃程度分为不同的等级,对于近一两天有登陆的用户push到优先queue来处理,而将不活跃甚至数周都没有登陆的用户干脆不进行推送。
 
 
Puller
再来看Puller,它可以看做是一个“拉”的过程。
 
对于活跃用户,他的timeline cache还没有过期(expired),那么可以按照uid直接去redis缓存中拉数据。
 
对于非活跃用户,他的timeline cache未命中,所以要建立这个timeline cache,按照最开始介绍的简单模型,需要取出所有的关注uid,然后分库分表查询,这里也不是一个串行的过程,同样在单机上利用Java提供的Excecutors框架,用invokeAll(Callable)方法获取所有Future,设置一个timeout时间查询失败的干脆忽略,还有考虑用CyclicBarrier,需要所有的子任务都完成时,才执行主任务取进行多数据源merge,rank、sort、filter、page工作。
 
对于Puller拉的缓存其实还以分为3级,
1)JVM缓存:单机JVM缓存,当前端portal不是随机call puller的时候,而是固定user落到固定的server上时候,这个缓存就非常重要,可以尝试用guava的cache来实现。
2)page or fragment cache:通常明星用户的timeline会被很多人访问,可以尝试将这些高频访问用户的渲染出来的timeline进行一个缓存,这个渲染的结果甚至可以是包含js、模板运算完毕的。
3)上述的timeline cache,不赘述。
 
对于缓存还可以将内容和id分开,众所周知,固定长度并且都是数字保存的数据结构更加有利于查询和节省存储空间,那么缓存可以分为两类,一级缓存是
| uid | fid | following_uid |
 二级缓存是
| fid | content |
而二级缓存就可以用protobuf、thrift等高性能二进制压缩协议来存储交换,而一级缓存可以用更少的资源来存储,当二级缓存down的时候,还可以用来迅速查询MySQL取数据。
 
 
Comet
一般客户端都会看到“几条未读新鲜事”之类的提醒,一般都是通过comet来实现的long polling,用nginx的push stream module可以完成这个操作,不断对比timeline的last fid和timeline cache中的last fid,如果有diff则提示count(diff)未读。
 
 
服务型架构
Portal、Pusher、Puller等在模块化的基础之上,可以采用服务化的思想来改造,twitter用的即是Finagle框架,该种框架可以提供高性能的二进制RPC通信交互协议,同时提供服务注册、发现,服务治理的能力。这里打个广告自己在项目组开源的navi-rpc和这种服务化框架思想类似。
 
 
上述纯个人研究思考用,实际情况要具体问题具体分析,没有放诸四海而皆准的办法,但是有些设计的原则和模式可以沉淀下来借鉴。
 
 
原创文章,转载请注明来源于neoremind.com。 

7 Comments on this Post.

  1. baocaixiong

    写的很好
    图挂了哦

  2. 写的挺好的,赞!我这里大概也整理了下类似的
    http://gglinux.com/2017/03/06/feed_design/

  3. neo

    貌似是的,谢谢指出问题。

  4. JamLee

    非常有帮助的文章,感谢。

  5. Caesar

    博主,读完您的这篇文章,受益匪浅,太赞了!
    3k/per sec QPS,peek 10k/per sec QPS 应该是 3k QPS ,peak 10k QPS 吧?另外,图又挂啦。

  6. neo

    是的,谢谢更正。图的问题一直没来得及修复=。=。。。非常不好意思

  7. Will

    牛人!不光技术过硬,语言表达能力也超强。

Leave a Comment.