本文首先介绍集群与负载均衡的简单概念, 然后以 Apache + Tomcat 为例简介如何进行集群配置.

先讲一个故事:

从前, 有一个小公司,就一个员工,自然地他包揽的所有工作,包括接听客户电话,然后进行处理。

后来……,公司业务扩大了,又新增了几名员工。但是,公司电话就只有一部电话,于是老板选出了一位员工 A 专门负责接电话,而其它员工负责业务处理。在这种情况下,我们可以姑且称除 A 以外的其它员工组成了业务处理“集群”,而员工 A 就要负责把客户的要求转发给合适的员工来处理。

这就带来一个问题,A 按什么原则来转发客户要求呢?

一个基本的想法是,谁闲着就给谁干,谁能力强就让他多干点,别让某些人累死,而另一些是闲死,最终的目的当然是让客户要求能够得到最高效的处理。而员工 A 就可以称为 “负载均衡” 调度员。

现在, 应该大概知道到什么是集群负载均衡了吧~

对于网站而言,情形与之类似。

最初的阶段, 我们可能直接用一个 Tomcat 就既干了监听客户端请求和处理请求两项工作,后来… 访问量大了,于是我们雇一位专门的接线员( Apache ),启动更多的 Tomcat 形成集群系统,由 Apache 来接收并转发请求给合适的 Tomcat 来处理, 待业务处理完成再反馈给 Apache, 最后由 Apache 封装后再返回给客户端。

这样做的好处至少有两点:

(1)业务处理能力提高了(我们可以在一台服务器上启动多个 Tomcat 进程,也可以把多个 Tomcat 部署到多台服务器上, 实现分布式部署)

(2)系统更稳定了:万一哪个 Tomcat 挂了,Apache 就让别的 Tomcat 来处理请求就行了

Tomcat 主要的工作就是处理客户端请求,而 Apache 主要负责接收并转发请求,这其实才是他们真正应有的分工

当然, 如果客户端请求的只是一些静态资源 (如: 图片), 对于这样一些粗浅的工作, Apache 就直接干了, 就不到再劳烦 Tomcat 了, 这样也有利于减轻 Tomcat 的压力.

Apache 也可以用 IIS、nginx 或者别的软件替代,使用 IIS 和 nginx 的配置集群不在本文讨论范围内, 感兴趣的话请查阅别的资料。( 本人近年更偏爱 nginx )

等一下…… IIS 的角色不应该更像 Tomcat 吗?

NO! IIS 之所以可以 “处理请求” 那是因为它调用了后端的一个 COM+ 组件。当然,如果你要认为那个处理请求的COM+ 组件是IIS的一部分也行,那 IIS 就成“杂种”了,微软的很多软件都是如此,比如 Access,哎 ~

开始实战 ~

Apache 配置

先安装一个 Apache(接线员),不同版本的 Apache 可能配置不同,这里我们使用的是 2.2.25 版本。

假设 Apache 安装于 D:\Apache2.2\conf

安装之前注意让其它软件把 80 端口让出来,这里要特别注意一下,如果你已经安装了IIS,它默认是占据了80 端口的,这就需要配置一下,叫 IIS 把80端口交出来(怎么配置,问度娘)

80 端口是 HTTP 协议的默认端口, 也就是说当我们访问http://xxxx时其实等同于访问http://xxxx:80

Apache 安装完成后,我们来配置请求转发和负载均衡。

常用的方法有2种:代理方式( Proxy )或 使用 JK , 下面仅介绍代理方式,使用JK的方式也不复杂,读者可自行查阅其它资料。

(1)打开文件 D:\Apache2.2\conf\httpd.conf ,这是 Apache 最重要的配置文件。在文件最后添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
LoadModule proxy_module modules/mod_proxy.so
LoadModule proxy_ajp_module modules/mod_proxy_ajp.so
LoadModule proxy_balancer_module modules/mod_proxy_balancer.so
LoadModule proxy_connect_module modules/mod_proxy_connect.so
LoadModule proxy_ftp_module modules/mod_proxy_ftp.so
LoadModule proxy_http_module modules/mod_proxy_http.so

ProxyRequests Off
<proxy balancer://cluster>
BalancerMember ajp://127.0.0.1:9091 loadfactor=1 route=tomcat1
BalancerMember ajp://127.0.0.1:9092 loadfactor=1 route=tomcat2
</proxy>

