可扩展性
即使现在系统工作稳定,但这并不意味着它未来一定可靠。 降级的一个常见原因是负载增加:或许该系统已经从10,000个并发用户增长到100,000个并发用户,也或者从100万个到1000万个; 也许它正在处理比以前更多的数据。
可扩展性是我们用来描述系统应对增加能力的术语加载。 但是,请注意它不是我们可以附加到系统的一维标签:说“X可扩展”或“Y不扩展”是毫无意义的。相反,讨论可扩展性意味着考虑如下问题:“如果系统以特定方式增长,我们应对增长的方案有哪些?“和”我们如何增加计算资源来处理额外的负载?“。
负载
首先,我们需要简洁地描述系统当前的负载; 只有这样我们才能讨论增长问题(如果我们的负荷加倍,会发生什么?)。 负载可以被描述为几个我们称之为加载参数的数字。 参数的最佳选择取决于系统的体系结构:它可能是每秒向网络服务器发送的请求数,数据库中读取与写入的比率,在聊天室中同时活跃的用户数量,缓存中的命中率或其他内容。 也许平均情况对你来说很重要,亦或者你的瓶颈主要是少数极端情况引起的。
为了使这个想法更具体,让我们以Twitter为例,使用2012年11月出版的数据。 Twitter的两项主要业务是:
发布推文
用户可以向他们的追随者发布一条新消息(平均每秒发送4.6k个请求,峰值超过12k次/秒)。
Home timeline
用户可以查看他们关注的人发布的推文(每秒300k个请求)。
简单地处理每秒12,000次写入(发布推文的最高速率)是相当容易。 然而,Twitter的主要挑战并不是推特量,而是由于每个用户关注了很多人,并且每个用户都被很多人关注。 实现这两种操作的方法大致有两种:
发布推文只需将新推文插入推文的全局集合即可。当用户请求他们的home timeline时,查找他们关注的所有人,找到每个用户的所有推文,并合并它们(按时间排 序)。 在一个像图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
为每个用户的home timeline维护一个缓存 - 就像给每个收件人用户一个推文邮箱(见图1-3)。 当用户发布推文时,查询所有关注此用户的人,并将新推文插入到他们每个人的home timeline缓存。查询home timeline的请求代价更小,因为它的结果已经被提前计算。
图1-2. 用于实现 Twitter home timeline 的简单关系模式 图1-3。 Twitter用于向关注者传递推文的数据管道Twitter的第一个版本使用方法1,但系统只能查询home timeline的负载,所以公司切换到方法2效果更好,因为发布的推文的平均比率几乎是两个数量级幅度低于home timeline读数的速率,所以在这种情况下,可以在写时间上做更多的工作,而在读操作上做得更少。
然而,方法2的缺点是发布推文现在需要很多额外的工作。 平均而言,每个推文需发送约75个关注者,因此每个推文需要4.6k个推文其次成为home timeline高速缓存的每秒345k次写入。 但是这个平均值隐藏了每个用户的关注者数量变化很大的事实,一些用户拥有超过3000万的关注者。这意味着一条推文可能会导致超过30条百万写入home timeline!为及时做到这一点 - Twitter试图在5秒内向关注者发送推文 - 而这是一项重大挑战。
在Twitter的例子中,每个用户的关注者分布(也许是这些用户推特的频率加权)是讨论可扩展性的关键负载参数,因为它确定扇出(fan-out)负载。你的应用程序可能有非常不同的特征,但是你可以应用类似的原则来推理它的负载。
推特轶事的最后一个转折:现在,方法2得到了强有力的实施,Twitter正在转向两种方法的混合。大多数用户的推文仍然存在当他们发布的时候,他们在home timeline上展开,但只有一小部分拥有大量追随者(即名人)的用户不在此扇出(fan-out)。用户可能关注的任何名人的推文都是单独提取的,在查询时与该用户的home timeline合并,如方法1能够持续提供良好的性能。在我们讨论了更多的技术基础之后,我们将在第12章重新讨论这个例子。
性能
一旦你描述了系统的负载,当负载增加时,你可以调查发生了什么。 你可以用两种方式来查看它:
增加加载参数并保留系统资源(CPU,memory,网络带宽等)不变,您的系统性能如何受影响?
增加负载参数时,如果你想保持性能不变,你需要增加多少资源?
这两种方式都需要性能数字,所以让我们简单看一下描述系统的性能。
在像Hadoop这样的批处理系统中,我们通常关心吞吐量 - 我们每秒可以处理的记录数,或者在一定大小的数据集上运行作业所需的总时间。在在线系统中,通常更重要的是服务的响应时间 - 也就是客户端发送请求和接收响应之间的时间。
延迟和响应时间
延迟和响应时间通常用作同义词,但它们并不相同。 响应时间是客户看到的:除了处理请求的实际时间(服务时间)外,还包括网络延迟和排队延迟。 延迟是请求等待处理的持续时间 - 在此期间它是潜在的、等待的服务。
即使你只是一次又一次地提出相同的请求,每次尝试都会得到一个稍微不同的响应时间。 实际上,在处理各种请求的系统中,响应时间可能会有很大差异。 因此,我们需要将响应时间视为不是一个单一的数字,而是作为您可以衡量的价值分布。
在图1-4中,每个灰色条代表对服务的请求,其高度显示该请求需要多长时间。 大多数请求的速度相当快,但偶尔出现的异常值需要更长的时间。 也许缓慢的请求本质上更昂贵,例如,因为它们处理更多的数据。 但即使在您认为所有请求都在同一时间的情况下,您也会得到不同的结果:随机附加延迟可能是由于上下文切换到后台进程,网络数据包丢失和TCP重新传输,垃圾回收暂停,强制从磁盘读取的页面错误,服务器机架中的机械振动或许多其他原因。
图1-4. 说明平均值和百分位数:对服务的100个请求样本的响应时间通常会看到报告的服务平均响应时间。 (严格地说,术语“平均”并不是指任何特定的公式,但实际上它通常被理解为算术平均值:给定n个值,将所有值相加并除以n)。然而,平均值,如你知道的“典型”响应时间,它并不是一个很好的指标,因为它不能告诉你有多少用户实际上经历了这种延迟。
通常使用百分位数更好。 如果您将响应时间列表从最快到最慢排序,那么中位数就是中间点:例如,如果您的中位响应时间为200毫秒,这意味着一半请求的响应时间不到200毫秒,而另一半的请求则需要更长的时间。
如果您想知道用户通常需要等待多长时间,那么使用中位值成为一个很好的度量标准:用户请求的一半服务时间少于中间响应时间,另一半服务时间比中间值长。 中位数也被称为第50百分位,有时缩写为p50。 请注意,中位数是指单个请求; 如果用户提出了多个请求(在一个会话过程中,或者由于多个资源包含在单个页面中),至少其中一个请求比中间值慢的概率远远大于50%。
为了弄清楚你的异常值有多差,你可以看看更高的百分位数:第95,99和99.9百分位数是常见的(缩写p95,p99和p999)。它们是响应时间阈值,其中95% 99%或99.9%的请求比特定阈值更快。 例如,如果第95百分位响应时间为1.5秒,则意味着100个请求中的95个需要少于1.5秒,并且100个请求中的5个需要1.5秒或更多。 如图1-4所示。
响应时间的高百分比(也称为尾部延迟)非常重要,因为它们直接影响用户的服务体验。 例如,亚马逊以99.9%的百分比描述了内部服务的响应时间要求,尽管它仅影响1,000个请求中的1个。 这是因为具有最慢请求的客户往往是那些拥有他们账户数据最多的客户,因为他们进行了大量的采购,也就是说,他们是最有价值的客户。 通过确保网站对他们来说是快速的,让这些客户满意是很重要的:亚马逊还观察到,响应时间增加100毫秒会使销售量减少1%,另一些人则报告说1秒的减速会降低16%的客户满意度。
另一方面,优化第99.99个百分点(10000个请求中最慢的1个)被认为太昂贵,并且不能为亚马逊带来足够的好处。 通过减少响应时间而达到非常高的百分位比非常困难,因为它们很容易受到您控制之外的随机事件的影响,并且益处正在减弱。
例如,百分比通常用于服务级别目标(SLO)和服务级别协议(SLA),即定义服务的预期性能和可用性的合约。 SLA可能会声明,如果服务的中值响应时间少于200毫秒,并且第99百分位低于1秒(如果响应时间更长,则可能会下降),则该服务可能会被启动,并且该服务可能需要至少有99.9%的时间。 这些指标为服务的客户设定了期望值,并允许客户在未达到SLA时要求退款。
排队延迟通常在高百分点的响应时间中占很大比例。 由于服务器只能并行处理少量事务(例如,受其CPU核数量限制),因此只需要少量缓慢请求即可处理后续请求,这种效果有时称为线头阻塞(head-of-line blocking)。 即使这些后续请求在服务器上快速处理,由于等待事先请求完成的时间,客户端将看到总体响应时间较慢。 由于这种影响,测量客户端的响应时间非常重要。
当为了测试系统的可扩展性而人为生成负载时,负载生成客户端需要不断响应请求,而不受响应时间的影响。 如果客户端在发送下一个请求之前等待先前的请求完成,那么这种行为会在测试中人为地保持队列的长度比实际上更短,这会扭曲了测量结果。
图1-5. 当需要多个后端调用来提供服务时,它只需要一个缓慢的后端请求来减慢整个最终用户请求百分位的实践
作为服务单个最终用户请求的一部分,多次调用后端服务时,高百分位数变得尤为重要。 即使您并行进行呼叫,最终用户请求仍然需要等待最慢的并行呼叫完成。 如图1-5所示,只需一个缓慢的呼叫就可以使整个最终用户请求变慢。 即使只有一小部分后端呼叫速度较慢,如果最终用户请求需要多个后端呼叫,则获得慢速呼叫的机会也会增加,因此较高比例的最终用户请求最终会变慢(效果称为尾部延迟放大)。
如果您希望将响应时间百分点添加到您的服务的监视仪表板,则需要持续有效地计算它们。 例如,您可能希望在最近10分钟内保持请求响应时间的滚动窗口。 每分钟,您都会计算该窗口中值的中位数和各种百分位数,并将这些度量值绘制在图上。
最真实的实现是在时间窗口内保存所有请求的响应时间列表,并且每分钟对该列表进行排序。 如果对您来说效率太低,那么有些算法可以以最小的CPU和内存成本(如正向衰减,t-digest或HdrHistogram)计算百分位数的近似值。 请注意,平均百分比(例如,减少时间分辨率或合并多台机器的数据)在数学上毫无意义-聚合响应时间数据的正确方法是添加直方图。
应对负荷
现在我们已经讨论了描述用于衡量性能的负载和度量的参数,我们可以开始认真讨论可扩展性:即使我们的负载参数增加了一定数量,我们如何保持良好的性能?
适用于一个级别的负载体系结构不太可能应付10倍的负载。 如果您正在开发一项快速增长的服务,那么您可能需要重新考虑每个数量级负载增加的架构,或者甚至更多。
人们经常谈论放大(垂直缩放,移动到更强大的机器)和扩大(水平缩放,将负载分配到多个更小的机器)之间的二分法。 在多台机器上分配负载也称为无共享体系结构。 可以在单台机器上运行的系统通常更简单,但高端机器可能会变得非常昂贵,因此非常密集的工作负载通常无法避免向外扩展。 实际上,良好的体系结构通常涉及一种务实的方法:例如,使用几台功能相当强大的机器仍可能比大量小型虚拟机更简单,更便宜。
有些系统具有弹性,这意味着他们可以在检测到负载增加时自动添加计算资源,而其他系统则是手动扩展(人工分析容量并决定向系统添加更多机器)。 如果负载高度不可预测,则弹性系统可能非常有用,但手动缩放的系统更简单并且可能具有更少的操作意外(请参阅第209页的“重新平衡分区”)。
在多台机器上分发无状态服务非常简单,从单个节点到分布式设置将有状态的数据系统带入了很多额外的复杂性。 出于这个原因,当前普遍的做法是将数据库保持在单个节点上(扩展),直到缩放成本或高可用性要求迫使您将其分发。
随着分布式系统的工具和抽象变得越来越好,这种常识可能会改变,至少对于某些类型的应用程序而言。 可以想象,分布式数据系统将成为未来的默认设置,即使对于不处理大量数据或流量的用例也是如此。 在本书其余部分的过程中,我们将介绍多种分布式数据系统,并讨论它们不仅在可扩展性方面的表现,还包括易用性和可维护性。
大规模运行的系统体系结构通常对应用程序非常具体,不存在通用的可扩展体系结构(非正式地称为魔术垢酱)。 问题可能是读取量,写入量,要存储的数据量,数据的复杂程度,响应时间要求,访问模式或(通常)所有这些的混合物以及更多问题。
例如,设计用于处理每秒100,000个请求(每个大小为1 kB)的系统与为每分钟3个请求(每个大小为2 GB)设计的系统看起来大不相同,即使这两个系统具有相同的数据吞吐量。
对于某个特定应用来说,扩展性很好的体系结构是围绕着哪些操作是常见的假设而建立的,哪些操作很少见 - 即负载参数。 如果这些假设结果是错误的,那么缩放的工程努力至多是浪费的,最坏的情况是适得其反。 在早期阶段的初创公司或未经证实的产品中,能够快速迭代产品功能比扩展到假设的未来负载更重要。
尽管它们特定于特定的应用程序,但可扩展架构通常是从通用构建模块构建而成,并以熟悉的模式排列。 在本书中,我们将讨论这些构件和模式。
网友评论