服务的扩展性

935 阅读1小时+
原文链接: www.cnblogs.com

  在编写一个应用时,我们常常考虑的是该应用应该如何实现特定的业务逻辑。但是在逐渐发展出越来越多的用户后,这些应用常常会暴露出一系列问题,如不容易增大容量,容错性差等等。这常常会导致这些应用在市场的拓展过程中无法快速地响应用户的需求,并最终失去商业上的先机。

  通常情况下,我们将应用所具有的用来避免这一系列问题的特征称为非功能性需求。相信您已经能够从字面意义上理解这个名词了:功能性需求用来提供对业务逻辑的支持,而非功能性需求则是一系列和业务逻辑无关,却可能影响到产品后续发展的一系列需求。这些需求常常包括:高可用性(High Avalibility),扩展性(Scalability),维护性(Maintainability),可测试性(Testability)等等。

  而在这些非功能性需求中,扩展性可能是最有趣的一种了。因此在本文中,我们将对如何编写一个具有高可扩展性的应用进行讲解。

什么是扩展性

  假设我们编写了一个Web应用,并将其置于共有云上以向用户提供服务。该应用的创意非常新颖,并在短时间内就吸引了大量的用户。但是由于我们在编写该应用时并没有期望它来处理这么多用户的请求,因此它的运行速度越来越慢,甚至可能出现服务没有响应的情况。频繁发生这种事情的结果就是,用户将无法忍受该应用经常性地宕机,并将寻找其它类似应用来获得类似的服务。

  该应用所缺少的能够根据负载来对处理能力进行适当扩展的能力便是应用的扩展性,而其衡量的标准则是处理能力扩展的简单程度。如果您的应用在添加了更多内存后就能运行得更好,或者通过添加一个额外的服务实例就能解决服务实例过载的问题,那么我们就可以说该应用的扩展性非常好。如果为了处理更多的负载而不得不重写整个应用,那么应用的开发者就需要在多多注意应用的扩展性了。

  较好的扩展性不仅可以省却您重写应用的麻烦,更重要的是,它会帮助您在市场的争夺中获得先机。试想一下,如果您的应用已经出现了处理能力不够的苗头,却没有适当的解决方案来提高整个系统的处理能力,那么您能做的事情只能是重新编写一个具有更高处理能力的具有同一个功能的应用。在该段时间内,您的应用的处理能力显得越来越捉襟见肘。而体现在客户层面上的,则是您的应用的响应速度越来越慢,甚至有时都无法正常工作。在新应用上线之前,您的应用将逐渐地流失客户。而这些流失的客户则很有可能变成类似软件的忠实客户,从而使得您的产品失去了市场竞争的先机。反过来,如果您的应用具有非常良好的扩展性,而您的竞争对手并没有跟上用户的增长速度,那么的应用就有了完全超越甚至压制竞争对手的可能。

  当然,一个成功的应用不应该仅仅拥有高扩展性,而是应该在一系列非功能性需求上都做得很好。例如您的应用不应该有太多的Bug,也不应该有特别严重的Bug,以避免由于这些Bug导致您的用户无法正常使用应用。同时您的应用需要拥有较好的用户体验,这样才能让这些用户非常容易地熟悉您的应用,并产生用户粘性。

  当然,这些非功能性需求并不仅仅局限在用户的角度。例如从开发团队的角度来讲,一个软件的可测试性常常决定了测试组的工作效率。如果一个应用需要在几十台机器上逐一安装部署,那么每次测试人员对新版本的验证都需要几个小时甚至成天的时间才能准备完毕。测试组也就很自然地成为了该软件开发组中效率最为低下的一部分。为此我们就需要招入大量的测试人员,大大地增加了应用的整体开销。

  总的来说,一个应用所具有的非功能性需求非常多,如完整性(Completeness),正确性(Correctness),可用性(Availability),可靠性(Reliability),安全(Security),扩展性(Scalability),性能(Performance)等等。而这些需求都会对如何分析,设计以及编码提出一定的要求。不同的非功能性需求所提出的要求常常会发生冲突。而到底哪个非功能性需求更为重要则需要根据您所编写的应用类型来决定。例如在编写一个大规模Web应用的时候,扩展性,安全以及可用性较为重要,而对于一个实时应用来说,性能以及可靠性则占据上风。在这篇文章中,我们的讨论将主要集中在扩展性上。因此其所提出的一系列建议可能会对其它的非功能性需求产生较大的影响。而到底如何取舍则需要读者根据应用的实际情况自行决定。

