Tomcat源码分析—集群原理

Tomcat源码分析—集群原理

这篇文章是我在2011年时写的,现转到我自己的博客上

关于集群的配置请看Tomcat集群配置一文

配置集群包含了以下一些类:
SimpleTcpCluster
DeltaManager
GroupChannel
McastService
NioReceiver
ReplicationTransmitter
PooledParallelSender
TcpFailureDetector
MessageDispatch15Interceptor
ReplicationValve
JvmRouteBinderValve
JvmRouteSessionIDBinderListener
ClusterSessionListener

Tomcat配置的集群从配置文件来看分两种,一种是应用应用级别的,一种是底层的,也就是tribes框架
当Tomcat启动完后,会多出一些线程:
1.线程池,默认是6个,这是用于NIO接收使用的,发送线程也会使用这个线程池
2.组播接收线程
3.组播发送线程
4.心跳线程

tribes有一个NIO发送线程和一个NIO接收线程,都是用于处理应用级别的,比如session,持久化类的传输的
组播发送线程很简单,就是不停的发送自身的状态,这里发的是一个UDP组播,默认每500毫秒发一次,告诉一个组播内的机器自己还存活,组播发的
是一个很小的字节流,只有69个字节,里面的信息都是经过优化的:

当对方接到这个字节流后,会按照固定的属性解码,这里的ID是唯一的,这很重要,这样保证了每个Member在一个组播内肯定是唯一的,不会两个
ID一样的机器。

组播的类图如下:

一个MembershipImpl就代表组播内的一个成员,所有的成员都是交给Membership管理的,MucastServiceImpl有两个内部类,分别是接受线程和发送线程,他们的作用不断的接受组播内的成员MemberImpl以及不停的向组播内其他成员发送自己的信息,告诉其他人自己还存活。

首先看一下组播的发送线程:

这里没有类图和时序图,因为很简单,就一个send()方法,发送后睡眠一段时间继续发送,这里发送的是UDP广播
socket.send(p);
这个socket就是MulticastSocket

成员的管理有:成员的加入,成员的删除,成员的过期删除,心跳检查几种情况,下面分别介绍
首先看一下成员的加入时序图:

接收的工作是由 MucastServiceImpl的内部类 ReceiverThread去做的,当它接受到组播内发来的数据后,先解码成一个MembershipImpl,然后调用Membership的memberAlive()判断这个成员是新成员还是老成员,如果是新成员就加入,如果是老成员,就更新访问时间。
最后会开启一个临时的新线程,经过一条条调用链到达SimpleTcpCluster,SimpleTcpCluster其实并没有做什么多余的工作,只是简单记录日志而已,
在这些调用链上最有用的还属TcpFailureDetector,它会判断当前的MembershipImpl是否在Membership的列表里,如果不在的话,就执行一个socket的检查,看看远端的主机是否存在,如果存在的话,就将它加入到Membership中。

组播成员的丢弃时序图如下:

删除会有两种情况,一是接收到组播内其他成员的通知,然后执行删除,还有一个就是成员的存活时间超时了,也会被删除。现以超时删除为列,首先
接收线程会调用checkExpired()执行超时检查,这个超时检查又会委托给Membership去做,当Membership检查到它内部的某个成员已经超时了,就将它从自己的列表中删除,最后会开启一个临时的线程经过一些调用到达SimpleTcpCluster,SimpleTcpCluster也没有什么多余工作,只是简单记录了日志,和成员增加一样,这个调用链上最有用的也是TcpFailureDetector,它会再次检查一下远端的主机是否真的不可达到,如果是的话,再调用
Membership删除这个成员。

心跳管理的目的是定期更新成员的访问时间,不然的话就会被组播接收线程的超时检查给删除了。如果检查到某个成员不在Membership列表里,就会
执行一次scoket远端主机是否存在的检查,如果远程主机不存在就删除此成员。
心跳管理的时序图如下:

