当然,到底您的服务需要实现什么程度的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容器中正确地使用了非阻塞模式:
<Connector connectionTimeout="20000" maxThreads="1000" port="8080"
protocol="org.apache.coyote.http11.Http11NioProtocol" redirectPort="8443"/>