应用的扩展方法

  好的,让我们重新回到扩展性这个话题上来。导致一个软件需要扩展的最根本原因实际上还是其所需要面对的吞吐量。在用户的一个请求到达时,服务实例需要对它进行处理并将其转化为对数据的操作。在这个过程中,服务实例以及数据库都需要消耗一定的资源。如果用户的请求过多从而导致应用中的某个组成所无法应对,那么我们就需要想办法提高该组成的数据处理能力。

  提高数据处理能力的方法主要分为两类,那就是纵向扩展及横向扩展。而这两种方法所对应的操作就是Scale Up以及Scale Out。

  纵向扩展表示在需要处理更多负载时通过提高单个系统处理能力的方法来解决问题。最简单的情况就是为该系统提供更为强大的硬件。例如如果数据库所在的服务器实例只有2G内存,进而导致了数据库不能高效地运行,那么我们就可以通过将该服务器的内存扩展至8G来解决这个问题:

  上图所展示的就是通过添加内存进行纵向扩展,以解决数据库所在服务实例IO过高的情况:当运行数据库服务的服务器所包含的内存不能加载数据库中所存储的最为常见的数据时,其会不断地从硬盘中读取持久化到磁盘中的内存页面,从而导致数据库的性能大幅下降。而在将服务器的内存扩展到8G的情况下,那些常用数据就能够长时间地驻留在内存中,从而使得数据库所在服务实例的磁盘IO迅速回复正常。

  除了通过硬件方法来提高单个服务实例的性能之外,我们还可以通过优化软件的执行效率来完成应用的纵向扩展。最简单的示例就是,如果原有的服务实现只能使用单线程来处理数据,而不能同时利用服务器实例中所包含的多个CPU核心,那么我们可以通过将算法更改为多线程来充分利用CPU的多核计算能力,成倍地提高服务的执行效率。

  但是纵向扩展并非总是最正确的选择。影响我们选择的最常见因素就是硬件的成本。我们知道,硬件的价格通常与该硬件所处的定位有关。如果一个硬件是当前市场上的主流配置,那么由于它已经大量出货,因此平摊的研发成本在每件硬件中已经变得非常小。反过来,如果一个硬件是刚刚投入市场的高端产品,那么每件硬件所包含的研发成本将会非常多。因此纵向扩展的投入性能比曲线常常如下所示:

  也就是说,在单个实例优化到一定程度以后,再花费大量的时间和金钱来对单个实例的性能进行提高已经没有太多的意义了。在这个时候,我们就需要考虑横向扩展,也就是使用多个服务实例来一起提供服务。

  就以一个在线的图像处理服务为例。由于图像处理是一个非常消耗资源的计算过程,因此单个服务器常常无法满足大量用户所发送的请求:

  就像上图中所展示的那样,虽然我们的服务器已经安装了4个CPU,但是在单个服务器实例提供服务的情况下,CPU使用率还是一直处于警戒线之上。如果我们再在应用中添加一个相同的服务器来共同处理用户的请求,那么每台服务器的负载将会降到原有负载的一半左右,从而使得CPU使用率保持在警戒线之下。

  在这种情况下,该服务所提供的一系列其它功能也随之得到了扩充。例如对处理结果进行保存的功能的性能也将变成原来的两倍。只是由于我们暂时并不需要这种扩充,因此该部分性能的增强实际上是毫无用处的,甚至造成了服务资源的浪费:

  从上图中可以看到,在没有横向扩展之前,橙色组成的负载已经达到了90%,接近单个服务实例的极限。为了解决这个问题,我们再引入一个服务器实例来分担工作。但是这样会导致其它几个本来资源利用率就已经不高的组成的利用率降得更低。而更为正确的扩展方式则是只扩展橙色组成:

  从上面的讲解中可以看出,横向扩展实际上包含了很多种方式。相应地,《The Art of Scalability》一书则介绍了一个横向扩展所需要遵守的AKF扩展模型。根据AKF扩展模型,横向扩展实际上包含了三个维度,而横向扩展解决方案则是这三个维度上所做工作的结合:

  上图中展示了AKF扩展模型的最通用的表示形式。在该图中,原点O表示的是应用实例并没有能力执行任何横向扩展,而只能通过纵向扩展来提高它的服务能力。如果您的系统朝着某个坐标轴的方向前进,那么它就将得到一定程度的横向扩展能力。当然,这三个坐标轴并不互斥,因此您的应用可能同时拥有XYZ三个轴向的扩展能力:

  现在就让我们来看一下AKF扩展模型中各个坐标轴的意义。首先要讲解的就是X轴。在AKF扩展模型中,X轴表示的是应用可以通过部署更多的服务实例来解决扩展性的问题。在这种情况下,原本需要少量服务实例处理的大量负载就可以通过新添加的其它服务实例分担,从而扩大了系统容量,降低了单个服务实例的压力。

  我们刚刚提到过,一个服务的扩展性可以同时由多个轴向的扩展性共同组成,因此在该服务中,这种X轴方向的扩展性不仅仅存在于服务这个层次上,更可以由子服务,甚至服务组成的扩展性来共同完成:

  请注意上图中的橙色方块。在该服务中,橙色方块作为一个子服务来向整个服务提供特定功能。在需要扩展时,我们可以通过添加一个新的橙色子服务实例来解决橙色服务负载过大的问题。因此就整个服务而言,其X轴的横向扩展能力并不是通过重新部署整套服务来完成的,而是对独立的子服务进行扩容。

  相信您会问:既然只通过添加新的服务或子服务实例就能够完成对服务容量的扩充,那么我们还需要其它两个轴向的横向扩展能力么?

  答案是肯定的。首先,最为现实的问题就是服务运行场景的约束。例如在对服务进行X轴横向扩展的时候,我们常常需要一个负载平衡服务。在《企业级负载平衡简介》一文中我们已经说过,负载平衡服务器常常具有一定的性能限制。因此横向扩展并非全无止境。除此之外,我们也看到了横向扩展有时是使用在子服务上的,而将一个大的服务分割为多个子服务,本身也是沿着其它轴向的横向扩展。

  Y轴横向扩展的意义则在于将所有的工作根据数据的类型或业务逻辑进行划分。而就一个Web服务而言,Y轴横向扩展所做的最主要工作就是将一个Monolith服务划分为一系列子服务,从而使不同的子服务独立工作并拥有独立地进行横向扩展的能力。这一方面可以将原本一个服务所处理的所有请求分担给一系列子服务实例来运行,更可以让您根据应用的实际运行情况来对某个成为系统瓶颈的子服务进行X轴横向扩展,避免由于对整个服务进行X轴横向扩展所造成的资源浪费:

  这种组织各个子服务的方式被称为Microservice。使用Microservice组织子服务还可以帮助您实现一系列其它非功能性需求,如高可用性,可测试性等等。具体内容详见《Microservice架构模式简介》一文。

  相较而言,执行Y轴扩展要比执行X轴扩展困难一些。但是其常常会使得其它一系列非功能性需求具有更高的质量。

  而在Z轴上的横向扩展可能是大家所最不熟悉的情况。其表示需要根据用户的某些特性对用户的请求进行划分。例如使用基于DNS的负载平衡。

  当然,到底您的服务需要实现什么程度的X,Y,Z轴扩展能力则需要根据服务的实际情况来决定。如果一个应用的最终规模并不大,那么只拥有X轴扩展能力,或者有部分Y轴扩展能力即可。如果一个应用的增长非常迅速,并最终演变为对吞吐量有极高要求的应用,那么我们就需要从一开始就考虑这个应用在X,Y,Z轴的扩展能力。