Include conf/extra/httpd-vhosts.conf

其中:

  • 1 ~ 6 行的那些代码用来启动一些所需的 Apache 模块

  • 8 ~ 12 行使用代理 ( proxy ) 方式进行负载均衡配置。

    可以看到,我们的集群里有两个干活的(BalancerMember),它们都在本机上,名称分别叫tomcat1tomcat2,按 1:1 的权重 (loadfactor) 分配给它工作,两个 tomcat 分别从90919092端口接收请求 ( 监听 9091 和 9092 端口 )。

    如果 Tomcat 部署在别的机器上,那把127.0.0.1换成对应服务器的IP地址就可以了。

    若要改变工作分配的权重,调整 loadfactor 后面的值就可以了。

    当然,如果 Tomcat 们不在同一台机器上,可以使用相同的端口。

  • 第 14 行引入了一个配置文件,在这个配置文件中将告诉 Apache, 应该将什么样的请求转给上述配置的代理.

(2)打开文件 D:\Apache2.2\conf\extra\httpd-vhosts.conf ,添加如下代码:

1
2
3
4
5
6
7
8
<VirtualHost *:80>
ServerAdmin webmaster@mysite.com
ServerName mysite.com
ProxyPass / balancer://cluster/ stickysession=jsessionid nofailover=On
ProxyPassReverse / balancer://cluster/
ErrorLog "|bin/rotatelogs.exe -l logs/cluster-error-%Y-%m-%d.log 86400"
CustomLog "|bin/rotatelogs.exe -l logs/cluster-access-%Y-%m-%d.log 86400" common
</VirtualHost>

其中:

  • 第 3 行的 “mysite.com” 应替换成你网站网址中紧跟在 http:// 之后的那一段.

    例如:若网站首页是 http://127.0.0.1/index.html,则第 3 行的mysite.com应替换为127.0.0.1,而如果你的网站域名是 www.mysite.com 则应是上述代码中的写法。

    这一部分说明了什么样的请求使用本转发规则.

  • 第 4 ~ 5 行说明了满足上述转发规则的情况下,把请求转发给我们在 httpd.conf 文件中配置的代理来处理。

  • 也许你已经注意到配置中的VirtualHost了,是的,这里是通过在 Apache 中创建虚拟主机来转发请求的。(不明白? 呵呵,没关系,当我没说就行了, 关键是前面 2 点)

OK, 至此,Apache 的配置就完成了,它已经可以把客户端请求把 1:1 的比例转发到本机的 9091 和 9092 端口了,后面就是要配置 2 个Tomcat,让他们分别监听 9091 和 9092 端口, 然后把它们同时启动起来。

Tomcat 配置

不同的 Tomcat 版本配置方法也不尽相同,本例使用的是7.0.52版本, 建议使用解压版本。

(1)把下载到的 Tomcat 压缩包解压 2 份,假设分别放在 D:\TomcatCluster\tomcat1D:\TomcatCluster\tomcat2

以下配置以Tomcat1为例,至于 Tomcat2 使用相同的方法配置即可,不再赘述。

(2)打开 D:\TomcatCluster\tomcat1\conf\server.xml 文件,找到下面呈现的这几行:

1
2
3
4
5
6
7
8
9
<Server port="8005" shutdown="SHUTDOWN">
...........
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />
...........
<Connector port="8009" protocol="AJP/1.3" redirectPort="8443" />
...........
<Engine name="Catalina" defaultHost="localhost" jvmRoute="tomcat1">
  • 第 7 行的代码, 将 8009 改成 Apache 配置第(1)步中的设置值 9091

    关键是 Apache 和 Tomcat 的配置一致,至于用什么端口号只要不被别的程序占用就行

  • 本例中我们将 2 个 Tomcat 放在了同一台机器上,所以应让两个 Tomcat 使用不同的端口.

    例如:第 1 行 和 第 3 行 将 8005 8080 在 tomcat1 的配置文件中设置为 9011 9081, 而在 tomcat2 中配置为 9012 9082.

    如果两个 Tomcat 没有部署在同一台机器上, 那这一步的配置可以不用做, 因为两个 Tomcat 不会端口冲突

  • 注意添加第 9 行的 jvmRoute="tomcat1",这里的 tomcat1 可以认为是这个 tomcat 的名字,要与 Apache 配置第(1)步中的 route 后面的值对应. tomcat2 的配置类似.