心跳线程是在GroupChannel中定义的类HeartbeatThread,它会调用GroupChannel,然后由GroupChannel负责调用它下面的拦截器,最后会调用
到一个NIO的读取动作,这是检查一个keepalive的动作,不过这段没明白是什么意思。心跳检查的核心是在TcpFailureDetector里,它会执行一个
performBasicCheck()检查,首先是调用Membership的memberAlive(),这个方法的作用是检查成员是否在列表里,如果不在就加入这个成员,否则就更新成员的访问时间。当加入这个成员后,TcpFailureDetector还会执行一个socket检查,看看远端主机是否存在如果存在就调用一个继续通知上层加入成功了,否则就将这个成员从Membership中删除。

组成员关系介绍完了,下面来看看上层的应用是如何利用底层的代码的,首先看一下启动的过程,定义在上层的元素包括了vavle,监听器,manager,以及SimpleTcpCluster,这里先介绍SimpleTcpCluster,先看看它的启动过程,这个元素在配置文件中可以定义在引擎节点下,也可以定义在主机节点下,所以启动的时候是由主机或者引擎负责启动它的。
SimpleTcpCluster的启动时序图如下:

启动的时候先由StandardHost调用到SimpleTcpCluster,接着SimpleTcpCluster调用两个注册addMembershipListener()和addChannelListener(),都是将自身增加到GroupChannel,然后将GroupChannel,这样就完成了应用的代码到tribes代码的过度。GroupChannel接着开启拦截器,默认注册的话,会有TcpFailureDetector和MessageDispatch15Interceptor两个拦截器,GroupChannle会分别开启它们。当执行到MessageDispatch15Interceptor#start()后,会调用它自身的startQueue(),然后创建线程池。以后发送线程也是利用这个线程池去工作的。
接着MessageDispatch15Interceptor会调用ChannelCoordinator的internalStart(),开始创建发送和接收线程。
ChannelCoordinator首先调用NioReceiver的start()方法,它会根据配置文件里面设置的参数,调用bind()方法,这时注册的就是非阻塞的IO,接着它也会创建线程池。同时将自身作为一个新线程开启(它继承了Runnable接口)。
下面ChannelCoordinator会调用McastService的setLocalMemberProperties(),由McastService创建一个MemberImpl,这个MemberImpl有唯一的ID号,而且要保证在一个组播内是唯一的,以后进行主机身份判断都是通过这个ID的。
ChannelCoordinator接着调用ReplicationTransmitter的start(),这个类就是在配置文件中定义的,它会调用PooledParallelSender的connect(),这个类也是在配置文件中定义的,这是用来发送多个节点的类。connect()的作用的notify()队列,表示可以用了。
最后ChannelCoordinator会调用 McastService的start(),而McastService会配置一些自身的MembershipImpl信息,然后调用McastServiceImpl,由McastServiceImpl开启接收的组播线程和发送的组播线程。

集群配置的manager是DeltaManager,它的启动过程如下:

 

DeltaManager是由StandardContext负责启动的,启动后首先注册MBean,然后调用父类ManagerBase的generateSessionId(),这个方法会返回一个sessionID,这个ID是用随机算法创建的,很奇怪的是,DeltaManager并没有使用它的返回值,仅仅只是调用一个而已,不清楚为什么要这样做。
之后DeltaManager会创建一个SessionMessageImpl,用来获取组播内所有节点的session数据,这个发送工作委托给SimpleTcpCluster去做,发送工作也是比较复杂的调用,这里省略。waitForSendAllSessions()是等待若干时间,等待接收所有节点的session数据,最后处理这些接收数据。
发送并不是一个广播,而是从成员列表里面找第一个成员,向这个成员获取所有的session数据,当远程的主机收到这个消息后,DeltaManager会收到这个消息,然后调用handleGET_ALL_SESSIONS()方法处理,这会将自己机器上的所有session都发送出去,当然包括了序列化等一系列工作了,所以这个发送接收数据量是比较大的,tomcat只选择了一台机器就索取session就比较合理。
远端机器发送了之后,本机器当然需要处理了,这最后会调用到DeltaManager的handleALL_SESSION_DATA(),由它来处理接收到的所有session数据。它会读取有多少个session,然后创建一个数组,接着遍历读取,先反序列化读取session中的所有属性,最后将这个session增加到ManagerBase中。
集群启动获取session的简单描述如下:

 