服务的扩展

  好了,介绍了那么多理论知识,相信您已经迫不及待地想要了解如何令一个应用具有良好的扩展性了吧。那好,让我们首先从服务实例的扩展性说起。

  我们已经在前面介绍过,对服务进行扩展主要有两种方法:横向扩展以及纵向扩展。对于服务实例而言,横向扩展非常简单:无非是将服务分割为众多的子服务并在负载平衡等技术的帮助下在应用中添加新的服务实例:

  上图展示了服务实例是如何按照AKF扩展模型进行横向扩展的。在该图的最顶层,我们使用了基于DNS的负载平衡。由于DNS拥有根据用户所在位置决定距离用户最近的服务这一功能,因此用户在DNS查找时所得到的IP将指向距离自己最近的服务。例如一个处于美国西部的用户在访问Google时所得到的IP可能就是64.233.167.99。这一功能便是AKF扩展模型中的Z轴:根据用户的某些特性对用户的请求进行划分。

  接下来,负载平衡服务器就会根据用户所访问地址的URL来对用户的请求进行划分。例如用户在访问网页搜索服务时,服务集群需要使用左边的虚线方框中的服务实例来为用户服务。而在访问图片搜索服务时,服务集群则需要使用右边虚线方框中的服务实例。这则是AKF扩展模型中的Y轴:根据数据的类型或业务逻辑来划分请求。

  最后,由于用户所最常使用的服务就是网页搜索,而单个服务实例的性能毕竟有限,因此服务集群中常常包含了多个用来提供网页搜索服务的服务实例。负载平衡服务器会根据各个服务实例的能力以及服务实例的状态来对用户的请求进行分发。而这则是沿着AKF扩展模型中的X轴进行扩展:通过部署具有相同功能的服务实例来分担整个负载。

  可以看到,在负载平衡服务器的帮助下,对应用实例进行横向扩展是非常简单的事情。如果您对负载平衡功能比较感兴趣,请查看我的另一篇博文《企业级负载平衡简介》。

  相较于服务的横向扩展,服务的纵向扩展则是一个常常被软件开发人员所忽视的问题。横向扩展诚然可以提供近乎无限的系统容量,但是如果一个服务实例本身的效能就十分低下,那么这种无限的横向扩展常常是在浪费金钱:

  就像上图中所展示的那样,一个应用当然可以通过部署4台具有同样功能的服务器来为用户提供服务。在这种情况下,搭建该服务的开销是5万美元。但是由于应用实现本身的质量不高,因此这四台服务器的资源使用率并不高。如果一个肯于动脑的软件开发人员能够仔细地分析服务实例中的系统瓶颈并加以改正,那么公司将可能只需要购买一台服务器,而员工的个人能力及薪水都会得到提升,并可能得到一笔额外的嘉奖。如果该员工为应用所添加的纵向扩展性足够高,那么该应用将可以在具有更高性能的服务器上运行良好。也就是说,单个服务实例的纵向扩展性不仅仅可以充分利用现有硬件所能提供的性能,以辅助降低搭建整个服务的花费,更可以兼容具有更强资源的服务器。这就使得我们可以通过简单地调整服务器设置来完成对整个服务的增强,如添加更多的内存,或者使用更高速的网络等方法。

  现在就让我们来看看如何提高单个服务实例的扩展性。在一个应用中,服务实例常常处于核心位置:其接受用户的请求,并在处理用户请求的过程中从数据库中读取数据。接下来,服务实例会通过计算将这些数据库中得到的数据糅合在一起,并作为对用户请求的响应将其返回。在整个处理过程中,服务实例还可能通过服务端缓存取得之前计算过程中已经得到的结果:

  也就是说,服务实例在运行时常常通过向其它组成发送请求来得到运行时所需要的数据。由于这些请求常常是一个阻塞调用,服务实例的线程也会被阻塞,进而影响了单个线程在服务中执行的效率:

  从上图中可以看到,如果我们使用了阻塞调用,那么在调用另一个组成以获得数据的时候,调用方所在的线程将被阻塞。在这种情况下,整个执行过程需要3份时间来完成。而如果我们使用了非阻塞调用,那么调用方在等待其它组成的响应时可以执行其它任务,从而使得其在4份时间内可以处理两个任务,相当于提高了50%的吞吐量。

  因此在编写一个高吞吐量的服务实现时,您首先需要考虑是否应该使用Java所提供的非阻塞IO功能。通常情况下,由非阻塞IO组织的服务会比由阻塞IO所编写的服务慢,但是其在高负载的情况下的吞吐量较非阻塞IO所编写的服务高很多。这其中最好的证明就是Tomcat对非阻塞IO的支持。

  在较早的版本中,Tomcat会在一个请求到达时为该请求分配一个独立的线程,并由该线程来完成该请求的处理。一旦该请求的处理过程中出现了阻塞调用,那么该线程将挂起直至阻塞调用返回。而在该请求处理完毕后,负责处理该请求的线程将被送回到线程池中等待对下一个请求进行处理。在这种情况下,Tomcat所能并行处理的最大吞吐量实际上与其线程池中的线程数量相关。反过来,如果将线程数量设置得过大,那么操作系统将忙于处理线程的管理及切换等一系列工作,反而降低了效率。而在一些较新版本中,Tomcat则允许用户使用非阻塞IO。在这种情况下,Tomcat将拥有一系列用来接收请求的线程。一旦请求到达,这些线程就会接收该请求,并将请求转给真正处理请求的工作线程。因此在新版Tomcat的运行过程中将只包括几十个线程,却能够同时处理成千上万的请求。当然,由于非阻塞IO是异步的,而不是在调用返回时就立即执行后续处理,因此其处理单个请求的时间较使用阻塞IO所需要的时间长。

  因此在服务少量的用户时,使用非阻塞IO的Tomcat对于单个请求的响应时间常常是Tomcat的2倍以上,但是在用户数量是成千上万个的时候,使用非阻塞IO的Tomcat的吞吐量则非常稳定:

  因此如果想要提高您的单个服务性能,首先您需要保证您在Tomcat等Web容器中正确地使用了非阻塞模式:

  

      protocol="org.apache.coyote.http11.Http11NioProtocol" redirectPort="8443"/>

  当然,使用非阻塞IO并不仅仅是通过配置Tomcat就完成了。试想在一个子服务实现中调用另一个子服务的情况:如果在调用子服务时调用方被阻塞,那么调用方的一个线程就被阻塞在那里,而不能处理其它待处理的请求。因此在您的应用中包含了较长时间的的阻塞调用时,您需要考虑使用非阻塞方式组织服务的实现。

  在使用非阻塞方式组织服务之前,您最好详细地阅读《Enterprise Integration Pattern》。Spring旗下的项目Spring Integration则是Enterprise Integration Pattern在Spring体系中的一种实现。因为它是在是一个非常大的话题,因此我会在其它博文中对它们进行简单地介绍。

  在通过使用非阻塞模式提高了并发连接数之后,我们就需要考虑是否其它硬件会成为单个服务实例的瓶颈了。首先,更大的并发会导致更大的内存占用。因此如果您所开发的应用对内存大小较为敏感,那么您首先要做的就是为系统添加内存。而且在您的内存敏感应用的实现中,内存管理也会变成您需要考虑的一项任务。虽然说很多语言,如Java等,已经通过提供垃圾回收机制解决了野指针,内存泄露等一系列问题,但是在这些垃圾回收机制启动的时候,您的服务会暂时挂起。因此在服务实现的过程中,您需要考虑通过一些技术来尽量避免内存回收。

  另外一个和硬件有关的话题可能就是CPU了。一个服务器常常包含多个CPU,而这些CPU可以包含多个核,因此在该台服务实例上常常可以同时运行十几个,甚至几十个线程。但是在实现服务时,我们常常忽略了这种信息,从而导致某些服务只能由少数几个线程并行执行。通常情况下,这都是因为服务过多地访问同一个资源,如过多地使用了锁,同步块,或者是数据库性能不够等一系列原因。

  还有一个需要考虑的事情就是服务的动静分离。如果一个应用需要提供一系列静态资源,那么那些常用的Servlet容器可能并不是一个最优的选择。一些轻量级的Web服务器,如nginx在服务静态资源时的效率就将明显高于Apache等一系列动态内容服务器。

  由于这篇文章的主旨并不是为了讲解如何编写一个具有较高性能的服务,因此对于上面所述的各种增强单个服务性能的技巧将不再进行深入讲解。

  除了从服务自身下功夫来增强一个服务实例的纵向扩展性之外,我们还有一个重要的用来提高服务实例工作效率的武器,那就是服务端缓存。这些缓存通过将之前得到的计算结果记录在缓存系统中,从而尽可能地避免对该结果再次进行计算。通过这种方式,服务端缓存能大大地减轻数据库的压力:

  那它和服务的扩展性有什么关系呢?答案是,如果服务端缓存能够减轻系统中每个服务的负载,那么它实际上相当于提高了单个服务实例的工作效率,减少了其它组成对扩容的需求,变相地增加了各个相关组成的扩展性。

  现在市面上较为主流的服务端缓存主要分为两种:运行于服务实例之上并与服务实例处于同一个进程之内的缓存,以及在服务实例之外独立运行的缓存。而后一种则是现在较为流行的解决方案:

  从上图中可以看出,由于进程内缓存与特定的应用实例绑定,因此每个应用实例将只能访问特定的缓存。这种绑定一方面会导致单个服务实例所能够访问的缓存容量变得很小,另一方面也可能导致不同的缓存实例中存在着冗余的数据,降低了缓存系统的整体效率。相较而言,由于独立缓存实例是独立于各个应用服务器实例运行的,因此应用服务实例可以访问任意的缓存实例。这同时解决了服务实例能够使用的缓存容量过小以及冗余数据这两个问题。

  如果您希望了解更多的有关如何搭建服务端缓存的知识,请查看我的另一篇博文《Memcached简介》。

  除了服务端缓存之外,CDN也是一种预防服务过载的技术。当然,它的最主要功能还是提高距离服务较远的用户访问服务的速度。通常情况下,这些CDN服务会根据请求分布及实际负载等众多因素在不同的地理区域内搭建。在提供服务时,CDN会从服务端取得服务的静态数据,并缓存在CDN之内。而在一个距离该服务较远的用户尝试使用该服务时,其将会从这些CDN中取得这些静态资源,以提高加载这些静态数据的速度。这样服务器就不必再处理从世界各地所发来的对静态资源的请求,进而降低了服务器的负载。