通常我们还需要让不同的 Tomcat 共用同一个网站程序位置, 这样可以保证一个网站程序只有一个版本,便于维护。在某些情况下网站还可能接收用户上传的文件,如果多个 Tomcat 各自使用自己的路径来存放,那用户上传的文件就被到处乱丢了,不便于管理。试想,当用户要下载的文件的时候,还得先确定文件在哪个 Tomcat 那里,这不给自己添麻烦吗?

因此,我们做如下配置:

(3)在 D:\TomcatCluster\tomcat1\conf\server.xml 文件中找到如下部分:

1
2
3
4
5
<Host name="localhost"  appBase="webapps" unpackWARs="true" autoDeploy="true">
.......
<Context path="" docBase="D:/webroot/root" reloadable="true"/>
.......
</Host>
  • 注意第 3 行,告诉 Tomcat,网站程序的根目录在 D:/webroot/root 如果每个 Tomcat 都这样配置的话,那大家就共享同一个程序位置了。
  • path 的值不一定是 “”,根据情况,如果你网站的访问地址是 http://www.xxx.com/yyy,那这里应该是 path = "/yyy"

到此为止,如果你的两个 Tomcat 都已经配置好了,它们已经可以正确处理由 Apache 转发来的请求了。

Session 同步

有一个关键问题亟待解决:

通常我们会使用 Session 对象来保存一些会话信息, 例如:用户的登录状态.

此时,因为 Apache 可能将来自同一个客户端的多次请求转发到不同的 Tomcat,而不同的两个 Tomcat 的 Session 并不共享(不在同一个存储空间).

假若你的网站是使用 Session 来保存用户登录状态,那么就会出现用户登录后登录状态信息可能被保存在了 tomcat1 的 Session 中,而 Tomcat2 的 Session 中并未保存此信息, 此时若新的请求被转发到了 tomcat2, 则Tomcat2 并不知道客户已经登录了, 从而要求客户登录。

当然,两个 Tomcat 的 Session 不同步导致的问题不止于此…

怎么办? 两个方法:

方法1:

让 Apache 将同一个客户端发来的请求总是转发给同一个 Tomcat,这样对于同一客户来说,它总是和同一个Tomcat 交互,所以也就不会存在上述问题了。

但是,试想,如果某一个客户一直和 Tomcat1 打交道,而 Tomcat1 好死不死就挂了呢? 那此客户的 Session 也就自然丢失了。

这种方式 “部分” 解决了负载分配的问题,但始终使用的是单个 Tomcat 为特定的客户服务。

按方法1的思路只需在 Apache 中改变一下配置即可,具体方法请查阅其它资料。

方法2:

让每个 Tomcat 都保存一个 Session 的复本,这样即使某个或某几个 Tomcat 挂了,只要不是全部挂了,那客户的 Session 信息仍不至于丢失。

当然,这就需要解决一个问题:多个 Tomcat 之间的 Session 同步

OK,下面就来看如何实现 Session 同步……

(4)在第 1 个Tomcat 的配置文件 D:\TomcatCluster\tomcat1\conf\server.xml 中找到 <Engine> .... </Engine>部分,上面的步骤我们为节点添加了 jvmRoute="tomcat1" 属性,现在在 <Engine>节点中插入代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<Engine name="Catalina" defaultHost="localhost" jvmRoute="tomcat1">
.......
<Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster" channelSendOptions="6">
<Manager className="org.apache.catalina.ha.session.BackupManager"
expireSessionsOnShutdown="false"
notifyListenersOnReplication="true"
mapSendOptions="6"/>

<Channel className="org.apache.catalina.tribes.group.GroupChannel">
<Membership className="org.apache.catalina.tribes.membership.McastService"
address="228.0.0.4"
port="45564"
frequency="500"
dropTime="3000"/>

<Receiver className="org.apache.catalina.tribes.transport.nio.NioReceiver"
address="auto"
port="4001"
selectorTimeout="100"
maxThreads="6"/>

<Sender className="org.apache.catalina.tribes.transport.ReplicationTransmitter">
<Transport className="org.apache.catalina.tribes.transport.nio.PooledParallelSender"/>
</Sender>

