当前位置: 代码迷 >> J2EE >> 转:构建可扩展的Java EE施用
  详细解决方案

转:构建可扩展的Java EE施用

热度:504   发布时间:2016-04-22 01:10:19.0
转:构建可扩展的Java EE应用

转:构建可扩展的Java EE应用
2011年05月21日
  原文地址: http://www.theserverside.com/news/1320914/Scaling- Your-Java-EE-Applications-Part-2     对于一个具备使用价值的应用而言,其使用者有可能会在一段时间内疯狂的增长。随着越来越多的关键性质的应用在Java EE上运行,很多的Java开发者也开始关注可扩展性的问题了。但目前来说,大部分的web 2.0站点是基于script语言编写的,对于Java应用可扩展能力,很多人都抱着质疑的态度。在这篇文章中,Wang Yu基于他本身在实验室项目的经验来展示如何构建可扩展的java应用,同时,基于一些在可扩展性上做的比较失败的项目给读者带来构建可扩展java应用的实践、理论、算法、框架和经验。
  我一直为一家互联网性质的实验室工作,这个实验室采用我们公司最新的大型服务器环境为合作伙伴的产品和解决方案免费做性能测试,我工作的部分就是帮助他们在强大的CMT和SMP服务器上进行性能调优。
  这些年来,我已经为不同的解决方案测试了数十种java应用。许多的产品都是为了解决同样的领域问题,因此这些产品的功能基本都是类似的,但在可扩展性上表现的却非常不同,其中有些不能扩展到64 CPU的服务器上运行,但可以扩展到20台服务器做集群运行,有些则只能运行在不超过2 CPU的机器上。
  造成这些差别的原因在于设计产品时的架构愿景,所有的具备良好扩展性的java应用从需求需求阶段、系统设计阶段以及实现阶段都为可扩展性做了考虑,所以,你所编写的java应用的可扩展能力完全取决于你的愿景。
  可扩展性作为系统的属性之一,是个很难定义的名词,经常会与性能混淆。当然,可扩展性和性能是有关系的,它的目的是为了达到高性能。但是衡量可扩展性和性能的方法是不一样的,在这篇文章中,我们采用wikipedia中的定义:
  可扩展性是系统、网络或进程的可选属性之一,它表达的含义是可以以一种优雅的方式来处理不断增长的工作,或者以一种很明白的方式进行扩充。例如:它可以用来表示系统具备随着资源(典型的有硬件)的增加提升吞吐量的能力。
  垂直扩展的意思是给系统中的单节点增加资源,典型的是给机器增加CPU或内存,垂直扩展为操作系统和应用模块提供了更多可共用的资源,因此它使得虚拟化的技术(应该是指在一台机器上运行多个虚拟机)能够运行的更加有效。
  水平扩展的意思是指给系统增加更多的节点,例如为一个分布式的软件系统增加新的机器,一个更清晰的例子是将一台web服务器增加为三台。随着计算机价格的不断降低以及性能的不断提升,以往需要依靠超级计算机来进行的高性能计算的应用(例如:地震分析、生物计算等)现在可以采用这种多个低成本的应用来完成。由上百台普通机器构成的集群可以达到传统的基于RISC处理器的科学计算机所具备的计算能力。
  这篇文章的第一部分来讨论下垂直扩展Java应用。
  如何让Java EE应用垂直扩展
  很多的软件设计人员和开发人员都认为功能是产品中最重要的因素,而性能和可扩展性是附加的特性和功能完成后才做的工作。他们中大部分人认为可以借助昂贵的硬件来缩小性能问题。
  但有时候他们是错的,上个月,我们实验室中有一个紧急的项目,合作伙伴提供的产品在他们客户提供的CPU的机器上测试未达到性能的要求,因此合作伙伴希望在更多CPU(8 CPU)的机器上测试他们的产品,但结果却是在8 CPU的机器上性能反而比4 CPU的机器更差。
  为什么会这样呢?首先,如果你的系统是多进程或多线程的,并且已经用尽了CPU的资源,那么在这种情况下增加CPU通常能让应用很好的得到扩展。
  基于java技术的应用可以很简单的使用线程,Java语言不仅可以用来支持编写多线程的应用,同时JVM本身在对java应用的执行管理和内存管理上采用的也是多线程的方式,因此通常来说Java应用在多CPU的机器上可以运行的更好,例如Bea weblogic、IBM Websphere、开源的Glassfish和Tomcat等应用服务器,运行在Java EE应用服务器中的应用可以立刻从CMT和SMP技术中获取到好处。
  但在我的实验室中,我发现很多的产品并不能充分的使用CPU,有些应用在8 CPU的服务器上只能使用到不到20%的CPU,像这类应用即使增加CPU也提升不了多少的。
  热锁(Hot Lock)是可扩展性的关键障碍
  在Java程序中,用来协调线程的最重要的工具就是 synchronized这个关键字了。由于java所采用的规则,包括缓存刷新和失效,Java语言中的synchronized块通常都会其他平台提供的类似的机制更加的昂贵。即使程序只是一个运行在单处理器上的单线程程序,一个synchronized的方法调用也会比非同步的方法调用慢。
  要检查问题是否为采用synchronized关键字造成的,只需要像JVM进程发送一个QUIT指令(译者注:在linux上也可以用kill -3 PID的方式)来获取线程堆栈信息。如果你看到类似下面线程堆栈的信息,那么就意味着你的系统出现了热锁的问题: ..
  "Thread-0"prio=10tid=0x08222eb0nid=0x9waitingformo nitorentry[0xf927b000..0xf927bdb8]
  attestthread.WaitThread.run(WaitThread.java:39)
  -waitingtolock(ajava.lang.Object)
  -locked(ajava.util.ArrayList)
  atjava.lang.Thread.run(Thread.java:595)  synchronized 关键字强制执行器串行的执行synchronized中的动作。如果很多线程竞争同样的同步对象,那么只有一个线程能够执行同步块,而其他的线程就只能进入blocked状态了,如果此时没有其他需要执行的线程,那么处理器就进入空闲状态了,在这种情况下,增加CPU也带来不了多少性能提升。
  热锁可能会导致更多线程的切换和系统的调用。当多个线程竞争同一个monitor时,JVM必须维护一个竞争此monitor的线程队列(同样,这个队列也必须同步),这也就意味着更多的时间需要花费在JVM或OS的代码执行上,而更少的时间是用在你的程序上的。
  要避免热锁现象,以下的建议能带来一些帮助:
  尽可能的缩短同步块
  当你将线程中持有锁的时间尽量缩短后,其他线程竞争锁的时间也就变得更短。因此当你需要采用同步块来操作共享的变量时,应该将线程安全的代码放在同步块的外面,来看以下代码的例子:
  Code list 1: publicbooleanupdateSchema(HashMapnodeTree){
  synchronized(schema){
  StringnodeName=(String)nodeTree.get("nodeName");
  StringnodeAttributes=(List)nodeTree.get("attribute s");
  if(nodeName==null)
  returnfalse;
  else
  returnschema.update(nodeName,nodeAttributes);
  }
  }  上面的代码片段是为了当更新"schema"变量时保护这个共享的变量。但获取attribute值部分的代码是线程安全的。因此我们可以将这部分移至同步块的外面,让同步块变得更短一些:
  Code list 2: publicbooleanupdateSchema(HashMapnodeTree){
  StringnodeName=(String)nodeTree.get("nodeName");
  StringnodeAttributes=(List)nodeTree.get("attribute s");
  synchronized(schema){
  if(nodeName==null)
  returnfalse;
  else
  returnschema.update(nodeName,nodeAttributes);
  }
  }  减小锁的粒度
  当你使用"synchronized"时,有两种粒度可选择:"方法锁"或"块锁"。如果你将"synchronized"放在方法上,那么也就意味着锁定了"this"对象。
  Code list 3: publicclassSchemaManager{
  privateHashMapschema;
  privateHashMaptreeNodes;
  .
  publicbooleansynchronizedupdateSchema(HashMapnodeT ree){
  StringnodeName=(String)nodeTree.get("nodeName");
  StringnodeAttributes=(List)nodeTree.get("attribute s");
  if(nodeName==null)returnfalse;
  elsereturnschema.update(nodeName,nodeAttributes);
  }
  publicbooleansynchronizedupdateTreeNodes(){
  }
  } 对比Code list 2中的代码,这段代码就显得更糟糕些了,因为当调用"updateSchema"方法时,它锁定了整个 
  对象,为了获得更好的粒度控制,应该仅仅锁定"schema"变量来替代锁定整个对象,这样其他不同的方法就可 
  以保持并行执行了。 
  避免在static方法上加锁 
  最糟糕的状况是在static方法上加"synchronized",这样会造成锁定这个class的所有实例对象。 
  --------------------------------  atsun.awt.font.NativeFontWrapper.initializeFont(Na tiveMethod)
  -waitingtolock(ajava.lang.Class)
  atjava.awt.Font.initializeFont(Font.java:316)
  atjava.awt.Font.readObject(Font.java:1185)
  atsun.reflect.GeneratedMethodAccessor147.invoke(Un knownSource)
  atsun.reflect.DelegatingMethodAccessorImpl.invoke( DelegatingMethodAccessorImpl.java:25)
  atjava.lang.reflect.Method.invoke(Method.java:324)
  atjava.io.ObjectStreamClass.invokeReadObject(Objec tStreamClass.java:838)
  atjava.io.ObjectInputStream.readSerialData(ObjectI nputStream.java:1736)
  atjava.io.ObjectInputStream.readOrdinaryObject(Obj ectInputStream.java:1646)
  atjava.io.ObjectInputStream.readObject0(ObjectInpu tStream.java:1274)
  atjava.io.ObjectInputStream.defaultReadFields(Obje ctInputStream.java:1835)
  atjava.io.ObjectInputStream.readSerialData(ObjectI nputStream.java:1759)
  atjava.io.ObjectInputStream.readOrdinaryObject(Obj ectInputStream.java:1646)
  atjava.io.ObjectInputStream.readObject0(ObjectInpu tStream.java:1274)
  atjava.io.ObjectInputStream.defaultReadFields(Obje ctInputStream.java:1835)
  atjava.io.ObjectInputStream.defaultReadObject(Obje ctInputStream.java:452)
  atcom.fr.report.CellElement.readObject(UnknownSour ce)  当使用Java 2D来为报表生成字体对象时,开发人员放了一个native的static锁在"initialize"方法上,不过这是sun JDK 1.4中才会出现的,在JDK 5.0中,这个static lock就消失了。
  在Java SE 5.0中使用lock free的数据结构
  在Java中,"synchronized"关键字是一个较简单、并且相对来说比较好用的协作机制,不过同时对于管理一个简单的操作(例如增加统计值或更新一个值)来说就显得比较重量级了,就像以下的代码:
  Code list 4: publicclassOnlineNumber{
  privateinttotalNumber;
  publicsynchronizedintgetTotalNumber(){returntotalN umber;}
  publicsynchronizedintincrement(){return++totalNumb er;}
  publicsynchronizedintdecrement(){return--totalNumb er;}
  }  以上的代码只是用来锁定非常简单的操作,"synchronized"块也是非常的短。但是锁是非常重量级(当锁被其他线程持有时,线程会去频繁尝试获取锁)的,吞吐量会下降,并且同步锁的竞争也是很昂贵的。
  幸运的是,在Java SE 5.0或以上版本,你可以在不使用native代码的情况下使用硬件级同步语义的wait-free、lock-free的算法。几乎所有现代的处理器都具有检测和防止其他处理器并发修改变量的基础设施。这些基础设施称为比较并交换,或CAS。
  一个CAS操作包含三个参数 -- 一个内存地址,期待的旧的值以及新的值。 如果内存地址上的值和所期待的旧的值是同一个的话,处理器将此地址的值更新为新的值;否则它就什么都不做,同时它会返回CAS操作前内存地址上的值。一个使用CAS来实现同步的例子如下:
  Code list 5: publicintincrement(){
  intoldValue=value.getValue();
  intnewValue=oldValue+1;
  while(value.compareAndSwap(oldValue,newValue)!=old Value)
  oldValue=value.getValue();
  returnoldValue+1;
  }  首先,我们从地址上读取一个值,然后执行几步操作来产生新的值(例子中只是做加1的操作),最后使用CAS方式来将地址中的旧值改变为新值。如果在时间片段内地址上的值未改变,那么CAS操作将成功。如果另外的线程同时修改了地址上的值,那么CAS操作将失败,但会检测到这个操作失败,并在while循环中进行重试。CAS最好的原因在于它是硬件级别的实现并且非常轻量级,如果100个线程同时执行这个increment()方法,最糟糕的情况是在 increment方法执行完毕前每个线程最多尝试99次。
  在Java SE 5.0和以上版本的java.util.concurrent.atomic包中提供了在单个变量上lock-free和线程安全操作支持的类。这些原子变量的类都提供了比较和交换的原语,它基于各种平台上可用的最后的native的方式实现,这个包内提供了九种原子变量,包括:AtomicInteger;AtomicLong;AtomicReference;AtomicBoolean;array forms of atomic integer、long、reference;和atomic marked reference和stamped reference类。
  使用atomic包非常容易,重写上面code list 5的代码片段:
  Code list 6: importjava.util.concurrent.atomic.*;
  .
  privateAtomicIntegervalue=newAtomicInteger(0);
  publicintincrement(){
  returnvalue.getAndIncrement();
  } 几乎java.util.concurrent包中所有的类都直接或间接的采用了原子变量来替代synchronized。像 
  ConcurrentLinkedQueue采用了原子变量来直接实现wait-free算法,而像ConcurrentHashMap则采用 
  ReentrantLock来实现必要的锁,而ReentrantLock则是采用原子变量来维护所有等待锁的线程队列。 
  在我们实验室中一个最成功的关于lock free算法的案例发生在一个金融系统中,当将"Vector"数据结构替换为"ConcurrentHashMap"后,在我们的CMT机器(8核)性能提升了超过3倍。
  竞争条件也会导致可扩展性出现问题
  太多的"synchronized"关键字会导致可扩展性出现问题。但在某些场合,缺少"synchronized"也会导致系统无法垂直扩展。缺少"synchronized"会产生竞争场景,在这种场景下允许两个线程同时修改共享的资源,这有可能会造成破坏共享数据,为什么我说它会导致可扩展性出现问题呢?
  来看一个实际的例子。这是一个制作业的ERP系统,当在我们最新的一台CMT服务器(2CPU、16核、128芯)上进行性能测试时,我们发现CPU的使用率超过90%,这非常让人惊讶,因为很少有应用能够在这款机器上扩展的这么好。但我们仅仅兴奋了5分钟,之后我们发现平均响应时间非常的慢,同时吞吐量也降到不可思议的低。那么这些CPU都在干嘛呢?它们不是在忙吗,那么它们到底在忙些什么呢?通过OS的跟踪工具,我们发现几乎所有的CPU都在干同一件事-- "HashMap.get()",看起来所有的CPU都进入了死循环,之后我们在不同数量的CPU的服务器上再测试了这个应用,结果表明,服务器拥有越多CPU,那么产生死循环的概率就会越高。
  产生这个死循环的根源在于对一个未保护的共享变量 -- 一个"HashMap"数据结构的操作。当在所有操作的方法上加了"synchronized"后,一切恢复了正常。检查"HashMap"(Java SE 5.0)的源码,我们发现有潜在的破坏其内部结构最终造成死循环的可能。在下面的代码中,如果我们使得HashMap中的entries进入循环,那么"e.next()"永远都不会为null。
  Code list 7: publicVget(Objectkey){
  if(key==null)returngetForNullKey();
  inthash=hash(key.hashCode());
  for(Entrye=table[indexFor(hash,table.length)];
  e!=null;
  e=e.next){
  Objectk;
  if(e.hash==hash&&((k=e.key)==key||key.equals(k)))
  returne.value;
  }
  returnnull;
  }  不仅get()方法会这样,put()以及其他对外暴露的方法都会有这个风险,这算jvm的bug吗?应该说不是的,这个现象很早以前就报告出来了(详细见:http://bugs.sun.com/bugdatabase/view_bug.do?bug_id =6423457)。Sun的工程师并不认为这是bug,而是建议在这样的场景下应采用"ConcurrentHashMap",在构建可扩展的系统时应将这点纳入规范中。
  非阻塞 IO vs. 阻塞IO
  Java 1.4中引入的java.nio包,允许开发人员在进行数据处理时获取更好的性能并提供更好的扩展性。NIO提供的非阻塞IO操作允许java应用像其他底层语言(例如c)一样操作IO。目前已经有很多NIO的框架(例如Apache的Mina、Sun的Grizzly)了被广泛的使用在很多的项目和产品中。
  在最近的5个月内,我们实验室有两个Java EE项目测试对比了基于传统的阻塞I/O构建的服务器和非阻塞I/O构建的服务器上的性能。他们选择了Tomcat 5作为基于阻塞I/O的服务器,Glassfish作为基于非阻塞I/O的服务器。
  首先,他们测试了一些简单的JSP页面和servlets,得到如下结果:(在一台4 CPU的服务器上)
  从测试结果来看,Glassfish的性能远低于Tomcat。客户对非阻塞I/O能够带来的提升表示怀疑,但为什么那么多的文章以及技术报告都告诉大家NIO具备更好的性能和可扩展性呢?
  当在更多的场景进行测试后,随着NIO的能力逐步的展现出来,他们改变了观点,他们做了以下的测试:
  1、比简单的JSP、servlet更为复杂的场景,包括EJB、数据库、文件IO、JMS和事务;
  2、模拟更多的并发用户,从1000到10000;
  3、在不同的硬件环境上进行测试,从2 CPU、4 CPU到16 CPU。
  以下的图为在4 CPU服务器上的测试结果:
  Figure 1: Throughput in a 4CPU server
  传统的阻塞I/O为每个请求分配一个工作线程,这个工作线程负责请求的整个过程的处理,包括从网络读取请求数据、解析参数、计算或调用其他的业务逻辑、编码结果并将其返回给请求者,然后这个线程将返回到线程池中供其他线程复用。Tomcat 5采用的这种方式在应对完美的网络环境、简单的逻辑以及小量的并发用户时是非常高效的。
  但如果请求包括了复杂的逻辑、或需要和外部的系统(例如文件系统、数据库或消息服务器)进行交互时,工作线程在其处理的大部分时间都会处于等待同步的调用或网络传输返回的状态中,这个阻塞的线程会被请求持有直到请求处理完毕,但操作系统需要暂停线程来保证CPU能够处理其他的请求,如果客户端和服务器端的网络状况不太好的话,网络的延时会导致线程被阻塞更长时间,在更糟的状况下,当需要keep-alive的话,当前的工作线程会在请求处理完毕后阻塞很长一段时间,在这样的情况下,为了更好的使用CPU,就必须增加更多的工作线程了。
  Tomcat采用了一个线程池,每个请求都会被线程池中一个空闲的线程进行处理。"maxThreads"表示Tomcat 能创建的处理请求的最大线程数。如果我们把"maxThreads"设置的太小的话,就不能充分的使用CPU了,更为重要的是,随着并发用户的增长,会有很多请求被服务器抛弃和拒绝。在此次测试中,我们将"maxThreads"设置为了1000(这对于Tomcat来说有些太大了),在这样的设置下,当并发用户增长到较高数量时,Tomcat会创建很多的线程。大量的Java线程会导致JVM和OS忙于执行和维护这些线程,而不是执行业务逻辑处理,同时,太多的线程也会消耗更多的JVM heap内存(每个线程堆栈需要占用一些内存),并且会导致更为频繁的gc。
  Glassfish不需要这么多的线程,在非阻塞IO中,一个工作线程并不会绑定到一个特定的请求上,如果请求被某些原因所阻塞,那么这个线程将被其他的请求复用。在这样的方式下,Glassfish可以用几十个工作线程来处理几千的并发用户。通过限制线程资源,非阻塞IO拥有了更好的可扩展性,这也是Tomcat 6采用非阻塞IO的原因了。
  Figure 2: scalability test result
  单线程任务问题
  几个月前我们实验室测试了一个基于Java EE的ERP系统,它其中的一个测试场景是为了产生非常复杂的分析报告,我们在不同的服务器上测试了这个应用场景,发现竟然是在最便宜的AMD PC服务器上拥有最好的性能。这台AMD的服务器只有两个2.8HZ的CPU以及4G的内存,但它的性能竟然超过了昂贵的拥有8 CPU和32G内存的SPARC服务器。
  原因就在于这个场景是个单线程的任务,它同时只能被一个用户运行(并发的多用户执行在这个案例中毫无意义),因此当运行时它只使用了一个CPU,这样的任务是没法扩展到多个处理器的,在大多数时候,这种场景下的性能仅取决于CPU的运行速度。
  并行是解决这个问题的方案。为了让一个单线程的任务并行执行,你需要按顺序找出这个操作的过程中从某种程度上来讲不依赖的操作,然后采用多线程从而实现并行。在上面的案例中,客户重新定义了"分析报告产生"的任务,改为先生成月度报告,之后基于产生的这些12个月的月度报告来生成分析报告,由于最终用户并不需要"月度报告",因此这些"月度报告"只是临时产生的结果,但"月度报告"是可以并行生成的,然后用于快速的产生最后的分析报告,在这样的方式下,这个应用场景可以很好的扩展到4 CPU的SPARC服务器上运行,并且在性能上比在AMD Server高80%多。
  重新调整架构和重写代码的解决方案是一个耗时并且容易出现错误的工作。在我们实验室中的一个项目中采用了JOMP来为其单线程的任务获得并行性。JOMP是一个基于线程的SMP并行编程的Java API。就像OpenMP,JOMP也是根据编译指示来插入并行运行的代码片段到常规的程序中。在Java程序中,JOMP 通过//omp这样的指示方式来表示需要并行运行的部分。JOMP程序通过运行一个预编译器来处理这些//omp的指示并生成最终的java代码,这些 java代码再被正常的编译和执行。JOMP支持OpenMP的大部分特性,包括共享的并行循环和并行片段,共享变量,thread local变量以及reduction变量。以下的代码为JOMP程序的示例:
  Code list 8: LinkedListc=newLinkedList();
  c.add("this");
  c.add("is");
  c.add("a");
  c.add("demo");
  //#ompparalleliterator
  for(Strings:c)
  System.out.println("s");  就像大部分的并行编译器,JOMP也是关注于loop-level和集合的并行运算,研究如何同时执行不同的迭代。为了并行化,两个迭代之间不能产生任何的数据依赖,这也就是说,不能依赖于其他任何一个执行后产生的计算结果。要编写一个JOMP程序并不是容易的事。首先,你必须熟练使用OpenMP的指示,同时还得熟悉JVM对于这些指示的内存模型映射,最后你需要知道在你的业务逻辑代码的正确的地方放置正确的指示。
  另外一个选择是采用Parallel Java。Parallel Java,就像JOMP一样,也支持OpenMP的大部分特性;但又不同于JOMP,PJ的并行结构部分是通过在代码中调用PJ的类来实现,而不是通过插入预编译的指示,因此,"Parallel Java"不需要另外的预编译过程。Parallel Java不仅对于在多CPU上并行有效,对于多节点的扩展能力上也同样有效。以下的代码是"Parallel Java"程序的示例:
  Code list 9: staticdouble[][]d;
  newParallelTeam().execute(newParallelRegion()
  {
  publicvoidrun()throwsException
  {
  for(intii=0;ii文件,导致文件系统在创建和查找文件时变得非常的慢; 
  同步logging:这是一个可扩展性的常见问题。在有些案例中,可以通过采用Apache log4j来解决,或者采用jms消息来将同步的logging转为异步执行。
  这些不仅仅是Java EE应用的问题,对于所有平台的所有系统而言同样如此。为了解决这些问题,需要从系统的各个层面来从数据库管理员、系统工程师和网络分析人员处得到帮助。
  当并发用户数明显的开始增长,你可能会不满意一台机器所能提供的性能,或者由于单个JVM实例gc的限制,你没法扩展你的java应用,在这样的情况下你可以做的另外的选择是在多个JVM实例或多台服务器上运行你的系统,我们把这种方法称为水平扩展。
  请注意,我们相信能够在一台机器的多个JVM上运行系统的扩展方式是水平扩展方式,而非垂直扩展方式。JVM实例之间的IPC机制是有限的,两个JVM实例之间无法通过管道、共享内存、信号量或指令来进行通讯,不同的JVM进程之间最有效的通讯方式是socket。简而言之,如果Java EE应用如果扩展到多个JVM实例中运行,那么大多数情况下它也可以扩展到多台服务器上运行。
  随着计算机越来越便宜,性能越来越高,通过将低成本的机器群组装为集群可以获得超过那些昂贵的超级计算机所具备的计算能力。不过,大量的计算机也意味着增加了管理的复杂性以及更为复杂的编程模型,就像服务器节点之间的吞吐量和延时等问题。
  Java EE集群是一种成熟的技术,我在TSS上写了一篇名为"Uncover the Hood of J2EE Clustering"的文章来描述它的内部机制。
  从失败的项目中吸取的教训
  采用无共享的集群架构(SNA)
  
  Figure 3: share nothing cluster
  最具备扩展性的架构当属无共享的集群架构。在这样的集群中,每个节点具备完全相同的功能,并且不需要知道其他节点存在与否。负载均衡器(Load Balancer)来完成如何将请求分发给这些后台的服务器实例。由于负载均衡器只是做一些简单的工作,例如分派请求、健康检查和保持session,因此负载均衡器很少会成为瓶颈。如果后端的数据库系统或其他的信息系统足够的强大,那么通过增加更多的节点,集群的计算能力可以得到线性的增长。
  几乎所有的Java EE提供商在他们的集群产品中都实现了HttpSession的failover功能,这样即使在某些服务器节点不可用的情况下也仍然能够保证客户端的请求中的session信息不丢失,但这点其实是打破了无共享原则的。为了实现failover,同样的session数据将会被两个或多个节点共享,在我之前的文章中,我曾经推荐除非是万不得已,不要使用session failover。就像我文章中提到的,当失败发生时,session failover功能并不能完全避免错误,而且同时还会对性能和可扩展性带来损失。
  使用可扩展的session复制机制
  为了让用户获得更友好的体验,有些时候可能必须使用session failover功能,这里最重要的在于选择可扩展的复制型产品或机制。不同的厂商会提供不同的复制方案 - 有些采用数据库持久,有些采用中央集中的状态服务器,而有些则采用节点间内存复制的方式。最具可扩展性的是成对节点的复制(paired node replication),这也是现在大部分厂商采用的方案,包括BEA Weblogic、JBoss和IBM Websphere,Sun在Glassfish V2以及以上版本也实现了成对节点的复制。最不可取的方案是数据库持久session的方式。在我们实验室中曾经测试过一个采用数据库持久来实现 session复制的项目,测试结果表明如果session对象频繁更新的话,节点在三到四个时就会导致数据库崩溃。
  采用collocated部署方式来取代分布式
  Java EE技术,尤其是EJB,天生就是用来做分布式计算的。解耦业务功能和重用远程的组件使得多层的应用模型得以流行。但对于可扩展性而言,减少分布式的层次可能是一个好的选择。
  在我们实验室曾经以一个政府的项目测试过这两种方式在同样的服务器数量上的部署 - 一种是分布式的,一种是collocated方式的,如下图所示:
  Figure 4: distributed structure  
  Figure 5: collocated structure 
  结果表明collocated式的部署方式比分布式的方式更具备可扩展性。假设你应用中的一个方法调用了一堆的EJB,如果每个EJB的调用都需要load balance,那么有可能会因为需要分散到不同的服务器上进行调用导致你的应用崩溃,这样的结果就是,你可能做了很多次无谓的跨服务器的调用。来看更糟糕的情况,如果你的方法是需要事务的,那么这个事务就必须跨越多个服务器,而这对于性能是会产生很大的损害的。
  共享资源和服务
  对于用于支撑并发请求的Java EE集群系统而言,其扩展后的性能取决于对于那些不支持线性扩展的共享资源的操作。数据库服务器、JNDI树、LDAP服务器以及外部的文件系统都有可能被集群中的节点共享。
  尽管Java EE规范中并不推荐,但为了实现各种目标,通常都会采用外部的I/O操作。例如,在我们实验室测试的应用中有用文件系统来保存用户上传的文件的应用,或动态的创建xml配置文件的应用。在集群内,应用服务器节点必须想办法来复制这些文件到其他的节点,但这样做是不利于扩展的。随着越来越多节点的加入,节点间的文件复制会占用所有的网络带宽和消耗大量的CPU资源。在集群中要达到这样的目标,可以采用数据库来替代外部文件,或采用SAN作为文件的集中存储,另外一个可选的方案是采用高效的分布式文件系统,例如Hadoop DFS(http://wiki.apache.org/hadoop/)。
  在集群环境中共享服务很常见,这些服务不会部署到集群的每个节点,而是部署在专门的服务器节点上,例如分布式的日志服务或时间服务。分布式锁管理器 (DLM)来管理集群中的应用对这些共享服务的同步访问,即使在网络延时和系统处理失败的情况下,锁管理器也必须正常操作。举例来说,在我们的实验室中测试的一个ERP系统就碰到了这样的问题,他们写了自己的DLM系统,最终发现当集群中持有锁的节点失败时,他们的lock system将会永远的持有锁。
  分布式缓存
  我所碰到过的几乎所有的Java EE项目都采用了对象缓存来提升性能,同样所有流行的应用服务器也都提供了不同级别的缓存来加速应用。但有些缓存是为单一运行的环境而设计的,并且只能在单JVM实例中正常的运行。由于有些对象的创建需要耗费大量的资源,我们需要缓存,因此我们维护对象池来缓存对象的实例。如果获取维护缓存较之创建对象而言更划算,那么我们就提升了系统的性能。在集群环境中,每个jvm实例维护着自己的缓存,为了保持集群中所有服务器状态的一致,这些缓存对象需要进行同步。有些时候这样的同步机制有可能会比不采用缓存的性能还差,对于整个集群的扩展能力而言,一个可扩展的分布式缓存系统是非常重要的。
  如今很多分布式缓存相关的开源java产品已经非常流行,在我们实验室中有如下的一些测试:
  1个基于JBoss Cache的项目的测试; 
  3个基于Terracotta的项目的测试; 
  9个基于memcached的项目的测试; 
  测试结果表明Terracotta可以很好的扩展到10个节点,并且在不超过5个节点时拥有很高的性能,但memcached则在超过20个服务器节点时会扩展的非常好。
  Memcached 
  Memcached是一个高性能的分布式对象缓存系统,经常被用于降低数据库load,同时提升动态web应用的速度。Memcached的奇妙之处在于它的两阶段hash的方法,它通过一个巨大的hash表来查找key = value对,给它一个key,就可以set或get数据了。当进行一次memcached查询时,首先客户端将会根据整个服务器的列表来对key进行 hash,在找到一台服务器后,客户端就发送请求,服务器端在接收到请求后通过对key再做一次内部的hash,从而查找到实际的数据项。当处理巨大的系统时,最大的好处就是memcached所具备的良好的水平扩展能力。由于客户端做了一层hashing,这使得增加N多的节点到集群变得非常的容易,并不会因为节点的互连造成负载的增高,也不会因为多播协议而造成网络的洪水效应。
  实际上Memcached并不是一款java产品,但它提供了Java client API,这也就意味着如果你需要在Java EE应用中使用memcached的话,并不需要做多大的改动就可以从cache中通过get获取值,或通过put将值放入cache中。使用 memcached是非常简单的,不过同时也得注意一些事情避免对扩展性和性能造成损失:
  不要缓存写频繁的对象。Memcached是用来减少对数据库的读操作的,而非写操作,在使用Memcached前,应先关注对象的读/写比率,如果这个比率比较高,那么采用缓存才有意义。 
  尽量避免让运行的memcached的节点互相调用,对于memcached而言这是灾难性的。 
  尽量避免行方式的缓存,在这样的情况下可采用复杂的对象来进行缓存,这对于memcached来说会更为有效。 
  选 择合适的hashing算法。在默认的算法下,增加或减少服务器会导致所有的cache全部失效。由于服务器的列表hash值被改变,可能会造成大部分的 key都要hash到和之前不同的服务器上去,这种情况下,可以考虑采用持续的hashing算法(http://weblogs.java.net /blog/tomwhite/archive/2007/11/consistent_hash.htm l) 来增加和减少服务器,这样做可以保证你大部分缓存的对象仍然是有效的。
  Terracotta 
  Terracotta(http://www.terracottatech.com/)是一个企业级的、开源的、JVM级别的集群解决方案。JVM级的集群方案意味着可以支撑将企业级的Java应用部署部署到多JVM上,而且就像是运行在同一个JVM中。 Terracotta扩展了JVM的内存模型,各虚拟机上的线程通过集群来与其他虚拟机上的线程进行交互(Terracotta extends the Java Memory Model of a single JVM to include a cluster of virtual machines such that threads on one virtual machine can interact with threads on another virtual machine as if they were all on the same virtual machine with an unlimited amount of heap.)。
  Figure 6: Terracotta JVM clustering
  采用Terracotta来实现集群应用的编程方式和编写单机应用基本没有什么差别,Terrocotta并没有特别的提供开发者的API,Terracotta采用字节码织入的方式(很多AOP软件开发框架中采用的技术,例如AspectJ和AspectWerkz)来将集群方式的代码插入到已有的java语言中。
  我猜想Terrocotta是通过某种互连的方式或多播协议的方式来实现服务器和客户端JVM实例的通讯的,可能是这个原因导致了在我们实验室测试时的效果:当超过20个节点时Terracotta扩展的并不是很好。(注:这个测试结果仅为在我们实验室的测试结果,你的结果可能会不同。)
  并行处理
  我之前说过,单线程的任务会成为系统可扩展性的瓶颈。但有些单线程的工作(例如处理或生成巨大的数据集)不仅需要多线程或多进程的运行,还会有扩展到多节点运行的需求。例如,在我们实验室测试的一个Java EE项目有一个场景是这样的:根据他们站点的日志文件分析URL的访问规则,每周产生的这些日志文件通常会超过120GB,当采用单线程的Java应用去分析时需要耗费四个小时,客户改为采用Hadoop Map-Reduce使其能够水平扩展从而解决了这个问题,如今这个分析URL访问规则的程序不仅运行在多进程模式下,同时还并行的在超过10个节点上运行,而完成所有的工作也只需要7分钟了。
  有很多的框架和工具可以帮助Java EE开发人员来让应用支持水平扩展。除了Hadoop,很多MPI的Java实现也可以用来将单线程的任务水平的扩展到多个节点上并行运行。
  MapReduce 
  MapReduce由Google的Jeffrey Dean和Sanjay Ghemawat提出,是一种用于在大型集群环境下处理巨量数据的分布式编程模型。MapReduce由两个步骤来实现 - Map:对集合中所有的对象进行操作并基于处理返回一系列的结果,Reduce:通过多线程、进程或独立系统并行的从两个或多个Map中整理和获取结果。Map()和Reduce()都是可以并行运行的,不过通常来说没必要在同样的系统同样的时间这么来做。
  Hadoop是一个开源的、点对点的、纯Java实现的MapReduce。它是一个用于将分布式应用部署到大型廉价集群上运行的Lucene-derived框架,得到了全世界范围开源人士的支持以及广泛的应用,Yahoo的Search Webmap、Amazon EC2/S3服务以及Sun的网格引擎都可运行在Hadoop上。 简单来说,通过使用"Hadoop Map-Reduce","URL访问规则分析"程序可以首先将日志文件分解为多个128M的小文件,然后由Hadoop将这些小文件分配到不同的Map()上去执行。Map()会分析分配给它的小文件并产生临时的结果,Map()产生的所有的临时结果会被排序并分配给不同的Reduce(),Reduce()合并所有的临时结果产生最终的结果,这些Map和Reduce操作都可以由Hadoop框架控制来并行的运行在集群中所有的节点上。
  MapReduce对于很多应用而言都是非常有用的,包括分布式检索、分布式排序、web link-graph reversal、term-vector per host、web访问日志分析、索引重建、文档集群、机器智能学习、statistical machine translation和其他领域。 
  MPI
  MPI是一种语言无关、用于实现并行运行计算机间交互的通讯协议,目前已经有很多Java版本的MPI标准的实现,mpiJava和MPJ是其中的典型。mpiJava 基于JNI绑定native的MPI库来实现,MPJ是100%纯java的MPI标准的实现。mpiJava和MPJ和MPI Fortran和C版本提供的API都基本一致,例如它们都对外提供了具备同样方法名和参数的Comm class来实现MPI的信息传递。
  CCJ是一个类似MPI通讯操作的java库。CCJ提供了barrier、broadcast、scatter、 gather、all-gather、reduce和all-reduce操作的支持(但不提供点对点的操作,例如send、receive和send- receive)。在底层的通讯协议方面,CCJ并没有自己实现,而是采用了Java RMI,这也就使得CCJ可以用来传递复杂的序列化对象,而不仅仅是MPI中的原始数据类型。进一步看,CCJ还可以从一组并行的processes中获取到复杂的集合对象,例如实现了CCJ的DividableDataObject接口的集合。
  采用不同的方法来获取高扩展能力
  有很多的书会教我们如何以OO的方式来设计灵活架构的系统,如何来使服务透明的被客户端使用以便维护,如何采用正常的模式来设计数据库schema以便集成。但有些时候为了获取高扩展性,需要采用一些不同的方法。
  Google设计了自己的高可扩展的分布式文件系统(GFS),它并不是基于POSIX API来实现的,不过GFS对于用户来说并不完全透明。为了使用GFS,你必须采用GFS的API包。Google也设计了自己的高可扩展的分布式数据库系统(Bigtable),但它并不遵循ANSI SQL标准,而且其中的概念和结构和传统的关系数据库几乎完全不同,但最重要的是GFS和Bigtable能够满足Google的存储要求、良好的扩展性要求,并且已经被Google的广泛的作为其存储平台而使用。
  传统方式下,我们通过使用更大型的、更快和更贵的机器或企业级的集群数据库(例如RAC)来将数据库扩展到多节点运行,但我有一个我们实验室中测试的social networking的网站采用了不同的方式,这个应用允许用户在网站上创建profiles、blogs,和朋友共享照片和音乐,此应用基于Java EE编写,运行在Tomcat和Mysql上,但不同于我们实验室中测试的其他应用,它只是希望在20多台便宜的PC Server上进行测试,其数据模型结构如下:
  Figure 7: Users data partitions
  这里比较特殊的地方子碍于不同的用户数据(例如profile、blog)可能会存储在不同的数据库实例上,例如,用户 00001存储在服务器A上,而用户20001存储在服务器C上,分库的规则以一张元信息的表的方式存储在专门的数据库上。当部署在Tomcat的 Java EE应用希望获取或更新用户信息时,首先它会从这张元信息的表中获取到需要去哪台服务器上获取这个用户,然后再连到实际的服务器上去执行查询或更新操作。
  用户数据分区和这种两步时的动作方式可以带来如下的一些好处:
  扩展了写的带宽:对于这类应用而言,blogging、ranking和BBS将会使得写带宽成为网站的主要瓶颈。分 布式的缓存对于数据库的写操作只能带来很小的提升。采用数据分区的方式,可以并行的进行写,同样也就意味着提升了写的吞吐量。要支持更多的注册用户,只需 要通过增加更多的数据库节点,然后修改元信息表来匹配到新的服务器上。 
  高可用性:如果一台数据库服务器down了,那么只会有部分用户被影响,而其他大部分的用户可以仍然正常使用; 
  同时也会带来一些缺点:
  由于数据库节点可以动态的增加,这对于在Tomcat中的Java EE应用而言要使用数据库连接池就比较难了; 
  由于操作用户的数据是两步式的,这也就意味着很难使用ORMapping的工具去实现; 
  当要执行一个复杂的搜索或合并数据时,需要从多台数据库服务器上获取很多不同的数据。
  这个系统的架构师这么说:"我们已经知道这些缺点,并且准备好了应对它,我们甚至准备好了应对当元信息表的服务器成为瓶颈的状况,如果出现那样的状况我们将会把元信息表再次划分,并创建出一个更高级别的元信息表来指向众多的二级元信息表服务器实例。
  相关解决方案