当一个节点新增一个session的时候,会像其他节点发送一个通知,告诉它们有一个节点创建了session,并将sessionID发送给对方。如果配置了JvmRouteBinderValve,这个valve判断了sessionID由另一个节点发给自己时,会根据这个sessionID重新生成一个新的ID,就是通过字符匹配算法,但是不会重新生成新ID。当过滤器执行完后,ReplicationValve会触发一个备份操作,将当前节点的所有session都备份到其他机器上。
首先看第一部分,Session数据的发送时序图:

 

当页面请求Request获取一个session,如果找不到的话,就会新创建一个,DeltaManager会委托给ManagerBase创建一个新session,当创建好了之会后会将Session的数据信息封装到一个SessionMessageImpl对象中,以后发送的就是这个对象了,此时由于是新创建按的session,所以session里面没有包含其他数据,只是一个空的session。发送过程是交给SimpleTcpCluster做的,SimpleTcpCluster会调用tribes的GroupChannel,GroupChannel会调用它下面的拦截器,TcpFailureDetector会有一个异常捕获,如果远程机器不可能达到,会创建一个socket检查一下远端机器是否存在,如果不存在就将这个成语从Membership列表中删除。发送的最后一个拦截器是MessageDispatch15Interceptor,它会调用自身的addToQueue(),然后会新建一个线程类,将这个线程交给线程池运行:

到这里发送的第一步就完成了,下面来看看发送的第二部,也就是真正发送数据的一些操作,这个线程是由线程池调用的
发送数据第二部时序图:

 

线程池会调用MessageDispatch15Interceptor的sendAsyncData(),MessageDispatch15Interceptor算是拦截器里的最有一个链了,所以会将这个发送交给协调者ChannelCoordinator去做,ChannelCoordinator又调用ReplicationTransmitter,这个类就是在配置文件中注册的,实际上这个类不是用来发送数据的,真正发送数据的是它下面定义的子元素里面的类PooledParallelSender,PooledParallelSender又是调用ParallelNioSender类去完成发送的,在ParallelNioSender里面会执行一个循环,它会拿到一个成员数组,然后挨个向每个成员发送一次数据,这个真正发送的工作是由NioSender完成的,NioSender类里有一个write()方法,这个方法里面会使用SocketChannel#write()真正的向socket写数据,写过之后另一个主机就可以获取的数据了。

以上介绍了发送数据的一系列过程,其实并没说说到session数据备份,会话粘贴的使用,这些工作是由valve完成的,会话粘贴是请求第一次达到主机后会由JvmRouteBinderValve经过一系列操作然后向其他主机发送ID信息,而数据备份是在ReplicationValve里完成的。
session数据发送经过Valve的过程时序图如下:

 

这里最核心的两个valve就是ReplicationValve和JvmRouteBinderValve,当请求经过AJP连接器后先到达ReplicationValve,ReplicationValve先将请求转给下一个valve去处理,也就是JvmRouteBinderValve,这个vavle首先计算出发过来请求的ID,比如请求从A主机达到,A的sessionID是
1234567890.node1,此时JvmRouteBinderValve会经过一些字符串匹配计算,得到一个新的id,1234567890.node2,然后将这个新的ID和老的ID一起组成一个消息,也就是SessionIDMessage,最后会委托SimpleTcpCluster,将这个消息发送给组播内的机器,实际上这个valve并不是备份数据的,而是告诉其他机器,当出现宕机的情况下,请求就需要和某个机器的绑定,也就是前端apache配置了mod_jk的时候,当后台某个机器宕机了,就需要将会话和某个固定的机器绑定,这个valve就是干这个事的。
备份数据的任务是由ReplicationValve去做的,当JvmRouteBinderValve执行完了,就会回到ReplicationValve,它首先从Request中获取一个sessionID,然后通过这个ID得到session,再将这个session给序列化,如果session中包含了一些自定义的持久化类,又会触发自定义持久化类的
writeObject()方法,持久化完之后会返回一个byte[],然后根据这个byte[],封装成一个ClusterMessage对象,最后将这个ClusterMessage交给SimpleTcpCluster完成发送。
这里有一个疑问,假设A和B两台机器上session数据都是相同的,都是10条,现在在B机器上增加了一个新session,那么会发送新增的一条session,还是会发送所有的session?答案是只发送和当前请求相关联的session,也就是一条session。仅仅只是将这个session中所有的内容进行
序列化然后发送给其他机器,其余10不会发送。也就是相当于没请求一次就会触发一次session数据备份。当设置HttpSession的setAttribute()时,并不会触发session的备份,也就是说和session数据设置没有关系,仅仅和请求有关联。