数据库的扩展性

  相较于服务实例,数据库的扩展则是一个更为复杂的话题。我们知道,不同的服务对数据的使用方式常常具有很大的差异。例如不同的服务常常具有非常不同的读写比,而另一些服务则更强调扩展性。因此如何对数据库进行扩展并没有一个统一的方法,而常常决定于应用自身对数据的要求。因此在本节中,我们将采取由下向上的方法讲解如何对数据库进行扩展。

  通常情况下,对一个话题自上而下的讲解常常能够形成较好的知识系统。在使用该方式对问题进行讲解的时候,我们将首先提出问题,然后再以该问题为中心讲解组成该问题的各个子问题。在讲解中我们需要逐一地解决这些子问题,并将这些子问题的解决方案进行关联和比较。通过这种方式,读者常常能够更清晰地认识到各个解决方案的优点和缺点,进而能够根据问题的实际情况对解决方案进行取舍。这种方法较为适合问题较为简单并且清晰的情况。

  而在问题较为复杂,包含情况较多的情况下,我们就需要将这些问题拆分为子问题,并在讲清楚各个子问题之后再去分析整个问题如何通过这些子问题解决方案合作解决。

  那么如何将数据库的扩展性分割为子问题呢?在决定一个数据库应该拥有哪些特性时,常常用来作为评判标准的就是CAP理论。该理论指出我们很难保证数据库的一致性(Consistency),可用性(Availability)以及分区容错性(Partition tolerance):

  因此一系列数据库都选择了其中的两个特性来作为其实现的重点。例如常见的关系型数据库主要保证的是数据的一致性及数据的可用性,而并不强调对扩展性非常重要的分区容错性。这也便是数据库的横向扩展成为业界难题的一个原因。

  当然,如果您的应用对一致性或可用性的要求并不是那么高,那么您就可以选择将分区容错性作为重点的数据库。这些类型的数据库有很多。例如现在非常流行的NoSQL数据库大多都将分区容错性作为一个实现重点。

  因此在本节中,我们将会以关系型数据库作为重点进行讲解。又由于对关系型数据库进行横向扩展常常较纵向扩展更为困难,因此我们将首先讲解如何对关系型数据库进行横向扩展。

  首先,最为常见也最为简单的纵向扩展方法就是增加关系型数据库所在服务实例的性能。我们知道,数据库在运行时会将其所包含的数据加载在内存之中,而且最常访问的数据是否存在于内存之中是数据库是否运行良好的关键。如果数据库所在的服务实例能够根据实际负载提供足够的内存,以承载所有最常被访问的数据,那么数据库的性能将得到充分地发挥。因此在执行纵向扩展的第一步就是要检查您的数据库所在的服务实例是否拥有足够的资源。

  当然,仅仅从硬件入手是不够的。在前面的章节中已经介绍过,纵向扩展需要从两个方面入手:硬件的增强,以及软件的优化。就数据库本身而言,其最重要的保证运行性能的组成就是索引。在当代的各个数据库中,索引主要分为聚簇索引以及非聚簇索引两种。这两种索引能够加速对具有特定特征的数据的查找:

  因此在数据库优化过程中,索引可以说是最为重要的一环。从上图中可以看出,如果一个查找能够通过索引来完成,而不是通过逐个查找数据库中所拥有的记录来进行,那么整个查找只需要分析组成索引的几个节点,而不是遍历数据库所拥有的成千上万条记录。这将会大大地提高数据库的运行性能。

  但是如果索引没有存在于内存中,那么数据库就需要从硬盘中将它们读取到内存中再进行操作。这明显是一个非常慢的操作。因此为了您的索引能够正常工作,您首先要保证数据库运行所在的服务实例拥有足够的内存。

  除了保证拥有足够的内存之外,我们还需要保证数据库的索引自身没有过多的浪费内存。一个最常见的索引浪费内存的情况就是Index Fragmentation。也就是说,在经过一系列添加,更新和删除之后,数据库中的数据在存储中的物理结构中将变得不再规律。这主要分为两种:Internal Fragmentation,即物理结构中可能存在着大量空白;External Fragmentation,即这些数据在物理结构中并不是有序排列的。Internal Fragmentation意味着索引所包含节点的增加。这一方面导致我们需要更大的空间来存储索引,从而占用更多的内存,另一方面也会让数据寻找所需要遍历的节点数量增加,从而导致系统性能的下降。而External Fragmentation则意味着从磁盘顺序读取这些数据时需要硬盘重新进行寻址等操作,也会显著降低系统的执行性能。还有一个需要考虑的有关External Fragmentation的问题则是是否我们的服务与其它服务使用了共享磁盘。如果是,那么其它服务对于磁盘的使用会导致External Fragmentation的问题无法从根本上解决,巡道操作将常常发生。

  另外一个常用的对索引进行优化的方法就是在非聚簇索引中通过INCLUDE子句包含特定列,以加快某些请求语句的执行速度。我们知道,聚簇索引和非聚簇索引的差别主要就存在于是否包含数据。如果从聚簇索引中执行数据的查找,那么在找到对应的节点之后,我们就已经可以从该节点中得到需要查找的数据。而如果我们的查找是在非聚簇索引中进行的,那么我们得到的则是目标数据所在的位置。为了找到真正的数据,我们还需要进行一次寻址操作。而在通过INCLUDE子句包含了所需要数据的情况下,我们就可以避免这次寻址,进而提高了查找的性能。

  但是需要注意的是,索引是数据库在其本身所拥有的数据之外额外创建的数据结构,因此其实际上也需要占用内存。在插入及删除数据的时候,数据库同样需要维护这些索引,以保证索引和实际数据的一致性,因此其会导致数据库插入及删除操作性能的下降。

  还有一个需要考虑的则是通过正确地设置Fill Factor来尽量避免Page Split。在常见的数据库中,数据是记录在具有固定大小的页中。当我们需要插入一条数据的时候,目标页中的可用空间可能已经不足以再添加一条新的数据。此时数据库会添加一个新的页,并将数据从一个页分到这两个页中。在该过程中,数据库不仅仅要添加及修改数据页本身,更需要对IAM等页进行更改,因此是一个较为消耗资源的操作。FillFactor是一个用来控制在叶页创建时每页所填充的百分比的全局设置。在设置了FillFactor的基础之上,用户还可以设置PAD_INDEX选项,来控制非叶页也使用FillFactor来控制数据的填充。一个较高的FillFactor会使数据更加集中,由此拥有更高的读取性能。而一个较低的FillFactor则对写入较为友好,因为其防止了Page Split。

  除了上面所述的各种方法之外,您还可以通过其它一系列数据库功能来提高性能。这其中最重要的当然是各个数据库所提供的执行计划(Execution Plan)。通过执行计划,您可以看到您正在执行的请求是如何被数据库执行的:

  由于如何提高单个数据库的性能是一个庞大的话题,而我们的文章主要集中在如何提高扩展性,因此我们在这里不再对如何提高数据库的执行性能进行详细的介绍。

  反过来,由于单个服务器的性能毕竟有限,因此我们并不能无限地对关系型数据库进行纵向扩展。因此在必要条件下,我们需要考虑对关系型数据库进行横向扩展。而将AKF横向扩展模型施行在关系型数据库之上后,其各个轴的意义则如下所示:

  现在就跟我来看看各个轴的含义。在AKF模型中,X轴表示的是应用可以通过部署更多的服务实例来解决扩展性的问题。而由于关系型数据库要管理数据的读写并保证数据的一致性,因此在X轴上的扩展将不能简单地通过部署额外的数据库实例来解决问题。在进行X轴扩展的时候,这些数据库实例常常拥有不同的职责并组成特定的拓扑结构。这就是数据库的Replication。

  而相较于X轴,数据库AKF模型中的Y轴和Z轴则较为容易理解。AKF模型中的Y轴表示的是将所有的工作根据数据的类型或业务逻辑进行划分,而Z轴则表示根据用户的某些特性对用户的请求进行划分。这两种划分实际上都是要将数据库中的数据划分到多个数据库实例中,因此它们对应的则是数据库的Partition。

  让我们先看看数据库的Replication。简单地说,数据库的Replication表示的就是将数据存储在多个数据库实例中。读请求可以在任意的数据库实例上执行,而一旦某个数据库实例上发生了数据的更新,那么这些更新将会自动复制到其它数据库实例上。在数据复制的过程中,数据源被称为Master,而目标实例则被称为Slave。这两个角色并不是互斥的:在一些较为复杂的拓扑结构中,一个数据库实例可能既是Master,又是Slave。

  在关系型数据库的Replication中,最为常见的拓扑模型就是简单的Master-Slave模型。在该模型中,对数据的读取可以在任意的数据库实例上完成。而在需要对数据进行更新的时候,数据将只能写入特定的数据库实例。此时这些数据的更改将沿着单一的方向从Master向Slave进行传递:

  在该模型中,数据读取的工作是由Master和Slave共同处理的。因此在上图中,每个数据库的读负载将是原来的一半左右。但是在写入时,Master和Slave都需要执行一次写操作,因此各个数据库实例的写负载并没有降低。如果读负载逐渐增大,我们还可以加入更多的Slave节点以分担读负载:

  相信您现在已经清楚了,关系型数据库的横向扩展主要是通过加入一系列数据库实例来分担读负载来完成的。但是有一点需要注意的是,这种写入传递关系是靠Master和Slave中的一个独立的线程来完成的。也就是说,一个Master拥有多少个Slave,它的内部就需要维持多少个线程来完成对属于它的Slave的更新。由于在一个大型应用中常常可能包含上百个Slave实例,因此将这些Slave都归于同一个Master将导致Master的性能急剧下降。

  其中一个解决方法就是将其中的某些Slave转化为其它Slave的Master,并将它们组织成为一个树状结构:

  但是Master-Slave模型拥有一个缺点,那就是有单点失效的危险。一旦作为Master的数据库实例失效了,那么整个数据库系统,至少是以该Master节点为根的子系统将会失效。

  而解决该问题的一种方法就是使用多Master的Replication模型。在该模型中,每个Master数据库实例除了可以将数据同步给各个Slave之外,还可以将数据同步给其它的Master:

  在这种情况下,我们避免了单点失效的问题。但是如果两个数据库实例对同一份数据更新,那么它们将产生数据冲突。当然,我们可以通过将对数据的划分为毫不相干的多个子集并由每个Master节点负责某个特定子集的更新的方式来防止数据冲突。

  从上图中可以看到,用户对数据的写入会根据特定条件来分配到不同的数据库实例上。接下来,这些写入会同步到其它实例上,从而保持数据的一致性。但是既然我们能将这些数据独立地切割为各个子集,那么我们为什么不去尝试一下数据库的Partition呢?

  简单地说,数据库的Partition就是将数据库中需要记录的数据划分为一系列子集,并由不同的数据库实例来记录这些数据子集所包含的数据。通过这种方法,对数据的读取以及写入负载都会根据数据所在的数据库实例来进行划分。而这也就是数据库沿AKF扩展模型的Y轴进行横向扩展的方法。

  在执行数据库的Partition时,数据库原有的数据将被切分到不同的数据库实例中。每个数据库实例将只包含原数据库中几个表的数据,从而将对整个数据库的访问切分到不同的数据库实例中:

  但是在某些情况下,对数据库中的数据按表切分并不能解决问题。切分完毕后的某个数据库实例仍然可能承担了过多的负载。那么此时我们就需要将该数据库再次切分。只是这次我们所切分的是数据库中的数据行:

  在这种情况下,我们在对数据进行操作之前首先需要执行一次计算来决定数据所在的数据库实例。

  然而数据库的Partition并不是没有缺点。最常见的问题就是我们不能通过同一条SQL语句操作不同数据库实例中记录的数据。因此在决定对数据库进行切分之前,您首先需要仔细地检查各个表之间的关系,并确认被分割到不同数据库中的各个表没有过多的关联操作。

  好了。至此为止,我们已经讲解了如何创建具有可扩展性的服务实例,缓存以及数据库。相信您已经对如何创建一个具有高扩展性的应用有了一个较为清晰的认识。当然,在撰写本文的过程中,我也发现了一系列可以继续讲解的话题,如Spring Integration,以及对数据库Replication以及Partition(Sharding)的讲解。在有些方面(如数据库),我并不是专家。但是我会尽我所能把本文所写的知识点一一陈述清楚。

