Terracotta是一种分布式java集群技术,它巧妙得隐藏了多个分布式JVM带来的复杂性,使得java对象能够透明得在多个JVM集群中进行分享和同步,并能够进行持久化。从某种意义上讲它类似于hadoop中的zookeeper,可以作为zookeeper之外的另外一种选择。这篇文章谈谈Terracotta集群是如何工作的。本文的主要内容译自文献1。
Terracotta采用的是一种被称之为hub-and-spoke(中心辐射)的架构。在这种架构里运行着分布式应用程序的JVM们在启动时都会与一台中心Terracotta服务器相连。Terracotta服务器负责存储DSO对象数据,协调JVM之间的并发线程。Terracotta库位于应用程序JVM中,在类加载过程中,它们用来对类的字节码进行instrumentation (中文不知该翻译成什么,字节码增强?),处理同步块内的lock和unlock请求,处理应用JVM之间的wait(),notify()请求,处理运行时和Terracotta服务器的联系等等。
普通的应用程序是怎么获得这种分布式的集群行为呢?当应用程序类在被JVM加载的时候,它们通过字节码增强技术被偷偷注入了分布式集群行为。Terracotta采用的字节码增强注入技术其实很常用,在很多AOP框架中都得以采用,比如AspectJ和AspectWerkz。类的字节码在加载的时候由Terracotta库进行解析和检查。然后这些字节码会被传递到JVM重新构造成一个类,在此之前这些字节码会根据配置被修改。
为了维护对象的修改,PUTFIELD和GETFIELD字节指令被进行了重载。PUTFIELD指令被替换了,能够存储对一个分布式对象的各个域的修改。GETFILED字节指令重载后能够在需要的时候从服务器获取对象的域数据,但这么做的前提是此时它还没有从服务器的查询中获取到被这个域所引用的对象,此时该域引用的对象还没有在JVM堆中被实例化。也就是说GETFIELD是一个lazy initialize模式,如果域为空才会加载域数据,否则不会加载。
为了管理线程之间的协调,MONITORENTER和MONITOREXIT字节码指令也被重载 了,INVOKEVIRTUAL指令也会被重载,这些指令会被各种各样的object.wait()和objecti.notify()方法用到。MONITORENTER意味着某一个线程对某一个对象monitor的请求。一个线程会阻塞在这条指令上,直到它获得了对该对象的锁。一旦获得了锁,那么线程就会持有该对象的排他锁,直至针对该对象的MONITOREXIT指令被执行。如果在MONITORENTER请求查询monitor的时候这个对象碰巧是一个集群对象-DSO,Terracotta会保证:除了请求这个对象在本地JVM里的本地锁之外,线程还会在这个DSO对象上的整个JVM集群上的排他锁,在此之前,该线程会一直阻塞。当线程释放本地JVM上对该DSO对象的本地锁的时候,他也会释放相应的整个JVM集群上的锁。
在Terracotta的应用程序中,所有的synchronized方法和synchronized块往往会被被配置成“autolocking”,这就意味着MONITORENTER和MONITOREXIT方法被进行了字节码增强处理。当然有的程序员可能不太愿意用显式的synchronized关键字,那么这些家伙可以在Terracotta配置文件中声明一个方法为一个locked方法,从而使得应用程序获得集群同步特性。
对象wait()和notify()方法相应的字节码指令也会被进行字节码增强。当某一个共享对象的wait()方法被调用时,terracotta服务器会把调用这个wait()方法的线程加入到一个线程队列中去,这个线程队列记录了整个JVM集群中所有等待该对象锁的所有线程。当这个对象的notify方法被调用时,服务器会确保整个集群中所有阻塞在该对象上的线程会被通知到。一旦该对象的notify在一个JVM中被调用时,terracotta服务器会选择一个阻塞在该对象上的线程,然后唤醒通知它。当notifyAll被调用时,terracotta服务器会让所有JVM中等待在该DSO上的所有线程都被唤醒。
集群对象从一个共享对象图中的根开始。这个root可以通过Terracotta配置文件中的一个或多个域进行配置的。
当一个root被首先实例化时,这个root对象和这个root所能到达的所有的对象就变成了集群对象。他们的各个域的数据会被传递到服务器上由服务器来存储。在任何JVM中一旦一个root对象被创建,那么该root对象创建时所对应的那个域就会忽略本地堆对象的分配,取而代之的是分配一个服务器集群对象。这种情况往往发生在第二个应用程序实例创建root对象的时刻,由于root对象已经由第一个应用程序实例化创建了,那么其他应用程序中的,尽管这些root域按照代码的要求是要通过构造函数来生成对象的,但这些指令都被忽略了,取而代之的是,Terracotta客户端库会从服务器获取root对象,然后在本地堆中实例化它,然后把这个引用赋给相应的域,这些工作都是透明得进行的,被terracotta的库隐藏了。terracotta的工作机制给我们的应用程序带来的最主要也是最有价值的地方也就在于此。
当一个非集群对象突然变成了一个集群可达的对象时,那么这个非集群对象和该非集群对象可达的整个对象可达图也会变成集群对象。一旦某一个对象变成集群对象了,那么他就会被分配一个整个集群范围内唯一的object id,并且在剩下的生命周期内一直保持集群特性。一旦某一个集群对象突然变成了任何root对象都不可达的状态,并且在整个集群JVM中都没有它的任何实例,那么这个集群对象会被terracotta的服务器GC进行回收。
Synchronized方法,synchronized块,在terracotta配置文件中被声明为lock的方法,这三种形式代表了terracotta中transaction的3种形式。Terracotta transaction的概念跟JTA transaction的概念有所区别,和java内存模型中的transaction概念更加类似。(java memory model是另外一个问题,在另外的文章中会讲到)
正如前面所提到的,一个针对DSO的MONITORENTER指令会被进行字节码增强成为对一个分布式锁的请求,调用该指令的线程会被一直阻塞,直到它获得了对该DSO的本地锁和其他JVM上的分布式锁。Terracotta服务器会把在MONITORENTER和MONITOREXIT之间DSO对象数据的修改汇集成一个本地transaction记录。Terracotta会确保在一个线程在被允许通过MONITORENTER指令之前,在JVM集群上某一个DSO锁对应的所有的transaction中所做的修改都会在所有的JVM上提交生效。Transaction可以包含对任何对象的任何修改,不仅仅是持有该transaction对应的锁的对象。
包含DSO对象变化的transaction只包含那些已经发生变化的域的数据。这些transaction会被发送到Terracotta服务器和其他集群JVM上从而保持集群一致性。服务器不是广播式的将transaction发送到所有其它的JVM上,这些transaction只会被发送到特定的JVM上,这些JVM包含transaction代表的对象,并且这些对象在这些JVM的堆上进行了实例化。也就是说,terracotta服务器只发送其它JVM必须使用到的transaction的一部分。举个例子吧,如果一个线程改变了对象a中的域p和对象b中的域q,那么只有a.p和b.q的域数据会被放到transaction中并被发送到服务器。更改某个DSO的多个相关的域在terracotta中一定是原子的,一定是要用synchronized关键字进行同步的,根据前面的定义,那么更改DSO的这些域也就是transaction,被发送到服务器上。(只更改某个DSO的单个域本身就是原子的,不需要用synchronized进行显式同步,也就是说某个DSO单个域的修改本身就是一个transaction)。Terracotta服务器会决定哪些JVM含有a和b的实例。如果一个JVM的本地堆只含有对象a的实例而并没有对象b的实例,那么这个JVM只会收到a.p的数据,而不会收到b.q的数据。
由于对象的变化的历史记录只是局限在对象的域层次并且transaction只包含的是DSO的片段而不是整个对象图,因此terracotta不会使用Java序列化来复制传播对象的变化。举个例子,我们更改一个product对象的price域,那么我们需要发送到集群上就是发生变化的对象的ID,对象发生变化的域的ID,和包含price域数据的字节。Product对象的其余部分就被忽略了。如果我们采用object序列化技术,那么product对象的每个域都需要被序列化,而各个域又会引用到其它对象,这样最后的结果是仅仅是对product对象的一个double域的修改就会导致整个对象图都会被序列化。
Terracotta目前采用的做法相比java序列化而言更加高效,因为它只发送了发生改变的对象而不是整个对象图。但是,除了效率,利用对象域作为改变的基本单位还有另外的好处:保持对象的唯一性。
如果采用java序列化来在集群间转移对象的变化,那么JVM集群的客户端应用程序需要对发生改变的对象进行反序列化并且不得不替换已有的对象实例。这就是为什么许多其他的集群和分布式技术会要求提供PUT/GET API,因为一个集群对象从集群中被获取出来一定需要一个GET调用,当对象发生变化时,它一定需要一个PUT调用把发生改变的对象放回集群上去。
Terracotta没有这样的限制。一个集群对象也像普通对象一样在JVM堆中生存着。当对象是被JVM本地进行修改的,那么这些修改直接作用在JVM的堆上。如果这些修改是通过远程的在另外一个JVM上的这个DSO对象的引用进行的,那么本地的JVM就会收到这个transaction并且直接将transaction作用在已在本地堆中存在的对象上。这意味着针对某个DSO,在任何给定的时刻一个JVM在堆中只可能拥有一个对它的实例引用。(当多个classloader加入进来时,这就变得复杂了,这已经超出了本文的范围)。
有了terracotta,你不必考虑每个JVM实际上存放的是一个对象的copy,也不必考虑当本地进行完修改时再把对象的copy放回集群中去。由于没有对象拷贝的概念,一个集群对象就是一个在集群堆中的普通对象,行为也和普通对象没有什么区别。任何对集群对象的修改对任何拥有该集群对象引用的对象也是有效的。也就是说,一个引用foo指向的是一个DSO对象bar,另外一个引用baz指向的也是这个DSO对象bar,那么就有foo==baz,而不仅仅是foo.equals(baz)。集群的各个JVM保持的只是对DSO对象的引用和DSO对象数据缓存。
由于保留了对象的唯一性,这使得集群的,多JVM的应用在行为表现上和普通的,单JVM的应用没有什么区别。在集群中保持对象唯一性带来的简洁和强大使得分布式特性从应用程序的设计和实施中剥离出来。分布式行为被推给了terracotta服务器,已经融入了基础架构。就像Java的GC使得内存管理的代码从应用层代码中完全消失了,terracotta使得分布式计算行为也从应用代码中消失。
除了在多个JVM之间分享和同步对象,Terracotta也能够针对非常大的对象图有效得使用本地堆。随着共享对象图不断增大,可能它已经不能够放在单个JVM的堆中了。Terracotta会根据对DSO实例的使用模式对对象图进行剪枝。Terracotta会保持一个对分布式对象图的配置窗口,这样当分布式对象对堆的使用超过一定阈值后就会按照一定的策略被flush out出去。当这些被flush out的对象片段又被使用时,再从terracotta服务器中取出来放到JVM的堆中。你可以把terracotta服务器看成一个无限大的虚拟堆或者网络内存。
这个特性使得无论多么大的对象图都可以被装载进引用程序的堆。它还支持非常灵活的,运行时的数据分区。你可以想象一下,有一个Catalog对象,随着越来越多的product对象的加入,Catalog会越来越大,甚至到达了gigabyte的级别。添加这么多的对象可能要花费几个小时的时间。没有terracotta,把这些数据放到一个JVM的堆中,可能需要这个JVM所在的主机拥有64位4G的内存。为了保持可用性,你还需要至少两台的应用服务器来备份。为了可扩展,你会加入越来越多的应用服务器。没有terracotta这些应用服务器需要各自独立的加载Catalog对象。
由于terracotta可以看成一个巨大的可以无限扩展的网络内存,你可以装进整个Catalog对象,使之成为一个分布式的对象图,而不必关心它的大小。Catalog对象只需要装载一次,这大大减小了应用程序实例的启动的时间。
Terracotta几个典型的应用场景
1. HttpSession复制分发
2. 分布式缓存
3. POJO集群
4. 分布式协作
1,2的应用比较容易理解,不再啰嗦。
POJO集群非常有意思,如果spring框架中引入terracotta,那么spring中的bean就可以分布式集群了。试想一下,bean被分布式了,那么我们可以无缝得扩展我们的web系统,我们的web系统也天然得有了故障转移机制。多么得诱人,很少的代价就使得一个单机系统变成了分布式系统。
Terracotta提供了一个分布式的共享空间,类似JavaSpaces,有了这个共享空间我们可以在系统中共享很多东西,比如任务队列,数据队列等等,很容易构造master/worker 这样的分布式处理模型,这就是terracotta提供给我们的分布式协作能力。同时terracotta还提供了分布式的一些数据结构,courrenthashmap,readwritelock,clylicbarrier等等,使得分布式应用的构建变得更加容易。
文献
[1] http://www.infoq.com/articles/open-terracotta-intro