介绍完了发送,再来看看接收的过程,接收数据又分监听是处理接收数据两步,先看看处理接受数据,接收到的数据最后会交给DeltaManager,由它判断这个数据怎么处理,是新创建,还是丢弃,还是setAttribute()
处理接收数据的时序图:

 

 

处理接收过程首先是从已经注册到的NIOkey中获取一个可以读的KEY,然后使用SocketChannel去读数据,可以看到读取过程是和NIO的处理方式类似的,最后将读取到的ByteBuffer数据拷贝到ObjectReader对象中,再从这个ObjectReader对象返回一个ChannelMessage对象,这个对象就是上层代码需要处理的数据,此时如果注册了ACK应答机制的话,还会给对象发送一个消息,告诉他我已经收到数据了:
if (ChannelData.sendAckAsync(msgs[i].getOptions())) sendAck(key,channel,Constants.ACK_COMMAND);
接着由ReceiverBase将处理数据交给上层,首先是ChannelCoordinator,经过一些拦截器后达到GroupChannel,GroupChannel再把数据交给SimpleTcpCluster,SimpleTcpCluster通过注册的ClusterSessionListener,就将数据交给了DeltaManager,DeltaManager会对数据类型进行判断,看看是新创建的session,还是session增加属性,还是session丢弃:

 

可以看到这里包含了很多类型,其处理方式都是差不多的,可以参照普通的session处理方式,这里介绍一下session的新建,设置和丢弃。
1.如果是新建的话, 就简单创建一个空的DeltaSession,然后将id等信息赋值过去就可以了,最后增加到ManagerBase中。
2.如果是设置session值的话,首先根据SessionMessage的ID从ManagerBase中找到对象的session,如果找到的话,就将SessionMessage中的
数据反序列化然后,再由这个反序列化的数据设置到session中,此时会调用到StandardSession的setAttribute(),如果有自定义的Listener,
还会触发自定以的Listener。
3.如果是session丢球的话,首先调用DeltaSession的expire()方法,DeltaSession又会调用父类StandardSession,这样就会从ManagerBase中
删除,并且触发自定义的Listener的删除方法。DeltaSession之后会调用DeltaManager,将这个丢弃的session广播出去,于是DeltaManager
委托给SimpleTcpCluster去完成。
经过以上步骤后,session的拷贝工作就完成了。

刚才说到了处理处理数据的过程,再处理数据之前还有一个监听读取socket,然后封装数据的过程
下面看一下监听线程的时序图:

监听线程的过程相对比较简单,当它检查到有数据可读后,就调用自己的readDataFromSocket()方法,然后从任务队列中拿到一个NioReplicationTask,任务队列就是LinkedList,这里面保存了若干个NioReplicationTask(如果是BIO监听的话就是BioReplicationTask),
当任务不够了且没有超过上限就会新建一个任务,当获取一个任务后就从队列中删除,最后NioReceiver会调用ThreadPoolExecutor#execute(),将
任务交给线程池,由线程池去执行,这也就是之前说的处理任务的过程了。

再看一下后台的session失效检查步骤,当session实效后会将这个实效的session广播出去
session失效检查时序图:

 

 