转载请注明原文地址并标明转载:www.cnblogs.com/loveis715/p…

商业转载请事先与我联系:silverfox715@sina.com


  在之前的一篇文章《放好你的密码 - 从芝麻金融被攻破说起》中,一位读者在评论中提出了“如果整个过程速度比较慢登陆会有问题”这样一条评论。虽然说我对文章的正确性很有把握,但也仍需要仔细思考是否自己哪里没有说清楚。在这个思考过程中,我想起了一个非常值得一说的话题,那就是负载平衡。

  在那篇文章中我们说到,要安全地管理好密码,计算密码哈希所使用的迭代次数应该尽可能地大,从而使得单次哈希计算的速度变长,增加恶意人员破解密码的难度。反过来,如果有一百个人或者一千个人同时执行登陆操作,那么这么繁琐的哈希计算将导致登陆服务器产生“忙不过来”的情况。此时我们就需要使用负载平衡将登陆请求分散到多个登陆服务器上,以降低单个服务器的负载。

负载平衡简介

  或许有些读者仍然对负载平衡这个名词感到陌生,那么我们就花一小段篇幅来讲解一下到底什么是负载平衡。

  在一个大型网站中,在线用户有时可能有几千个甚至上万个之多。如果一个用户的请求需要服务使用0.02秒来处理,那么该服务实例每秒钟将只能处理50个这样的请求,每分钟也只能处理3000个。如果该服务是一个用户非常常用的功能,如浏览网站的产品列表,那么很显然单个服务实例已经无法支持该网站的运营。在这种情况下,我们就需要对该服务进行扩容。

  扩容主要分为Scale Up和Scale Out两种,分别对应着增强单个服务的服务能力以及增强服务数量。在某些情况下,Scale Up是一个较为简单的操作,例如为该服务所在的服务器添加更大的内存。但是任意一个服务器所能提供的能力实际上都受到其自身物理硬件能力的限制,尤其是具有越高性能的服务器其单位服务能力的价格越为昂贵,因此我们就需要使用Scale  Out方式将工作量分摊到多个服务器之中:

  就如上图所显示的那样,当服务器的负载过多而来不及处理的时候,服务器就处于一种过载的状态。在该状态的服务常常会出现响应速度慢甚至无响应的情况。而在执行了Scale Out之后,我们将会使用多个服务器同时处理用户的请求。在该解决方案中,我们需要使用一台特定的设备将这些请求分发到各个服务器。该设备会根据其内部所包含的请求分发逻辑来决定如何对这些请求进行分发,以避免出现单个服务器过载的情况。这些用来对请求进行分发的设备实际上就是负载平衡服务器。

  当然,我们不会等到服务器真正过载了才去解决这个问题。在服务的日常运维中,我们在服务器的平均负载和峰值负载达到某个特定阈值时就需要考虑是否需要为相应服务进行扩容。

  一旦一个服务使用了负载平衡系统,那么它将在高可用性以及扩展性上得到极大的增强。这也是我们使用负载平衡解决方案的最重要原因。例如对于一个拥有三台服务器的负载平衡系统,如果其中一台发生了故障,那么负载平衡服务器可以通过向各个服务发送心跳等方式得知它们的异常,进而不再向这个发生了故障的服务器分发任务:

  而如果当前负载平衡系统中所负担的服务容量已经超过了阈值,那么我们可以简单地通过在负载平衡系统中添加服务器来解决这个问题:

  这样,每个服务器所需要处理的任务就相对减少了,从而减轻了单个服务器的负担。

基于DNS的负载平衡

  OK,在了解了负载平衡系统的大致组成及使用方式之后,我们就来看看各种负载解决方案。

  当前业界中所最常使用的负载平衡解决方案主要分为三种:基于DNS的负载平衡,L3/4负载平衡,也即是基于网络层的负载平衡,以及L7负载平衡,即基于应用层的负载平衡。在这些解决方案中,基于DNS的负载平衡是最简单的,也是最早出现的一种负载平衡解决方案。

  当我们通过在浏览器的地址栏中键入域名来访问某个网站时,浏览器将首先查找本地的DNS缓存是否拥有该域名所对应的IP地址。如果有,那么浏览器将尝试直接使用该IP地址访问该网站的内容。如果本地DNS缓存中没有该域名所对应的IP地址,那么它将向DNS发送一个请求,以获得该域名所对应的IP并添加到本地DNS缓存中。

  而在DNS中,一个域名可能和多个IP地址绑定。在这种情况下,DNS响应将会按照Round Robin方式返回这些IP地址的列表。例如在多次通过nslookup或host等命令来查看特定域名所对应的IP时,其可能的返回如下(因国内网络原因,您需要FQ再进行试验):

  $ host -t a google.com

  google.com has address 72.14.207.99

  google.com has address 64.233.167.99

  google.com has address 64.233.187.99

  $ host -t a google.com

  google.com has address 64.233.187.99

  google.com has address 72.14.207.99

  google.com has address 64.233.167.99

  可以看到,不同的DNS请求所返回的结果会按照Round Robin进行轮换,进而使得不同的用户访问不同的IP地址,平衡各个服务器的负载。

  虽然这种负载平衡解决方案非常容易实现,但是它有一个致命的缺点:为了减少DNS请求的次数以提高访问效率,浏览器常常缓存了DNS查询的结果。如果一个IP处的服务失效,那么浏览器可能仍会根据DNS缓存中所记录的信息向该不可用的服务发送请求(不同的浏览器可能有不同的行为)。虽然说整个服务只有一处IP所对应的服务失效了,但是从用户的角度看来该网站已经不可访问。因此基于DNS的负载平衡方案并不能作为一个独立的负载平衡解决方案来提供高可用性的保障,而是作为其它负载平衡解决方案的补充方案来使用。