<Interceptor className="org.apache.catalina.tribes.group.interceptors.TcpFailureDetector"/>
<Interceptor className="org.apache.catalina.tribes.group.interceptors.MessageDispatch15Interceptor"/>
<Interceptor className="org.apache.catalina.tribes.group.interceptors.ThroughputInterceptor"/>

</Channel>

<Valve className="org.apache.catalina.ha.tcp.ReplicationValve"
filter=".*\.gif|.*\.js|.*\.jpeg|.*\.jpg|.*\.png|.*\.htm|.*\.html|.*\.css|.*\.txt"/>

<Deployer className="org.apache.catalina.ha.deploy.FarmWarDeployer"
tempDir="/tmp/war-temp/"
deployDir="/tmp/war-deploy/"
watchDir="/tmp/war-listen/"
watchEnabled="false"/>

<ClusterListener className="org.apache.catalina.ha.session.ClusterSessionListener"/>
</Cluster>
..............
</Engine>
  • <Cluster....>.....</Cluster> 部分 ( 3 ~ 42 行 ) 是插入的内容,你可以把上述部分的代码复制并插入到你 server.xml 文件的 <Engine>...</Engine>
  • 第 18 行的端口号4001 对于同一台机器上的两个 Tomcat 应设置为不同值。( 例如: 可把 Tomcat2 设置为 4002 )
  • 其余地方无须更改

上面这么一大段代码是告诉 Tomcat,你现在要以群集方式工作了,你的状态应该以广播的方式通知其他 Tomcat,至于细节暂时无须理会。

(5)最后,千万记得在你的项目的 web.xml 文件的 <web-app ... >...</web-app> 中插入 <distributable/>, 告诉 Web 容器,这个项目是工作在分布式模式下的。

最后的最后…… 提醒那些对网站服务器进行了安全配置的同学,记得把上述第(4)步配置代码中第 18 行的端口 4001 开放,别被防火墙或安全策略挡住了,不然的话,各个 Tomcat 之间是无法通信的。

按上述同样的方法配置第 2 个 Tomcat, 注意端口!


华丽丽的分隔线之后,开始测试~

(1)新建一个名为 test.jsp 的文件,把它放在:D:/webroot/root 文件夹下, 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<%@ page contentType="text/html; charset=GBK" %> 
<%@ page import="java.util.*" %>
<html><head><title>Cluster App Test</title></head>
<body>
Server Info:
<%
// 输出当前Session的ID
out.println("Session ID = " + session.getId() + "<br/>");

// 如果有新的键值对,则将其保存到 Session 属性
String dataName = request.getParameter("dataName");
if (dataName != null && dataName.length() > 0) {
String dataValue = request.getParameter("dataValue");
session.setAttribute(dataName, dataValue);
}

// 列出当前Session中所有的键值对
out.println("<h3>Session 列表</h3>");
System.out.println("============================");
Enumeration e = session.getAttributeNames();
while (e.hasMoreElements()) {
String name = (String)e.nextElement();
String value = session.getAttribute(name).toString();
out.println( name + " = " + value+"<br/>");
System.out.println( name + " = " + value);
}
%>

<form action="test.jsp" method="POST">
键:<input type=text size=20 name="dataName"/><br/>
值:<input type=text size=20 name="dataValue"/><br/>
<input type="submit"/>
</form>

</body>
</html>

这个网页提供了一个表单,可以输入一个键值对,服务器端脚本把键值对保存在Session中,并列表输出当前所有已保存的键值对。

(2)新建一个名为 web.xml 的文件,把它放在:D:/webroot/root/WEB-INF 文件夹下。 代码如下:

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:web="http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
version="3.0">
.......
<distributable/>
.......
</web-app>

这其实是你网站项目的配置文件, 关键是第 8 行。

OK,把 Apache 和 所有的 Tomcat 都启动起来吧……( 若 Apaceh 在此前已经启动,那重启一下 )

在浏览器中打开 http://localhost/test.jsp

如果一切正常 ( 但愿 RP 没问题 ),应该可以看到:

(1)无论刷新多少次,网页中显示的 Session ID 应该不会变,这说明无论 Apache 把你的请求转给了哪个 Tomcat,你的 Session 都被保持住了。 另外,在 Session ID 值的后缀部分可以看到你的第一次请求是由哪个 Tomcat 来处理的。

