尽管你很精心的“烹制”你的应用程序,但是随着负载的增加,所有灾难都将降临。当然你可以使用横向扩展或纵向扩展,但是你同样可以更好的进行编程,让你的系统可以支撑更大的负载。这会给你节约成本,因为可以减少所添加的服务器数量,同样还可以提高整个应用程序的可靠性和响应速度。同时,这也应该是优秀工程师的分内之事。
下面一睹Tod Hoff在 High Scalabilty上带来的42怪兽军团峥嵘:
大量的对象
一旦对象数量太多,我们都会面临扩展问题。显然随着对象数量的剧增,可以为各种类型对象使用的资源将愈加捉襟见肘。
故障得不到恢复会导致无限的事件流
在大型网络故障的情景下,不会存在任何时间做系统恢复,系统将一直处于重负之下。
大量的高优先级工作
举个例子,路由的重定向就是个高优先级活动。如果存在大量既不可以被卸载又不可以被降级的路由重定向,资源将不断的被消耗,用于支撑这些高优先级工作。
数据流增大
随着数据体积的增大,系统负载将加重。随着请求源的增多,系统负载将加重。
功能蔓延(Feature Creep)
随着更多超过预期的特性添加,系统中的漏洞将会出现。
客户端的剧增
更多客户端意味着更多资源的占用。更多的线程被创建用于驱动事件。更多的内存在客户端请求队列上被占用,更多网络带宽被用于通信,每个客户端数据更需要专门的维护。
不完善的设计决策
不完善的设计可能会导致扩展隐患。
假设是无效的
大量无效的假设,比如:预期中的内存使用、某个操作/处理会持续多长时间、什么地方会产生响应超时、某个时间点会消耗多少资源、会发生什么类型的失败、系统中不同点的延时、请求队列的长度等等。
内存不足
随着负载的增加:系统的基本使用内存和峰值使用内存都会增加,这可能会导致OOM。
CPU饥饿(CPU Starvation)
更多的对象需要更长的时间去处理,因为它们必须要对更多的对象进行操作。这将降低CPU的有效性,同时其它的操作也可能会饿死。一个地方的饥饿会导致另一个地方也发生类似的情况。也可能会出现没有足够的CPU去处理需要立刻执行的工作,这可能是因为工作数量太多或者是特定情况下需要做大量优先级高的工作。
原始的资源使用增加
更多的对象占用更多的资源。如果有人希望支持1000万个对象,你可能就无法实现,因为你根本没有足够的内存。
隐式的资源使用量增加
大部分特性都需要耗费原始资源之外更多的资源,比如:对比之前你只将对象储存在一个表中,你现在将对象存入了两个表,那么耗费的资源将是之前的两倍。同样还可能会在以下方面耗费更多的资源:队列的长度、磁盘空间、二次数据拷贝增加的时间、将数据加载到应用程序中的时间、用于处理这些操作的CPU使用率、引导时间等等。
只知道疯狂的压榨CPU,对系统无真正的认知
无限工作流在许多新型系统中都有实现。Web服务器和应用程序服务器支撑着非常大的用户群体,不断的新工作会带来无限的事件流。基于7×24小时永无止境的新工作,CPU使用率很容易的就会被推倒100%。
通常情况下100%的CPU使用率会被当作一个不好的标志。为了应对,我们建立了复杂的基础设施、机器集群、冗余用于负载的平衡。
因为CPU不会说它很累,所以你可能一直让其处于满载状态。在服务器领域中,我们为了得到需要的响应时间通常会尽可能的压榨CPU;想法就是:如果我们不让CPU最高效率的运转,新的工作可能就会得不到理想的延时,旧的工作也不可能尽快的完成。
然而一直将CPU压榨到100%真的没问题吗?真正的问题就在于我们使用CPU有效性和任务优先级的方法:在系统架构中我们只对系统做简单的认知,而不是弄懂系统的低等级工作流,然后使用这个信息制定合适的调度决策。
除了基于负载平衡服务器做笨拙的架构决策,以及猜测会使用到的线程数量及这些线程的优先次序,我们并没有使用工具做任何对架构有益的事。
扩展一个系统首先要对其做深刻的认知,而当前的框架很少有说明应用程序的运行机制。
延时增加
随着负载增加(规模变大),延时可能和你想象的完全不同。CPU饥饿是导致这个问题的主要原因。
错误的任务优先级
低负载下制定的优先级方案可能完全不适用于高负载情况。这种情况在差的流程机制下尤为明显:给高优先级的任务指定一个低的优先级,会导致速度下降以及增加内存使用,因为低优先级任务得到运行的机会很小。
队列长度不够
大量的对象意味着更多的同步操作,从而队列的长度将远过于前。
引导时间变长
更多的对象将需要更长的时间从磁盘加载到内存中,因此引导时间必然变长。
同步时间变长
对象越多,应用程序越需要更长的时间去完成相互之间的同步。
大场景下的测试不够
随着结构变大,测试步骤所需要的开销将越来越大,所以在大型结构下测试的时间很少。在开发过程中你不可能使用到大型系统,就如同开始时你的系统并没有扩展。
操作需要更长的时间
如果某个操作是针对所有对象的,那么随着对象增多其必然会花费更长的时间。表格同样会变大,所以在之前查询很快速的表格,对象变多后也会变慢。
更多的随机错误
可能会出现正常操作中不会遇见的某些错误。ARP请求、文件系统,消息、响应等都会出现你预期不到的故障。
为错误打开了更大的窗户
规模的变大意味着错误有更多的发生机会,因为所有操作都要花费更长的时间。小数据集的数据交换可能会很快结束,这就意味着超时或重启只有很小的概率发生;然而在你对系统进行扩展的同时,你还扩宽了错误进入的大门,所以一些错误将首次进入你的眼帘。
超时适应不了扩展
任何设定在小数据集上的超时随着数据集变大都将失去作用。加上CPU饥饿问题,你的代码甚至得不到任何机会运行,初始的超时设定就更加无效了。
重试适应不了扩展
在错误发生之前没有任何途径给应用程序选择一个合适的重试次数,因为没有足够的信息让你做这项决策。1秒4次的重试真的有用?为什么不是20次重试?
优先级继承
长时间使用大范围锁将出现优先级继承问题。
内存消耗模式的改变会耗尽节点资源
在一种规模下,你可以取得发生器中的所有数据;而在另一种规模下,你可能就会耗尽队列空间或者是内存。举个例子:在轮询下一个队列之前,使用轮询器轮询一个远程队列中所有数据源;在队列中排队项很少时,这会工作的很好;但是一个特性的改变就可以增加远程队列中的排队项数量,而轮询器就会耗尽一个节点上的所有资源。
监控器超时
100% CPU使用可能会导致监控器超时。在低负载系统上可能会很少出现,然而高负载系统上则会经常出现。
内存泄露加速
随着系统扩展度增加,你不曾重视的缓慢内存泄露可能会增加到你不会相信的速度。
被遗忘的锁再次出现
放错位置的锁,在低扩展系统下可能不会引起注意,然而在接到指令前这个可能永远占用着CPU的线程在高扩展情况下就可能产生问题。在高扩展系统中可能会出现更多的抢占,这就意味着不同线程同时访问数据的机会增多。
死锁的可能性增加
不同的调度模式通过不同的路径使用代码,这就给死锁的出现创造了更多的机会。举个例子:某个文件系统会因为CPU的使用率过高而得不到运行,恰好的是这个文件系统在运行过程中莫名其妙的中断并且占用了100%的CPU使用率,这样的话它永远都不会得到运行。
被破坏的时钟同步
时钟同步并不具备很高的优先级,所以当CPU和网络资源变得紧张时,不同点上的时钟将不再保持一致。
记录器(Logger)丢失数据
如果队列的长度不足以支撑负载或者是CPU没有时间分给记录器去发送记录数据,那么将会出现记录器丢失数据的情况。取决于队列的类型和长度,这可能导致OOM。
无法完成在预期的时间启动计时器(timer)
一个繁忙的系统可能无法在预期的时间启动计时器,这可能在系统的其它部分造成一连串的延时。
ARP失效
在高CPU使用率或者高网络负载情况下,ARP可能会在机器之间失去作用。这意味着表格在修改之前(可能永远都不会),数据包可能发给了错误的机器。
文件描述(File Descriptor)符限制
通常情况下,每台主机上的描述符数量都存在一个固定的限制,而设计的限制数量必须低于主机默认的限制数量。如果发生描述符池泄露,一个拥有大量连接(ftp、booting、clients等)的设计将会出现问题。随着负载增加,描述符的数量有可能会达到一个峰值,描述符池泄露可能耗尽描述符池的可用性。
Socket缓冲限制
每个Socket都拥有预分配大小的缓冲空间,大量的Socket会减少可用内存的数量。随着负载的增加,可能会出现空间不足的情况。这同样与优先级有关,因为一个任务可能没有足够的优先级去读取Socket内的数据;同样在发送方,一个高优先级的任务可能会淹没在一个低优先级任务的不断消息请求中。
启动镜像服务限制
每个节点同时服务的镜像节点数量是有限制的,FTP服务器基础设施必须限制服务的节点数量,否则将出现CPU饥饿。
无序的信息
重压下的消息系统可能会发送无序的消息,这可能会促成非幂等性操作,从而造成大量的问题。
协议相关
必须谨慎的定制你的应用程序协议,否则随着程序的扩展,将会带来大把问题。
连接限制
将某种服务器作为中心服务器:在连接10台客户端时,可能会有优异的表现;然而连接的客户端数量变成100时,可能就会适应不了对响应的需求。这种情况下,响应时间可能会随客户端数量的增加呈线性增高,我们称之为O(N)复杂度,同样也可能会出现其它复杂性的问题。比如,我们需要一个网络中的N个节点都可以相互通信:如果我们将它们都连接到一个通信枢纽的话,需要O(N)条电缆;但是如果我们把它们做相互连接的话,则需要O(N^2)条电缆。
分层架构
这里有一个很好的总结,所以此处只做简单的叙述:基于分层的架构永远都与低延时、高吞吐量的程序绝缘,问题在于分层架构本身就是用于处理历史数据。在客户端的时代到互联网时代的过渡中,分层架构确实是解决可扩展性的不二选择。
先前问题的关键是如何对应用程序进行扩展,让其可以支撑数十万的用户。当然现在我们已经知道这个问题的答案就是N层架构,扩展性通过表示层上的负载均衡器实现。事实上,它确实解决了这个问题;然而随着问题的衍变,当下许多行业需要考虑的不仅是扩展用户体验,还必须考虑数据的体积问题。
多重处理器的性能问题
当处理器被要求在巨量不相干任务中切换时,硬件缓存加速可能会失效。
负载加重的笼罩下的“怪兽军团”已现,开发者又该如何完成程序的设计,才能让程序既易于扩展又具备高可靠性?下面来看一下High Scalability上的一篇姐妹文—— “7条法则以应对负载怪兽的袭击”,当然这都是在程序低扩展等级编写下需要注意的事项:
1. 限制资源使用的比例
这在实现扩展的应用程序中可能是最重要的规则,可以这么认为:
一些例子:
2. Merge Aggregation
在Merge Aggregation中,独立的数据和/或命令聚合到一处,用的是规则1的思想。
举个例子,如果一个对象中包含了以下几个命令序列:
这三个分开的请求,可以融合到一处。如果有个循环运行了这个请求100次,那么我们的队列中始终只有一个请求。
另一个例子是属性修改事件,独立的改变也可以融合到一处。想象一下这样做的益处有多大,队列的长度永远都不会超过对象的数量,不管触发了多少事件。完成这一点需要通信子系统的配合,所以必须确保相对智能。
3. Delete Aggregation
在Delete Aggregation中,数据和/或请求在允许的情况下将会被删除。比如,做以下两个操作:
在这个聚合中,许多create和delete操作将会被删除,大量的资源将被释放。
4. Batch Aggregation
定时分析的批处理将会把大量的数据整合在一起,从而大幅度的提高性能。逐个的进行操作永远比批量的处理来的慢。理念上应用程序不需要手动的做批处理业务,比如:框架会帮助你完成这个聚合。
5. Change Aggregation
在Change Aggregation中,所有的改变都将会被聚合到一处。当然这与Merge Aggregation不同,在Change Aggregation中我们注意到的是对象/数据改变的状态,而不是它被改变了多少次;取代为每次改变做记录,我们将把它的值发送给一个客户端,然后告诉它事情已经改变了。因为在大型系统中,我们不可能为事物的每次改变做记录。
6. Integration Aggregation
在Integration Aggregation中,事件只有在它存在过一个周期被关闭后才会被建立。
最常见的例子。一个警报只有在聚合周期结束后才被设置。当硬件发生问题等其它情况,我们可以建立一个alarm storm。
7. 卸载
卸载同样是个扩展方案,在这里工作将会被拒绝直到有足够的资源去运行它。
举个例子,在一个呼叫处理系统(call processing system)中,呼叫的数量将会被限制。任何在限制之后的出现的呼叫都会被拒绝,这将会给现有的呼叫足够的处理时间。
一些其它的卸载例子:
写在最后
当然一篇文章不可能包括负载加重后出现的所有问题,也不可能包括所有的应对方案。但是可以肯定是对自己系统做足够的认知,基于充足的信息做好设计决策,才能减少系统在扩展后所带来的问题。(文/仲浩 审校/王旭东)