Tomcat 集群实战:Nginx 负载均衡、Redis 会话共享与故障排查

单台 Tomcat 上线第一周就暴露了问题:一次发版重启,业务断了十几分钟;访问量一上来,CPU 直接顶到 100%。趁着业务还在爬坡期,把架构从单节点扩成了两节点集群——过程里踩的坑,比配置本身值得记录得多。

一、从单机到两节点

目标拓扑很常规:

1
2
3
用户 → Nginx(lb01) → upstream → web01 / web02 (Tomcat)
└──→ NFS 共享静态资源
└──→ Redis 会话存储

新节点的部署不是重装一遍,而是从老节点复制(前提是上一步的目录规范做得好):

1
2
3
4
5
6
7
# web01 上把程序目录和 systemd 配置推到 web02
scp -rp /soft 172.18.1.8:/
scp /usr/lib/systemd/system/tomcat.service 172.18.1.8:/usr/lib/systemd/system/

# web02 上建立软链接、重载并启动
ln -s /soft/apache-tomcat-9.0.34/ /soft/tomcat
systemctl daemon-reload && systemctl start tomcat && systemctl enable tomcat

静态资源要共享,不然图片”时有时无”

博客系统有上传目录(附件、头像),如果两个节点各存各的,用户刷新一下换到另一台机器,图片就 404。解决办法是所有节点挂载同一个 NFS 目录:

1
2
3
4
5
6
7
# NFS 服务端
cat /etc/exports
/data/zrlog 172.18.1.0/24(rw,sync,all_squash,anonuid=666,anongid=666)

# 所有 web 节点挂载到应用的附件目录
mount -t nfs 172.18.1.31:/data/zrlog /zrlog/ROOT/attached/
echo "172.18.1.31:/data/zrlog /zrlog/ROOT/attached nfs defaults 0 0" >> /etc/fstab

注意 anonuid/anongid=666 要跟 Web 运行用户的 uid/gid 对上,否则写文件会报权限拒绝——这是挂载 NFS 共享上传目录最常见的坑。

Nginx 侧接入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# /etc/nginx/conf.d/proxy_zrlog.conf
upstream zrlog {
server 172.18.1.7:8080;
server 172.18.1.8:8080;
}

server {
listen 80;
server_name blog.example.com;

location / {
proxy_pass http://zrlog;
include proxy_params;
}
}

proxy_params 是我单独维护的公共代理参数文件,所有代理站点统一 include:

1
2
3
4
5
6
7
8
9
10
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_connect_timeout 30;
proxy_send_timeout 60;
proxy_read_timeout 60;
proxy_buffering on;
proxy_buffer_size 32k;
proxy_buffers 4 128k;

有个细节值得说:Nginx 默认会把请求头里的 Host 改写成 $proxy_host(也就是 upstream 里写的名字或 IP)。所以不管后端是 Tomcat 还是 Nginx,只要不显式透传,后端日志里看到的 Host 都可能不是用户实际访问的域名。想让后端保留真实域名,必须显式配置 proxy_set_header Host $http_host——这也是 proxy_params 第一行的由来。

二、HTTPS 接入:证书放在哪一层

证书统一放在负载均衡层,后端继续跑 HTTP,这样证书只维护一份:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
server {
listen 443 ssl;
server_name blog.example.com;
ssl_certificate ssl_key/server.crt;
ssl_certificate_key ssl_key/server.key;

location / {
proxy_pass http://zrlog;
include proxy_params;
}
}

server {
listen 80;
server_name blog.example.com;
return 302 https://$server_name$request_uri;
}

三、会话保持:轮询之后,用户开始”随机掉登录”

这一节是这次扩容最大的教训。

两台 Tomcat 配好轮询之后,问题来了:用户登录后刷新页面,经常又跳回登录页。原因是默认的会话(session)存在单机的内存里——第一次请求落到 web01,session 存在 web01;下一次请求被轮询到 web02,web02 不认识这个 session,就当未登录处理。

会话保持的几种方案,我们对比过:

方案 原理 问题
ip_hash 按客户端 IP 固定后端 出口 IP 相同的用户全压一台;节点故障时登录态丢失
session 复制 Tomcat 集群自动同步 session 节点超过 4 个性能急剧下降
统一会话存储 session 存 Redis/MySQL 需要改造,但最稳

生产上选的是 Redis:多台 Tomcat 共享一份会话数据,任意节点宕机,用户换到另一台照样是登录状态。

接入步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1. 下载 tomcat-cluster-redis-session-manager(开源项目,GitHub 可搜)
wget https://github.com/ran-jit/tomcat-cluster-redis-session-manager/releases/download/4.0/tomcat-cluster-redis-session-manager.zip
unzip tomcat-cluster-redis-session-manager.zip

# 2. jar 包进 Tomcat 的 lib
cp tomcat-cluster-redis-session-manager/lib/* /soft/tomcat/lib/

# 3. 配置文件进 Tomcat 的 conf,改 Redis 地址
cp tomcat-cluster-redis-session-manager/conf/redis-data-cache.properties /soft/tomcat/conf/
vim /soft/tomcat/conf/redis-data-cache.properties
redis.hosts=172.18.1.51:6379

# 4. 在 conf/context.xml 的 </Context> 前加两行
<Valve className="tomcat.request.session.redis.SessionHandlerValve" />
<Manager className="tomcat.request.session.redis.SessionManager" />

改完一台,用 rsync 无差异同步到另一台,再重启 Tomcat。升级后的效果:随便停哪台节点,用户无感知,请求被自动转到另一台。

一定记得让两个节点的配置完全一致——session 序列化方式、jar 版本只要有一点不同,就会出现”这台能登录、那台报错”的诡异现象。

四、故障排查实战

集群跑稳之后,值班时遇到的高频故障就是这几类,逐个记一下排查路径。

1. 启动失败:先看三件事

1
2
3
4
5
6
# 1) 服务状态
systemctl status tomcat
# 2) 启动日志(最直接)
tail -100 /usr/local/tomcat/logs/catalina.out
# 3) 实在看不出,前台启动看完整报错
/usr/local/tomcat/bin/catalina.sh run

高频原因就三个:端口被占(Address already in use,ss -ntlp | grep 8080 找进程)、JAVA_HOME 没配好、war 包本身有问题。启动成功后的验证动作:ss -lntp | grep 8080 && curl -I http://127.0.0.1:8080。

2. CPU 100%:从进程找到那行代码

1
2
3
4
top -c                  # 找到最耗 CPU 的 java 进程
top -Hp <PID> # 进程内按线程排序,记下最高的线程号
printf '%x\n' 12345 # 转成十六进制,jstack 里线程 ID 是这个格式
jstack <PID> | grep -A 20 "0x3039" # 直接看线程栈

栈顶如果是业务代码 → 大概率死循环,找开发修;如果是 GC 线程 → 回到堆内存问题。这套”进程 → 线程 → 栈”的路径,比看监控图猜要快得多。

3. 响应慢 / 线程池满:先看访问日志的耗时

1
2
3
4
5
6
# 访问日志里 %D 字段是耗时(微秒),超过 3 秒的单独看
awk '{if ($NF > 3000000) print}' /usr/local/tomcat/logs/localhost_access_log.*.txt | tail

# 看线程池状态
jstack <PID> | grep "http-nio" | wc -l # 活跃 HTTP 线程数
jstack <PID> | grep -c "RUNNABLE"

线程数长期贴着 maxThreads、大量线程 BLOCKED,就是线程池打满的信号。往下追通常是慢 SQL 或者第三方接口没设超时——这两个占我们遇到的慢请求原因的一大半。

五、复盘

这次集群改造最值钱的不是配置本身,而是暴露出来的两个认知:

  1. 有状态的服务不能无脑加节点——session、上传文件、本地缓存,任何”存在本机”的东西,都会在集群下变成故障源;
  2. 排查顺序固定下来能省时间——启动问题看日志、CPU 问题看线程栈、慢请求看耗时字段,先分类再动手,比上来就重启强。

后来我们给集群加了两条约束:任何新服务上线前确认”状态是否外置”,发版必须逐节点滚动并验证,这两条规矩到现在没出过事故。