(2)试着填一下键值对,提交,应该可以保存下来

(3)只保留一个 Tomcat,把其它的全部停掉,再刷新页面,应该可以看到网页仍然可以打开,并且 Session 的值没有丢失

(4)启动另一个 Tomcat,启动完成之后,把原先未停止的 Tomcat 关闭,应该可以看到网页仍然可以打开,并且 Session 的值没有丢失

呵呵,这回爽了吧,只要还有一个 Tomcat 活着,那你的网站都可以正常访问,并且 Session 不会丢失。

当然,如果有很多个 Tomcat 一起工作,那负载会被分配到不同的 Tomcat 上,实现负载均衡。

谢谢观赏! 花絮更精彩……


迫不及待地把你之前做好的项目部署上去玩集群了吧?

呵呵,是不是发现你的项目弄上去后 Session 中存储的东西会丢啊?

这回彻底崩溃了…… 为什么上面的例子正常,偏偏就自己的项目出问题…… 难道是 RP 问题 ?

嘿嘿,Tomcat 7 的官方文档中关于集群配置有这样一段话,翻译过来大意是:

使用集群配置时,放进 Session 里的对象必须实现 java.io.Serializable 接口。

如若不然,Session 其实也没丢失,只是取出来的对象的属性值就会全部变成 null ……

因为 Tomcat 在进行 Session 广播的时候会对 Session 中存储的对象进行序列化操作,别的 Tomcat 接收到之后再反序列化还原出原来的对象。若存储进 Session 的对象不能正确序列化,自然就不能正确的广播给别人。前面的测试之所以可以成功,是因为放进 Session 里的是 String,而 String 是实现了 Serializable 接口的。

哈哈,这还不简单,我们都知道 java.io.Serializable 接口其实是个空接口,声明一下不就行了嘛!

呵呵,举个例子,你的修改后的代码可能很像下述样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.io.Serializable;

public class UserInfo implements Serializable {

private String id;
private String name;

public void setId(String id) {
this.id = id;
}
public String getId() {
return this.id;
}
public void setName(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
}

把用户信息 ( userInfo ) 存入 Session:

1
2
3
4
5
6
<%
UserInfo userInfo = new UserInfo();
userInfo.setId("001");
userInfo.setName("Zhang");
request.getSession().setAttribute("userInfo", userInfo);
%>

从 Session 中取出userInfo(通常下列代码不会跟上面的代码放在一个页面吧,呵呵~):

1
2
3
<%
UserInfo userInfo = request.getSession().getAttribute("userInfo");
%>

这行了吧,再试…… 晕…… 还是不对……

仍然有问题,可以从 Session 中取出原先存储的 userInfo 对象(并不为 null ),但是似乎又不是原来那个, id, name 的值变成 null 了……

呃…… 再研读一下 JDK 的说明书,人家说了,即使实现了Serializable接口,但出于安全性考虑,序列化过程只对 public 属性进行处理,而那些 protected, private 的属性统统不理会.

这回知道了吧,得把 idname 变成 public 的…… 修改一下,再试,这回总行了吧……

呵呵,如果 RP 真的没问题,应该 OK 了~

也许你在想,如果把所有的属性都变成 public 不是有失封装性原则嘛……

嘿! 谁让你全部变成 public 了,你可以只把 id 声明为 public 的嘛,如果需要name则根据id去数据库里取嘛,呵呵……

这里需要考虑性能安全的平衡,自己考量吧……

跟你说了花絮更精彩了吧,很多人估计是被最后这步坑死的,呵呵~

再次谢谢观赏~

小二,上字幕!

如果一个接线员忙不过来,能不能再多雇几个呢?

当然可以,但是,多个接线员的关系应该是树状层次的关系,而非平级关系。也就是说,对外只有一个接线员,而这个接线员负责把请求转发给别的接线员,然后再转发……

Why? 因为…… 网站用户访问某个网页时,请求的目的地是非常明确的,客户端发来的请求只会发送到某一个 IP 地址的某个端口上。

也就是说,对于客户,它只知道唯一的一个客服电话号码,而公司也只有一部对外的电话,所以,只能把接线员们架构成树状形式,由顶层接线员来接电话,然后再转接给别的接线员。

怎么弄? 自己想…