L3/4负载平衡

  另一种较为常见的负载平衡则是L3/4负载平衡。这里的L3/4实际上指的就是负载平衡服务器会根据OSI模型中的第三层网络层(Network Layer)和第四层传输层(Transport Layer)所包含的数据来进行负载平衡操作。在这种负载平衡服务器中,这些数据主要包含数据包的IP头和TCP、UDP等协议的协议头:

  L3/4负载平衡服务器的工作原理非常简单:在数据到达时,负载平衡服务器将根据自身算法以及OSI模型三四层所包含的数据决定需要处理该数据的服务实例并将其转发。

  整个负载平衡的运行包含三方面内容:负载平衡服务器需要知道当前有效的服务实例到底有哪些,并根据自身的分派算法来决定需要处理数据的服务实例,根据分派算法的计算结果将数据发送到目标服务实例上。

  首先来看看负载平衡服务器是如何确定服务实例的有效性的。为了能够保证从负载平衡服务器所派发的数据包能被它后面的服务器集群正常处理,负载平衡服务器需要周期性地发送状态查询请求以探测到底哪些服务实例正在有效地工作。这种状态查询请求常常会超出很多人的认知:如果服务实例崩溃但是承载它的操作系统正常工作,那么该操作系统仍然会正常响应负载平衡服务器所发出的Ping命令,只是此时TCP连接会失败;如果服务实例并没有崩溃,而只是挂起,那么它仍然可以接受TCP连接,只是无法接收HTTP请求。

  由于这种状态查询请求实际上是特定于服务实例的具体实现,因此很多负载平衡服务器都允许用户添加自定义脚本以执行特定于服务实例的查询。这些状态查询请求常常包含了很多测试,甚至会尝试从服务实例中返回数据。

  一旦负载平衡服务器发现其所管理的某个服务实例不再有效,那么它就不会再将任何数据转发给该服务实例,直到该服务实例回归正常状态。在这种情况下,其它各个服务实例就需要分担失效服务器所原本承担的工作。

  这里需要注意的一点是,在某个服务实例失效以后,整个系统仍应该具有足够的总容量以处理负载。举例来说,假如一个负载平衡服务器管理了三个具有相同能力的服务实例,而且这三个服务实例各自的负载都在80%左右。如果其中一个服务实例失效,那么所有的负载都需要由其它两个服务实例来完成。每个服务实例就需要承担120%的负载,远超过了它所具有的负载能力。这种情况的直接后果就是,服务显得非常不稳定,并常常有系统超时,应用无法正常工作的情况出现。

  OK。 现在假设我们的负载平衡服务器有一个设计良好的状态查询请求,那么它就会根据其所使用的负载平衡算法来为工作的服务实例分配负载。对于初次接触到负载平衡功能的人来说,最常见的误区就是认为负载平衡服务器会根据各个服务实例的响应速度或负载状况来决定请求需要到达的服务实例。

  通常情况下,Round Robin算法是最常用也是表现最好的负载平衡算法。如果各个服务实例的容量并不相同,那么负载平衡服务器会使用Weighted Round Robin算法,即根据各个服务实例的实际能力来安比例地分配负载。在某些商业负载平衡服务器中,其的确会根据当前服务实例的负载以及响应时间等因素对这些分配比例自动进行微小地调整,但是它们并不是决定性的因素。

  如果单纯地使用Round Robin算法,那么具有关联关系的各个请求将可能被分配到不同的服务实例上。因此很多负载平衡服务器允许根据数据的特定特征对这些负载进行分配,如使用一种哈希算法来对用户所在的IP进行计算,并以计算结果决定需要分配到的服务实例。

  同样地,我们也需要考虑某个服务器实例失效的情况。如果负载平衡系统中的某个服务器实例失效,那么哈希算法中的哈希值空间将发生变化,进而导致原本的服务实例分配结果将不再有效。在这种情况下,所有的请求将重新分配服务器实例。另外,在某些情况下,用户的IP也可能在各个请求之间发生变化,进而导致它所对应的服务实例发生更改。当然,不用担心,后面对L7负载平衡服务器的讲解会给您一个解决方案。

  在确定了数据包的目标地址后,负载平衡服务器所需要做的事情就是将它们转发到目标实例了。负载平衡服务器所使用的转发方式主要分为三种:Direct routing,Tunnelling以及IP address translation。

  在使用Direct routing方式的时候,负载平衡服务器以及各个服务实例必须在同一个网段上并使用同一个IP。在接收到数据的时候,负载平衡服务器将直接对这些数据包进行转发。而各个服务实例在处理完数据包之后可以将响应返回给负载平衡服务器,也可以选择将响应直接发送给用户,而不需要再经过负载平衡服务器。后一种返回方式被称为Direct Server Return。其运行方式如下所示:

  在该过程中,负载平衡服务器和各个服务实例都不需要对IP(Internet Protocol)层数据进行任何更改就可以对其进行转发。使用这种转发方式的负载平衡服务器的吞吐量非常高。反过来,这种组织方式也要求集群的搭建人员对TCP/IP等协议拥有足够多的理解。

  另一种转发方式Tunnelling实际上与Direct routing类似。唯一的一点不同则是在负载平衡服务器和各个服务之间建立了一系列通道。软件开发人员仍然可以选择使用Direct Server Return来减轻负载平衡服务器的负载。

  IP Address Translation则与前两种方式非常不同。用户所连接的目标地址实际上是一个虚拟地址(VIP,Virtual IP)。而负载平衡服务器在接到该请求的时候将会将其目标地址转化为服务实例所在的实际地址(RIP,Real IP),并将源地址更改为Load Balancer所在的地址。这样在对请求处理完毕后,服务实例将会把响应发送到负载平衡服务器。此时负载平衡服务器再将响应的地址更改为VIP,并将该响应返回给用户。在这种转发方式下,其运行流程则如下所示:

  有些细心的读者会问:在消息传递的过程中,用户所在的User IP已经不在消息中存在了,那负载平衡服务器在传回响应的时候应该如何恢复用户的IP地址呢?实际上在这种转发方式中,负载平衡服务器会维持一系列会话,以记录每个经由负载平衡服务器的正在处理的各个请求的相关信息。但是这些会话非常危险。如果将会话持续的时间设置得比较长,那么在一个高并发的负载平衡服务器上就需要维护过多的会话。反之如果将会话持续的时间设置得过短,那么就有可能导致ACK Storm发生。

  先看会话持续时间较长的情况。假设当前负载平衡服务器每秒钟会接收到50000个请求,而且该负载平衡服务器的会话过期时间为2分钟,那么其就需要保持6000000个会话。这些会话会占用负载平衡服务器的很大部分资源。而且在负载高峰期,其所消耗的资源可能会成倍地增长,会向服务器施加更多的压力。

  但是将会话持续时间设置得比较短则更为麻烦。这会导致用户和负载平衡服务器之间产生ACK Storm,占用用户和负载平衡服务器的大量带宽。在一个TCP连接中,客户端和服务端需要通过各自的Sequence Number来进行沟通。如果负载平衡服务器上的会话快速地失效,那么其它TCP连接就有可能重用该会话。被重用的会话中客户端和服务端的Sequence Number都会被重新生成。如果此时原有的用户再次发送消息,那么负载平衡服务器将通过一个ACK消息通知客户端其拥有的Sequence Number出错。而在客户端接受到该ACK消息之后,其将向负载平衡服务器发送另一个ACK消息通知服务端所拥有的Sequence Number出错。服务端接受到该ACK消息后,将再次发送ACK消息到客户端通知其所拥有的Sequence Number出错……这样客户端和服务端之间就将持续地发送这种无意义的ACK消息,直到某个ACK消息在网络传输过程中丢失为止。

  因此乍一看来,使用IP Address Translation的方案是最容易的,但是相较于其它两种方案,它却是最危险也是维护成本最高的一种方案。

