即使一个系统现在可以可靠地工作,但并不意味着未来它也一定会可靠地工作。造成退化的一个常见的原因就是日益增加的负载:系统的并发用户可能从10000增加到了100000,或者从1000000增加到10000000。可能它处理的数据量比之前大得多。可扩展性是我们用来描述一个系统处理增加的负载的能力。然而,它并不是一个我们可以贴到系统上的一维标签:说“X是可扩展的”或“Y不能扩展”是没有意义的。当然,讨论可扩展性的意思是考虑这样的问题“如果系统以特定的方式增长,我们处理增加量的选项是什么?”和“我们如何增加计算资源来对付增加的负载?”
1.3.1 负载描述
首先,我们需要简洁地描述系统的当前负载;只有这样我们才能讨论增长量的问题(如果负载加倍会怎样?)。我们可以用称之为负载参数的一些数字来描述负载。参数最好根据系统架构来选择:可能是网络服务器的每秒请求量,数据库中读写的比例,聊天室中的并发用户数量,缓存的命中率,等等。一般情况下是对你比较重要的指标,少数极端情况下可能是占主导地位的瓶颈。
更具体些讲,让我们以Twitter在2012年11月份发布的数据作为例子。Twitter的两个重要运营是:
发布推文
用户可以向粉丝发布新消息(平均每秒4.6k个请求,峰值超过12k)
首页时间线
用户可以观看关注者发布的推文(每秒300k个请求)
简单地处理每秒12000个写请求是相当容易的。但是,Twitter的扩展挑战主要不是推文量,而是“扇出”----每个用户关注了很多人,同时每个用户又被很多人关注。很明显,有两种方法来实施这两种运营:
1.发布推文时,简单地将新推文插入全局合集。当用户请求他们的首页时间线时,查找他关注的所有人,找出他们的所有推文,然后合并。在像图1-2中的关系型数据库中,你可以这样写查询语句:
SELECT tweets.*, users.* FROM tweets
JOIN users ON tweets.sender_id = users.id
JOIN follows ON follows.followee_id = users.id
WHERE follows.follower_id = current_user
图1-2 实施Twitter首页时间线的简单关系架构
2.为每个用户的首页时间线维护一个缓存----类似一个每个接收用户都有的推文收件箱(见图1-3)。当一个用户发布一条推文,查找所有关注此用户的人,将新推文插入他们的首页时间线缓存中。这样的话,读取首页时间线的请求会很快,因为结果已经提前计算好了。
图1-3 Twitter发送推文到粉丝的数据管道,在2012年11月的负载参数下。
Twitter使用的第一个版本是方法1,但是系统几乎无法应付首页时间线的查询负荷,所以切换到方法2.此方法工作良好,因为平均推文发布率差不多比首页时间线的读取率小两个量级,所以在这种情况下更倾向于在“写”上做更多工作而在“读”上做更少。
然而,方法2的缺点是现在发布一条推文需要更多的额外工作。一个推文平均要发送给75个粉丝,所以对首页时间线缓存4.6k的每秒写请求变成了345k。但是这个平均数没有考虑变化很大的粉丝的数量,而且有些用户又超过3000万的粉丝。这意味着一条简单的推文可能会导致对超过3000万首页时间线的写请求!实时做这些工作----Twitter设法在5秒内对所有粉丝交付推文----时一个巨大的挑战。
在Twitter的例子中,每个用户对粉丝的分发操作(可能按用户发布推文的频率计算)是讨论扩展性的一个关键负载参数,因为它决定了“扇出”负载。你的应用可能有非常不同的特性,但你可以运用类似的准则去推理负载。
Twitter轶事的最后转折:现在方法2已经被粗暴地实施,Twitter正打算融合两种方式。大部分用户发布推文时继续使用“扇出”更新首页时间线,同时,一小部分拥有大量粉丝的用户被排除。用户可能关注的任何名人的推文在被读取的时候会被单独拿来合并到这个用户的首页时间线中,类似方法1.这种融合的方法能够始终高性能地交付。在我们说到更多的技术背景后,我们会在第12章回顾这个例子。
1.3.2 性能描述
在描述了系统负载后,你就可以研究当负载增加时会发生什么。你可以以两种方法考虑:
- 当你提高负载参数并保持系统资源不变时,系统性能会如何变化?
- 当你提高负载参数,要想保持性能不变,需要增加多少资源?
两个问题都需要性能值,所以让我们简单看一下系统性能的描述。
在一个批处理系统,例如Hadoop中,我们通常关心“吞吐量”----我们每秒可以处理的记录的数量,或者在一个特定大小的数据集上跑一个工作所花的总时间。在在线系统中,通常更重要的是服务的响应时间----就是一个客户端发送请求和收到回应之间的时间。
等待时间和响应时间
等待时间和响应时间经常同时使用,但是他们不同。响应时间是客户所看到的:除了处理请求的实际时间外,它还包括网络延迟和队列延迟。等待时间是一个请求等待被处理的间隔----在等待服务时。
即使你只是重复发起同样的请求,每次的响应时间也会有微小的不同。实际上,在系统处理各种各样的请求中,响应时间差别很大。因此我们不能将响应时间看做简单的数字,而是一个可以测量的许多值的分布。
在图1-4中,每个灰条代表一个服务请求,它的高度呈现了响应时间的长度。大部分请求相当快,但是偶尔有更长的异常值。可能那些慢请求本来就比较耗时,因为需要处理更多的数据。但是即使在一个你认为所有请求应该耗时相同的场景里,你也会得到差异:上下文切换到后台进程时会引入随机的附加等待时间,网络包的丢失和TCP中继,一个垃圾回收暂停,一个页面错误强制读取磁盘,服务器机架的机械振动,或许多其他的原因。
图1-4 图例中均值和百分位数:100此服务请求响应时间的例子
通常可见报道的平均服务响应时间。(严格来讲,“平均”不涉及任何特定的公式,但实际上通常理解为算术平均值)然而,平均值不是一个非常好的度量,如果你想知道“典型的”响应时间,因为它不能告诉你有多少用户实际上经历了那个延迟。
通常使用百分位数比较好。如果你拿到响应时间的列表并从快到慢排序,那么中位数就是那个中间点:例如,如果你的响应时间中位数是200ms,那代表一半请求在200ms内返回,另一半需要超过200ms的时间。
这使中位数成为一个良好的度量,如果你想知道用户一般要等多久:一半用户在中位数时间内得到服务,另一半需要更长的时间。这个中位数也被熟知为第50个百分位数,有时缩写为p50。请注意中位数是指单个请求;如果用户发起若干请求(超过一个会话的过程,或者因为单页包含了若干资源),至少一个请求比中位数慢的概率会远大于50%。
为了搞清楚异常值有多差,你可以看一下更高的百分位数:一般是第95,第99,第99.9个百分位数。它们是95%,99%或者99.9%的请求都更快的响应时间阈值。例如,如果第95个百分位数的响应时间是1.5秒,这意味着100个请求中的95个都小于1.5秒,5个是1.5秒或更多。这在图1-4中说明了。
响应时间的高百分位数,也被认为是“尾潜伏期”,这个很重要,因为能直接影响用户的服务体验。例如,Amazon依据第99.9百分位数描述了互联网服务的响应时间需求,虽然它只影响千分之一的请求。这是因为请求最慢的客户通常账户上有最多的数据,因为他们购买了很多次,也就是说,他们是最有价值的客户。通过让他们更快的访问网站来保证这些客户的愉快是很重要的:Amazon同时观察到每增加100ms的响应时间会减少1%的销售额,其他报告也指出1秒的减慢会减少16%的客户满意度。
在另一方面,优化第99.99百分位数(最慢的千分之一的请求)被认为代价过于高昂而不足以达到Amazon期望的收益。减少高百分位数的响应时间是非常困难的,因为它们很容易受到你控制之外的随机事件的影响,而且收益会逐渐减少。
例如,百分位数在服务水平目标(SLO)和服务水平协议(SLA)中经常使用,就是定义了目标性能和服务能力的合同。一个SLA可能规定了服务被认为中位数响应时间要少于200ms并且第99百分位数在1s以内(如果响应时间更长,应当减小),同时服务应该至少99.9%的时间都在被请求。这些度量为客户设置了服务的期望值,允许客户在SLA没有达到时要求赔偿。
队列延迟通常可以解释大部分高百分位数的响应时间。因为一个服务器只能同时处理少量事情(有限制的,例如,CPU核心编号),它只能用少量的慢请求来阻止请求队列的处理----一个叫做“线头阻塞”的效应。即使那些后续的请求能很快处理,客户仍然会看到一个慢的全局相应时间,因为要等待优先级高的请求先完成。由于这个效应,在客户端测量响应时间会显得很重要。
当为了测试系统的可扩展性而人为制造负载时,制造负载的客户端需要持续独立于响应时间地发送请求。如果客户端发送下一个请求之前在等待前一个请求完成,那种行为在测试中会受到人为保持队列更短的影响,这实际上会影响计量结果。
实际情况下的百分位数
高百分位数在称为作为单终端用户请求的一部分的多重时间后台服务中变得尤其重要。即使你平行发送调用,中断用户请求仍然需要等待平行调用中最慢的那个先完成。它只使用一个慢调用就能使整个终端用户请求变慢,如图1-5所示。即使一小部分的后台调用比较慢,得到一个慢调用的几率也会变大,如果一个中断用户请求多重的后台调用,而且大比例的中断用户请求结束得很慢(一个叫做“尾潜伏期放大”的效应)。
如果你想增加服务监视仪表盘的响应时间百分位数,你需要在持续的基础上快速计算它们。例如,你可能想保持最后10分钟请求响应时间的滚动窗口。每分钟你都要计算中位数和窗口中各种各样数值的百分位数,并且在图形上绘制那些度量。
天真的实施方法是在时间窗口内保持所有请求的响应时间列表,并每分钟都排序。如果这样对你来说不太高效,还有可以花费最少CPU和内存的计算百分位数近似值的算法,比如正向衰变(Forward Decay)、t-digest、HdrHistogram等。注意,平均百分位数,为了减少解决方案时间或合并多台机器的数据,在数学上是没有意义的,合计响应时间数据的正确的方法是添加柱状图。
图1-5 当一个服务请求需要多个后台调用时,只要一个慢的后台请求就能减慢整个中断用户请求
1.3.3 负载的处理方法
现在我们已经讨论了描述负载的参数和测量性能的度量,我们可以开始认真地讨论可扩展性了:当负载增加了几个数量级时我们如何维持良好的性能?
一个适应某个负载水平的架构不一定能处理10倍的负载。如果你在一个快速增长的服务上工作,你很有可能需要重新考虑架构来处理每个量级的负载增长,甚至更多。
人们通常讨论两种方法:按比例放大(纵向扩展,转移到一台配置更高的机器上)和向外扩展(横向扩展,将负载分布到多台更小的机器上)。后者也被认为是非共享架构。可以运行在单台机器上的系统通常更简单,但是高端机器是很昂贵的,所以非常集中的负载通常无法避免横向扩展。实际上,好的架构会融合多种方法:例如,使用若干台相当强大的机器仍然比大量的小型虚拟机更简单和便宜。
有些系统是有弹性的,意思是当检测到负载增加时他们可以自动地增加计算资源,但是其他系统却是人为增加(有人分析了容量并决定为系统增加更多的机器)。当负载高度不确定时,一个有弹性的系统非常有用,但是人为扩容的系统更简单而且可能有更少的操作意外(请见209页的“再平衡分割”)。
在多台机器上分布无状态服务相当直接,但把有状态的数据系统从单节点转为分布式安装时会带来许多额外的复杂性。由于这个原因,直到最近,通常最好的做法就是讲数据库保持在单节点上,直到扩展代价或者高性能需求导致你不得不转为分布式。
随着分布式工具和概念系统变得越来越好,至少对某些类型的应用来讲,这个做法可能要改变了。可以想象到,未来分布式数据系统会变为默认选择,甚至对于不需要处理大量数据或交易的用例。在本书剩下的课程中,我们会讲到多种分布式数据系统,并且讨论他们如何处理可扩展性,以及如何缓解使用和可维护性。
大规模应用的系统架构通常是特定的----没有通用的,一体适用的可扩展架构。问题可能是读取量、写入量、数据存储量、数据复杂性、响应时间需求、访问模式、或这些甚至更多问题的混合。
例如,一个系统设计为每秒处理100000个请求,每个1KB,看起来与一个设计为每分钟3个请求,每个2GB的系统差异很大----即使两个系统有同样大小的吞吐量。
一个对特定应用扩展较好的架构,建立在操作普通且负载参数较少的假设上。如果这些假设是错的,那么那些设计工作量好的情况下是被浪费掉,坏的情况下会适得其反。在早期安装或未证实的生产中,能够在生产特性上快速迭代比扩展到未来假设的负载要更重要。
即使这样,它们对特殊应用仍然是特定的,可扩展的架构仍然从通用的模块构造而来,安排在熟悉的模式中。本书我们会讨论那些构造模块和模式。