和普通的session一样,由ManagerBase去做这个实效检查的操作,然后调用具体session的isValid()方法,这里就是DeltaSession的isValid(),当它检查到自己的session已经失效了就调用父类StandardSession将这个session从内存中删除,然后触发一些session删除的Listener,之后调用DeltaManager的sessionExpired(),将这个失效的session广播出去,于是DeltaManager又调用SimpleTcpCluster,完成最后的发送。
这里我们发现,后台判断的删除操作和接收到过期session之后的删除操作后面的步骤有些类似,也就是说当A机器上的session失效了,它会将这个已经实效的session广播出去,B和C接受到之后会将这个session从自己机器上删除,然后B和C也会发出一个广播(除非正好这个检查的session也过期了,就不会发出广播),告诉组播内的其他机子自己的session过期了,那么这样会不会引起循环通知呢?不会的,因为DeltaManager初期过期session是这样做的:

 

 

它会先从内存中找,如果找不到了,就不会发送广播通知,A收到了B和C的广播通知后,会检查自己机器上是否有这个ID,因为没有这个ID,所以它就不会发这个广播了,因为不会造成循环广播。

最后来看看集群的类的关系图,首先是监听器的类图:

 

这个很简单,这两个类就是在配置文件中定义的类
再是拦截器的类图:

 

拦截器在调用的时候用的是责任链模式,如TcpFailureDetector后面一个拦截器是MessageDispatch15Interceptor,在注册的时候就通过父类的
setNext完成,这样以后就是用父类的getNext().sendMessage()就完成了对下一个拦截器的调用。

下面是Valve类图:

 

很简单,集群没有自定自己的Valve抽象都,都是继承自ValveBase的。
下面是一些使用和没使用(就是在配置文件中默认没有定义的)的类图:

差点忘了,还有集群的停止,集群的停止包括对DeltaManager的stop和对SimpleTcpCluster的stop(),Manager的关闭是由上下文调用的,SimpleTcpCluster的stop是由主机或者引擎调用的。所以manager的关闭是先执行,它会发送一个session失效的组播通知,而SimpleTcpCluster关闭的时候会关闭和集群相关的一些线程和线程池。
下面是集群关闭的时序图:

首先是DeltaManager的关闭,它由StandardContext调用,关闭时先获取当前机器的所有session,然后判断当前的session是否失效了,如果已经失效的话,就将这个session从ManagerBase中移除,这会触发一些session关闭的Listener,然后委托SimpleTcpCluster发送一个广播通知,通知其他机器本机的session已经失效了。如果当前的session没有失效就主动调用它的失效方法,将它失效掉,同样也会触发广播通知。
DeltaManager停止完后,就是主机或者引擎调用SimpleTcpCluster的关闭了,首先它会调用GroupChannel的stop,然后关闭心跳线程,会
GroupChannel会调用它下面的拦截器,当调用到MessageDispatch15Interceptor后,会关闭线程池。而MessageDispatch15Interceptor又会调用它之后的协调器的关闭方法,协调器则会关闭NIO的监听线程,然后将发送线程给销毁。最后调用组播McastService#stop(),这会将McastServiceImpl的内部类组播接收线程和组播发送线程给关闭,当关闭这两个线程后,McastServiceImpl还会发送一个UDP广播,告诉其他机器本机已经退出了组播,到此为止,集群的关闭就结束了。

关于集群源码分析的补充:
server.xml中配置集群有这么一段:
<Cluster className=”org.apache.catalina.ha.tcp.SimpleTcpCluster”
channelSendOptions=”8″>
默认是8,表示异步发送,如果4的话,就是同步发送了,异步发送我们已经介绍过了,就是MessageDispatch15Interceptor将数据放到线程池中,然后由另一个线程发送。
那么同步呢?同步就更简单了,这里我简单的用一个堆栈图描述一下就可以了,请看下图:

MessageDispatch15Interceptor不会将数据交给线程池了,而且继续委托调用父类的发送,最后会到ChannelCoordinator,再由这个协调器调用PooledParallelSender#sendMessage(),后面的过程就和之前介绍的发送第二部过程一样了,这里就不介绍了,所以同步其实很简单,就一个线程发送,异步是另起一个线程。

2 次阅读

发表评论

电子邮件地址不会被公开。