L7负载平衡

  另一种较为常用的负载平衡解决方案则是L7负载平衡。顾名思义,其主要通过OSI模型中的第七层应用层中的数据决定如何分发负载。

  在运行时,L7负载平衡服务器上的操作系统会将接收到的各个数据包组织成为用户请求,并根据在该请求中所包含的的数据来决定由哪个服务实例来对该请求进行处理。其运行流程图大致如下所示:

  相较于L3/4负载平衡服务所使用的数据,L7负载平衡服务所使用的应用层数据更贴近服务本身,因此其具有更精确的负载平衡行为。

  在前面对L3/4负载平衡的讲解中我们已经介绍过,对于某些具有关联关系的各个请求,L3/4负载平衡服务器会根据某些算法(如计算IP的哈希值)来决定处理该请求的服务实例。但是这种方法并不是很稳定。当一个服务实例失效或用户的IP发生变化的时候,用户与服务实例之间的对应关系就将发生改变。此时用户原有的会话数据在新的服务实例上并不存在,进而导致一系列问题。

  其实产生这个问题的最根本原因就是用户与服务实例之间的关联关系是通过某些外部环境创建的,而并非由用户/服务实例本身来管理。因此它不能抵御外部环境变化的冲击。如果要在用户和服务实例之间建立稳定的关联关系,那么就需要一种稳定的在用户和服务实例之间传递的数据。在Web服务中,这种数据就是Cookie。

  简单地说,基于Cookie的负载平衡服务实际上就是分析用户请求中的某个特定Cookie并根据其值决定需要分发到的目标地址。其主要分为两种方式:Cookie  Learning以及Cookie Insertion。

  Cookie Learning是不具有侵入性的一种解决方案。其通过分析用户与服务实例通讯过程中所传递的Cookie来决定如何分派负载:在用户与服务第一次通讯时,负载平衡服务将找不到相应的Cookie,因此其将会把该请求根据负载平衡算法分配到某个服务实例上。而在服务实例返回的时候,负载平衡服务器将会把对应的Cookie以及服务实例的地址记录在负载平衡服务器中。当用户再次与服务通讯时,负载平衡服务器就会根据Cookie中所记录的数据找到前一次服务该用户的服务实例,并将请求转发到该服务实例上。

  这么做的最大缺点就是对高可用性的支持很差。如果一旦负载平衡服务器失效,那么在该负载平衡服务器上所维护的Cookie和服务实例之间的匹配关系将全部丢失。这样当备份负载平衡服务器启动之后,所有的用户请求都将被定向到随机的服务实例。

  而另一个问题就是会话维护功能对内存的消耗。与L3/4服务器上的会话维护不同,一个Cookie的失效时间可能非常长,至少在一次用户使用中可能会持续几个小时。对于一个访问量达到每秒上万次的系统而言,负载平衡服务器需要维护非常多的会话,甚至可能会将服务器的内存消耗殆尽。反过来,如果将负载平衡服务器中的Cookie过期时间设置得太短,那么当用户重新访问负载平衡服务器的时候,其将被导向到一个错误的服务实例。

  除了Cookie Learning 之外,另一种常用的方法就是Cookie Insertion。其通过向响应中添加Cookie以记录被分派到的服务实例,并在下一次处理请求时根据该Cookie所保存的值来决定分发到的服务实例。在用户与服务器进行第一次通讯的时候,负载平衡服务器将找不到分派记录所对应的Cookie,因此其会根据负载平衡算法为该请求分配一个服务实例。在接收到服务实例所返回的数据之后,负载平衡服务器将会向响应中插入一个Cookie,以记录该服务实例的ID。当用户再次发送请求到负载平衡服务器时,其将根据该Cookie里所记录的服务实例的ID派发该请求。

  相较于Cookie Learning,Cookie Insertion不需要在内存中维护Cookie与各个服务实例的对应关系,而且在当前负载平衡服务器失效的时候,备用负载平衡服务器也可以根据Cookie中所记录的信息正确地派发各个请求。

  当然,Cookie Insertion也有缺陷。最常见的问题就是浏览器以及用户自身对Cookie的限制。在Cookie Insertion中,我们需要插入一个额外的Cookie 来记录分配给当前用户的服务实例。但是在某些浏览器中,特别是移动浏览器中,常常会限制Cookie的个数,甚至只允许出现一个 Cookie。为了解决这个问题,负载平衡服务器也会使用一些其它方法。如Cookie Modification,即修改一个已有的Cookie使其包含服务实例的ID。

  而在用户禁用了Cookie的时候,Cookie Insertion将是完全失效的。此时负载平衡服务所能利用的将仅仅是JSESSIONID等信息。因此在一个L7负载平衡服务器中,Cookie Learning和Cookie Insertion常常同时使用:Cookie Learning会在用户启用Cookie的时候起主要作用,而在Cookie被用户禁用的情况下则使用Cookie Learning来根据JSESSIONID来保持用户与服务实例之间的关联关系。

  或许您会想:L3/4负载平衡服务器在处理各个关联请求的时候是通过IP的哈希值来决定处理该请求的服务实例的。既然这些基于Cookie的解决方案能达到100%的准确,为什么我们不在L3/4负载平衡服务器中使用它们呢?答案是:由于L3/4负载平衡服务器主要关注于数据包级别的转发,而Cookie信息则藏匿于数据包之中,因此L3/4负载平衡服务器很难决定单一的数据包该如何转发。

  例如在执行Cookie Insertion操作的时候,原有数据包中的所有数据都将被后移。此时需要负载平衡服务器接收到所有数据包之后才能完成:

  试想一下接收所有数据包所可能发生的事情吧。在网络的一端发送多个数据包的时候,网络的另一端所接收到的数据包的顺序可能与原有的发送顺序并不一致。甚至在网络拥堵的时候,某些数据包可能会丢失,进而再次加长接收到所有数据包所需要的时间。

  因此相较于将数据包直接转发的方法,等待所有的数据包到齐然后再插入Cookie的性能非常差。在后面对于解决方案的讲解中您会看到,L3/4负载平衡服务器对于性能的要求一般来说是很高的,而L7负载平衡服务器则可以通过一个集群来解决自身的性能问题。基于DNS的负载平衡,L3/4负载平衡服务器以及L7负载平衡服务器常常协同工作,以组成一个具有高可用性以及高可扩展性的系统。

SSL Farm

  在上面的讲解中,我们忽略了一个事情,那就是L7负载平衡服务器对于SSL的支持。在L7负载平衡服务器中,我们常常需要读写请求及响应中的Cookie。但是如果通讯使用的是SSL连接,那么L7负载平衡服务器将无法对请求及响应的内容进行读写操作。

  解决该问题所曾经使用的一个解决方案就是:将负载平衡服务器以反向代理的方式使用。在这种方案中,负载平衡服务器将拥有服务的证书,并可以通过证书中的密钥对请求进行解密。解密完成后,负载平衡服务器就可以开始尝试读取Cookie中的内容并根据其所记录的信息决定该请求所需要派发到的服务实例。在对该请求进行派发的时候,负载平衡服务器可以不再使用SSL连接,进而使得各个服务实例不再需要再次解密请求,提高服务实例的运行效率。

  在请求处理完毕之后,服务实例将通过服务实例与负载平衡服务器的非SSL连接返回一个响应。在负载平衡服务器接收到该响应之后,其将会把该响应加密并通过SSL连接发出:

  但是这样做的问题在于,如果所有对SSL的处理都集中在L7负载平衡服务器上,那么它将会变成系统的瓶颈。绕过该问题的方法就是在L7负载平衡服务器之前使用一系列反向代理来负责SSL的编解码操作。

  此时整个系统的架构将呈现如下的层次结构:

  从上图中可以看到,整个解决方案分为了四层。在用户的请求到达了第一层的负载平衡服务器时,其将会把该请求根据自身的负载平衡算法转发给处于第二层的专门负责SSL编解码工作的反向代理。该代理会将传入的由SSL连接所传输的请求由非SSL连接传出。在请求到达第三层时,L7负载平衡服务器可以直接访问这些请求所包含的Cookie,并根据Cookie中的内容决定需要处理该请求的服务实例。

  这么做的好处有很多。首先就是这些反向代理非常便宜,甚至只有常见负载平衡服务器的1/20左右的价格,却在处理SSL连接上拥有几乎相同的效率。除此之外,这些反向代理还提供了非常良好的扩展性和高可用性。一旦负载平衡系统在处理SSL连接的能力上显得有些吃力,我们就随时可以向系统中添加新的反向代理。而一旦其中一个反向代理失效,那么其它反向代理可以通过多承担一些负载来保证系统的安全运行。

