本文首先介绍集群与负载均衡的简单概念, 然后以 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),它们都在本机上,名称分别叫tomcat1和tomcat2,按 1:1 的权重 (loadfactor) 分配给它工作,两个 tomcat 分别从9091和9092端口接收请求 ( 监听 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\tomcat1 和 D:\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 的属性统统不理会.

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

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

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

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

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

​

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

​

再次谢谢观赏~

小二,上字幕!

​

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

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

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

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

怎么弄? 自己想…