需要考虑的问题

  在提出具体的负载平衡解决方案之前,我们需要首先讲解一下在设计负载平衡系统时我们所需要考虑的一些事情。

  首先要说的就是要在负载平衡系统设计时留意它的高可用性及扩展性。在一开始的讲解中,我们就已经提到过通过使用负载平衡,由众多服务器实例所组成的服务具有很高的可用性及扩展性。当其中一个服务实例失效的时候,其它服务实例可以帮助它分担一部分工作。而在总服务容量显得有些紧张的时候,我们可以向服务中添加新的服务实例以扩展服务的总容量。

  但是由于所有的数据传输都需要经过负载平衡服务器,因此负载平衡服务器一旦失效,那么整个系统就将无法使用。也就是说,负载平衡服务器的可用性影响着整个系统的高可用性。

  解决这个问题的方法要根据负载平衡服务器的类型来讨论。对于L3/4负载平衡服务器而言,为了能够让整个系统不失效,业界中的常用方法是在系统中使用一对负载平衡服务器。当其中一个负载平衡服务器失效的时候,另一个还能够为整个系统提供负载平衡服务。这一对负载平衡服务器可以按照Active-Passive模式使用,也可以按照Active-Active模式使用。

  在Active-Passive模式中,一个负载平衡服务器处于半休眠状态。其将会通过向另外一个负载平衡服务器发送心跳消息来探测对方的可用性。当正在工作的负载平衡服务器不再响应心跳的时候,那么心跳应用将会把负载平衡服务器从半休眠状态唤醒,接管负载平衡服务器的IP并开始执行负载平衡功能。

  而在Active-Active模式中,两台负载平衡服务器会同时工作。如果其中一台服务器发生了故障,那么另一台服务器将会承担所有的工作:

  可以说,两者各有千秋。相较而言,Active-Active模式具有较好的抵抗访问量大幅波动的情况。例如在通常情况下,两个服务器的负载都在30%左右,但是在服务使用的高峰时间,访问量可能是平时的两倍,因此两个服务器的负载就将达到60%左右,仍处于系统可以处理的范围内。如果我们使用的是Active-Passive模式,那么平时的负载就将达到60%,而在高峰时间的负载将达到负载平衡服务器容量的120%,进而使得服务无法处理所有的用户请求。

  反过来,Active-Active模式也有不好的地方,那就是容易导致管理上的疏忽。例如在一个使用了Active-Active模式的系统中,两个负载平衡服务器的负载常年都在60%左右。那么一旦其中的一个负载平衡服务器失效了,那么剩下的唯一一个服务器同样将无法处理所有的用户请求。

  或许您会问:L3/4负载平衡服务器一定要有两个么?其实主要由各负载平衡服务器产品自身来决定的。在前面我们已经讲过,实际上探测负载平衡服务器的可用性实际上需要很复杂的测试逻辑。因此如果一旦我们在一个负载平衡系统中使用了过多的L3/4负载平衡服务器,那么这些负载平衡服务器之间所发送的各种心跳测试将消耗非常多的资源。同时由于很多L3/4负载平衡服务器本身是基于硬件的,因此它能够非常快速地工作,甚至可以达到与其所支持的网络带宽所匹配的处理能力。因此在一般情况下,L3/4负载平衡服务器是成对使用的。

  如果L3/4负载平衡服务器真的接近其负载极限,那么我们还可以通过DNS负载平衡来分散请求:

  这种方法不仅仅可以解决扩展性的问题,更可以利用DNS的一个特性来提高用户体验:DNS可以根据用户所在的区域选择距离用户最近的服务器。这在一个全球性的服务中尤为有效。毕竟一个中国用户访问在中国架设的服务实例要比访问在美国架设的服务实例快得多。

  反过来由于L7负载平衡服务器主要是基于软件的,因此很多L7负载平衡服务器允许用户创建较为复杂的负载平衡服务器系统。例如定义一个具有两个启用而有一个备用的一组L7负载平衡服务器。

  讲解完了高可用性,我们就来介绍一下负载平衡服务器的扩展性。其实在上面我们刚刚介绍过,L3/4负载平衡服务器拥有很高的性能,因此一般的服务所使用的负载平衡系统不会遇到需要扩展性的问题。但是一旦出现了需要扩展的情况,那么使用DNS负载平衡就可以达到较好的扩展性。而L7负载平衡则更为灵活,因此扩展性更不是问题。

  但是一个负载平衡系统不可能都是由L3/4负载平衡服务器组成的,也不可能只由L7负载平衡服务器组成的。这是因为两者在性能和价格上都具有非常大的差异。一个L3/4负载平衡服务器实际上价格非常昂贵,常常达到上万美元。而L7负载平衡服务器则可以使用廉价服务器搭建。L3/4负载平衡服务器常常具有非常高的性能,而L7负载平衡服务器则常常通过组成一个集群来达到较高的整体性能。

  在设计负载平衡系统时,还有一点需要考虑的那就是服务的动静分离。我们知道,一个服务常常由动态请求和静态请求共同组成。这两种请求具有非常不同的特点:一个动态请求常常需要大量的计算而传输的数据常常不是很多,而一个静态的请求常常需要传输大量的数据而不需要太多的计算。不同的服务容器对这些请求的表现差异很大。因此很多服务常常将其所包含的服务实例分为两部分,分别用来处理静态请求和动态请求,并使用适合的服务容器提供服务。在这种情况下,静态请求常常被置于特定的路径下,如“/static”。这样负载平衡服务器就可以根据请求所发送到的路径而将动态请求和静态请求进行适当地转发。

  最后要提到的就是L3/4负载平衡服务器的一个软件实现LVS(Linux Virtual Server)。相较于硬件实现,软件实现需要做很多额外的工作,例如对数据包的解码,为处理数据包分配内存等等呢个。因此其性能常常只是具有相同硬件能力的L3/4负载平衡服务器的1/5到1/10。鉴于其只具有有限的性能但是搭建起来成本很低,如利用已有的在Lab里闲置的机器等,因此其常常在服务规模不是很大的时候作为临时替代方案使用。

负载平衡解决方案

  在文章的最后,我们将给出一系列常见的负载平衡解决方案,以供大家参考。

  在一般情况下,一个服务的负载常常是通过某些方式逐渐增长的。相应地,这些服务所拥有的负载平衡系统常常是从小到大逐渐演化的。因此我们也将会按照从小到大的方式逐次介绍这些负载平衡系统。

  首先是最简单的包含一对L7负载平衡服务器的系统:

  如果服务的负载逐渐增大,那么该系统中唯一的L7负载平衡服务器很容易变成瓶颈。此时我们可以通过添加一个SSL Farm以及运行LVS的服务器来解决问题:

  如果我们还要应对增长的负载,那么就需要使用真正的基于硬件的L3/4负载平衡服务器来替代LVS,并增加各层的容量:

  由于该解决方案的下面三层基本都有理论上无限的扩展性,因此最容易出现过载的就是最上面的L3/4负载平衡服务器。在这种情况下,我们就需要使用DNS来分配负载了:

转载请注明原文地址并标明转载:www.cnblogs.com/loveis715/p…

商业转载请事先与我联系:silverfox715@sina.com