1. 单机瓶颈是最先遇到的那堵墙
先讲一个我身边发生过的场景。某个业务系统上线半年,功能稳定,代码质量也不算差,部署方式就是一台 8C16G 的云主机上跑一个 Tomcat,前面挂 Nginx,数据库单独一台。刚开始每天几百个请求,Tomcat 轻轻松松。后面前端页面加了实时数据轮询,加上推广渠道带来一批用户,某天下午四点多,运营群里开始有人截图反馈:页面一直在转圈,登录接口要等十几秒才返回。
我登上服务器一看,Tomcat 的 localhost_access_log 里响应时间普遍从几十毫秒飙升到几秒,再 jstack 看线程栈,大量线程都卡在 http-nio-8080-exec-* 的数据库连接等待上。其实当时问题已经不只是 Tomcat 单点瓶颈,还有数据库慢查询、连接池不够、GC 频繁等一系列连锁反应。但作为一个 Java Web 服务,单台 Tomcat 的物理边界就在那里:默认 maxThreads 只有 200,再算上线程切换、JVM 堆内存、GC 停顿,一台机器能扛住的并发是有限的。CPU 加核、内存扩容能缓解,但加到最后成本越来越高,而且机器总有宕机风险。
所以真正的解法是做集群:多台机器上跑多个 Tomcat 实例,前面用负载均衡把流量分发到不同节点,这样单台机器挂了不会直接导致整个应用不可用。但集群部署不是简单地把 Tomcat 复制几份就能跑起来的,做过的人都知道,这里有两个坎必须迈过去:
一个是流量怎么分,也就是负载均衡;另一个更隐蔽——多个 Tomcat 之间怎么保证用户 Session 不丢。用户第一次请求在节点 A 上登录了,Session 存在节点 A 的内存里;下一次请求被 Nginx 分发到节点 B,节点 B 的内存里根本没有这个 Session,应用就会认为用户未登录,于是出现“明明登录了却反复跳回登录页”的诡异现象。
这篇文章就以我实际部署过的方案为主线,从单机到双节点、从负载均衡到 Session 共享,把完整的配置、排错思路和上线验证方法都讲清楚。适合所有正在准备生产环境部署的 Java 后端开发,也适合面试前把“Tomcat 集群”这个知识点真正弄明白的人。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 集群拓扑怎么选:先画一张不绕弯的架构图
在做具体配置之前,先把目标架构定下来。Tomcat 集群部署说起来可以有很多花样,但真正值得长期维护的拓扑并不复杂。我最终搭出来的形态是这样:
客户端请求先到 Nginx(或者云上的负载均衡器),Nginx 按策略把请求转发给后端的多个 Tomcat 节点;这些 Tomcat 节点都不再本地保存用户 Session 的“唯一真身”,而是把 Session 数据统一放到一个独立的 Redis 中。这样任何一个节点都能接住任意用户的请求,因为 Session 数据在共享存储里,而不是在某台 Tomcat 的内存里。
这个拓扑适合大多数业务场景。原因有三点:
第一,Tomcat 节点之间完全对等,没有主从关系。任何一个节点宕机、重启、发新版本,流量都可以被其他节点接住,用户无感知。
第二,水平扩容非常直接。流量涨了,再起一台 Tomcat,加进 Nginx 的 upstream 就行,不需要调整已有节点的任何配置。
第三,应用状态和服务器实例解耦。Session 数据在 Redis 里,Tomcat 实例维护不维护本地状态就不重要了,这对后面做容器化、自动化发布都是很大的优势。
如果是同机做多实例测试,或者只有两台低配机器,拓扑不变,只是 Tomcat 实例可以放在同一台机器上。这种情况下有个细节要特别注意:同一个 Tomcat 安装目录不能直接启动两份,否则端口会冲突。你应该复制出两个独立实例,分别调整三个关键端口:8005 是关闭 Tomcat 的管理端口,8080 是 HTTP 服务端口,8009 是 AJP 端口。同一台机器上两个实例如果都用默认端口,第二个实例肯定启动失败,报 Address already in use。
我一般习惯建两个目录,每个目录放独立的 conf、logs、temp、webapps、work,然后用 CATALINA_BASE 指向各自的目录启动。例如:
bash复制export CATALINA_HOME=/opt/tomcat
export CATALINA_BASE=/opt/tomcat-instance1
$CATALINA_HOME/bin/startup.sh
实例 1 的 server.xml 里改成:
xml复制<Server port="8005" shutdown="SHUTDOWN">
<Service name="Catalina">
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" />
<Connector port="8009" protocol="AJP/1.3" redirectPort="8443" />
</Service>
</Server>
实例 2 对应改成 8006/8081/8010,保证不冲突。这样就得到了两个完全独立的 Tomcat 服务。多机部署就不用考虑端口冲突,但生产环境我仍然建议把实例目录和日志目录分开,方便出了问题定位。
3. 负载均衡落地:Nginx upstream 配置与流量验证
负载均衡的选型,现在基本不用纠结。公司有云负载均衡产品就直接用云上的 SLB;没有的话,Nginx 是成本最低、资料最多、最容易排查的选择。本文以 Nginx 为例。
Nginx 的负载均衡核心是 upstream 块。下面是我实际用过的配置,先放出来,再逐个参数解释:
nginx复制upstream tomcat_pool {
least_conn;
keepalive 32;
server 10.0.0.11:8080 max_fails=3 fail_timeout=30s weight=1;
server 10.0.0.12:8080 max_fails=3 fail_timeout=30s weight=1;
server 10.0.1.13:8080 max_fails=3 fail_timeout=30s weight=2 backup;
}
server {
listen 80;
server_name www.example.com;
location / {
proxy_pass http://tomcat_pool;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
upstream 里默认的调度方式是轮询,每个请求按顺序轮流分给后端节点。如果各节点性能有差异,可以用 weight 调整比例,比如 8C 的机器 weight=2,4C 的机器 weight=1。least_conn 表示优先分给当前活跃连接数最少的节点,适合请求处理时间波动大的业务,比如有些接口 50ms 就返回,有些接口要查报表等两三秒,用 least_conn 比纯轮询更均衡。如果完全做了 Session 共享,我推荐就用 least_conn,它比 ip_hash 更灵活。
ip_hash 是另一种选择。它的效果是同一个客户端 IP 固定打到同一台 Tomcat,在没做 Session 共享的旧架构里很常见。但它有两个明显问题:一是负载可能倾斜,比如某个公司出口 IP 下所有员工都固定到一台节点;二是节点宕机后这部分用户的 Session 照样救不回来。所以它只是“缓解” Session 问题,不是真正的集群方案。
max_fails 和 fail_timeout 是 Nginx 对后端节点的被动健康检查参数。含义是:在 fail_timeout 秒内,如果向后端节点转发请求失败次数达到 max_fails,Nginx 就认为该节点不可用,并在接下来的 fail_timeout 时间内不再把流量分给它。我这里的配置意思是 30 秒内失败 3 次就摘除节点 30 秒。需要说明的是,这种检查不是主动探活,它只有流量打到节点且失败时才会累计失败次数。如果是长时间没有请求的冷备节点,挂了也不会被发现,直到流量来了才会被标记摘除。想要主动检查,云上负载均衡一般自带健康检查,Nginx 开源版要做主动探活需要额外模块,这里不展开。
配置里还有几个很容易被忽略的 Header 设置。X-Real-IP 和 X-Forwarded-For 是为了让后端 Tomcat 拿到用户的真实 IP,否则应用日志里看到的全是 Nginx 的内网地址,排查安全问题时会非常痛苦。X-Forwarded-Proto 也很关键,如果 HTTPS 证书在 Nginx 上终止,后端 Tomcat 走的是 HTTP,应用里如果用 request.isSecure() 或者根据协议拼接回调地址,会出现“明明用户用的是 https,应用生成的回调链接却是 http”的问题。Tomcat 侧配合这样一段配置:
xml复制<Valve className="org.apache.catalina.valves.RemoteIpValve"
internalProxies="127\.0\.0\.1|10\.0\.0\.0/8"
remoteIpHeader="x-forwarded-for"
proxiesHeader="x-forwarded-by"
protocolHeader="x-forwarded-proto" />
这段 Valve 让 Tomcat 信任 Nginx 传过来的 X-Forwarded-For,并覆盖 request.getRemoteAddr() 的结果。一定要把 internalProxies 配成 Nginx 所在的内网段,否则任何人都可以伪造 X-Forwarded-For 头,绕过真实 IP 记录。
配置完成后,验证负载均衡是否生效的方法很简单:分别看几个 Tomcat 节点的 logs/localhost_access_log,然后从外部多刷几次页面。正常情况下,日志会被 Nginx 轮流写到不同节点上。也可以用这条命令快速统计:
bash复制cat logs/localhost_access_log.txt | awk '{print $1}' | sort | uniq -c
如果所有来源 IP 都是同一台 Nginx 的地址,说明 RemoteIpValve 没生效,需要检查 Nginx 是否传了 X-Forwarded-For,以及 Valve 里的 internalProxies 是否匹配。
4. 会话共享的岔路口:粘滞、复制还是集中存储
负载均衡解决之后,Session 共享就是下一个决定成败的问题。很多人在测试环境配好 Nginx 和两台 Tomcat 后,信心满满地发版,结果线上用户大面积掉线,就是因为只解决了“流量分出去”,没解决“会话跟过来”。
先理解 Session 在 Tomcat 里到底是什么。每次调用 request.getSession(),Tomcat 都会在本地内存里建一个 StandardSession 对象,并生成一个 JSESSIONID 写入浏览器 Cookie。下一次请求带上这个 Cookie,Tomcat 通过 JSESSIONID 在本地内存的 Session 池里找。问题是,这个池子只存在于单个 Tomcat 节点内。Nginx 把请求分给了节点 B,节点 B 的内存池里自然没有节点 A 创建的会话。
针对这个问题的常见解法有三条路,我把它们放在一个表里对比:
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Session 粘滞 | 通过 Nginx 的 ip_hash 或 sticky cookie,让同一用户请求固定到同一台 Tomcat | 配置简单,不引入额外组件 | 节点挂掉后会话丢失;负载可能倾斜;不是真正的高可用 | 会话不敏感、可容忍掉线的小系统 |
| Tomcat 集群会话复制 | Tomcat 节点间通过组播或 TCP 同步 Session 数据,每个节点都有全量副本 | 无需额外中间件,纯 Tomcat 内实现 | 会话量一大多播同步开销大;节点多时有广播风暴;只适合 2-3 台小规模 | 小规模、内网、Session 量少的场景 |
| 集中式会话存储 | Session 统一放到 Redis/Memcached,所有节点从共享存储读写 | 节点无状态,任意分发,真正的高可用和水平扩展 | 多引入一个中间件;Session 需要序列化;Redis 本身也要做高可用 | 生产环境、分布式架构、容器化部署 |
Session 粘滞其实不叫“共享”,它只是让 Session 尽量不被别的节点接收。早年很多项目用 ip_hash,原因就是图省事。它的代价是:粘滞节点一旦宕机,这个节点上的所有用户会话全部丢失,Nginx 虽然能把后续请求转发到健康节点,但用户必须重新登录。另外,移动网络环境下用户 IP 会变(WiFi 切 4G),一变就可能被分到另一台节点,依然要重新登录。所以从我经历的项目来看,粘滞只能作为“还没做共享存储之前的过渡方案”,不建议当成最终架构。
Tomcat 自带的集群会话复制需要理解一下,因为很多 Tomcat 教程里会提到。它的做法是在 server.xml 里给 Engine 或 Host 加一段 <Cluster> 配置,让节点之间通过 DeltaManager 把 Session 的增删改广播给集群里的其他节点。每个节点内存里都保存所有 Session 的副本,所以任何一个节点都能处理任何一个用户的请求。听起来很完美,但实际跑起来会发现问题:假设你有 3 个节点,每个节点活跃 Session 有 5 万个,某次请求把某个用户 Session 里的一个属性改了,这个变更要同步给其他 2 个节点,越到后面同步的 Session 总量越大。当用户量和写频率上来后,网络里全是 Session 同步报文,Tomcat 的工作线程也会被复制逻辑拖慢。所以我只在实验室环境或者 2 节点且 Session 量极小的项目里用过它。
而集中式 Session 存储的思路就清爽得多:Session 数据放 Redis,不放在任何一台 Tomcat 内存里。Tomcat A 创建了会话,写入 Redis;下一个请求被 Nginx 分到 Tomcat B,B 从 Redis 里把同一个会话读出来即可。Tomcat 节点彻底变成无状态 Web 容器,它的角色只是执行 Java 代码,不承担用户上下文。这也是 Spring Session、shiro 等框架普遍推荐的方向。
5. 把 Session 放进 Redis:Spring Session 配置与序列化避坑
下面给出我在 Spring Boot 项目里集成 Redis 会话共享的完整步骤。用 Spring Session 代替 Tomcat 原生 Session 存储,核心逻辑是:Spring Session 提供一个过滤器,接管 HttpServletRequest.getSession() 的调用,返回一个“伪装”的 Session 对象,这个对象的实际数据存储在 Redis 里。
第一步,引入依赖。以 Maven 为例:
xml复制<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
第二步,在配置类上启用 Redis Session 管理:
java复制@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 1800)
public class SessionConfig {
@Bean
public LettuceConnectionFactory connectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
config.setHostName("10.0.2.15");
config.setPort(6379);
config.setPassword("yourpassword");
return new LettuceConnectionFactory(config);
}
}
这里 maxInactiveIntervalInSeconds 是 Session 的默认过期时间,设为 1800 表示 30 分钟。这个值要和原来 web.xml 里配置的 session-timeout 保持一致,否则会出现应用里设置的超时时间和 Redis 键的过期时间不一致,用户以为还在有效期,Redis 里数据却已经没了。
如果项目里已经有了 RedisConnectionFactory 的 Bean,比如通过 spring-boot-starter-data-redis 自动配置,那第二步的 connectionFactory 方法可以省略,Spring 会自动注入已有的连接工厂。
第三步,用 Redis 里的会话之前,要确保 Session 中保存的对象可以被序列化。这是最容易踩坑的地方。Spring Session 默认会用 JDK 原生序列化把 Session attribute 写到 Redis,要求你放进 Session 的每个对象都实现 java.io.Serializable。如果你往 Session 里塞了一个没实现 Serializable 的自定义对象,运行时直接抛 NotSerializableException。更隐蔽的问题是,就算实现了 Serializable,JDK 序列化结果是一串二进制,里面包含类的完整包名和内部结构。一旦某次发版改了类名、包名、字段名,老用户 Redis 里的旧数据反序列化就会失败,表现成用户突然无法访问或者接口报错。
为了让 Redis 里的数据可读、跨版本更稳,我建议把默认序列化方式改成 JSON:
java复制@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
配置好之后,可以连到 Redis 上验证:
bash复制redis-cli
127.0.0.1:6379> keys 'spring:session:*'
127.0.0.1:6379> type spring:session:sessions:8a2c3d...
你会看到 Session 数据确实落到了 Redis。以后不管 Nginx 把请求转发到哪个 Tomcat,只要它们连的是同一个 Redis,就能读到同一个 Session。
这里还要提醒一点:Redis 本身也要考虑高可用。如果 Redis 挂了,所有节点的 Session 都读不出来,那比单机 Tomcat 挂掉更严重。生产环境至少要给 Redis 做主从和自动故障转移,或者直接使用云上的 Redis 服务,不要裸跑一个单节点 Redis 就上生产。
另外,在非 Spring 的老项目里想实现同样的效果,会比较痛苦。没有 Spring Session 的自动接管,你有几条路可以走:一是把登录态主动从 Tomcat Session 里挪出来,只往 Session 里存一个用户唯一的 token,真正的用户信息放到 Redis,每次请求时从 Redis 查;二是用 Tomcat 的 PersistentManager 配置自定义 Store,把 Session 持久化到 Redis,但 Tomcat 原生没有官方 Redis Store 实现,GitHub 上那些第三方库大多年久失修;三是自己写一个 Filter 拦截 request.getSession(),本质上模拟 Spring Session 做的事。综合考虑,如果老项目要改造,最省力的其实是第一种思路——升级应用层的登录状态管理,而不是死磕 Tomcat 的 Session 存储。大项目的最终形态基本都是无状态接口 + Redis Token,而不是 Servlet 容器层面的 Session 共享。
6. 上线前自测与故障演练:让故障在白天暴露出来
静态配置写好了,不代表集群真的可用。我见过太多项目,配置没问题,但没做故障验证,等线上出了问题才知道某个环节漏了。所以上线前一定要做一轮系统性的自测和故障演练。
第一项,验证 Session 是否真的共享。最简单的方法:用浏览器登录系统,从浏览器开发者工具里复制当前 SESSION 或 JSESSIONID 的 Cookie 值,然后手动把 Nginx 转发到的后端节点断掉。具体操作是在 Nginx 上把某个 upstream 节点注释掉并 reload:
nginx复制upstream tomcat_pool {
least_conn;
server 10.0.0.11:8080 max_fails=3 fail_timeout=30s;
# server 10.0.0.12:8080 max_fails=3 fail_timeout=30s; # 模拟宕机
}
执行 nginx -s reload 后,继续在页面上操作。如果 Session 共享生效,页面几乎无感知;如果没生效,用户会被强制踢回登录页。这里有个细节:浏览器 Cookie 里的 JSESSIONID 在 Spring Session 接管后可能变成 SESSION,两个 Cookie 同时存在也不奇怪,因为它们来自不同的 Session 处理机制。
第二项,验证负载均衡失效节点摘除。把一台 Tomcat 直接 kill -9,然后不断访问应用,观察 Nginx 日志。你会发现连接失败的次数累计到 max_fails 后,Nginx 不再把请求分给故障节点。摘除不是瞬间完成的,需要等几个请求失败。这个过程在生产环境会造成少量 502 或 504,但对用户来说,只要后续请求能成功,影响就可控。
第三项,做一次简单的并发压测,顺便给 JVM 参数做参考。压测工具用 ab 就够:
bash复制ab -n 10000 -c 200 -k http://www.example.com/test
-n 10000 表示总请求数,-c 200 表示并发数,-k 表示开启 Keep-Alive。压测时盯两个指标:一个是 Nginx 返回的失败率,正常应该接近 0;另一个是后端各个 Tomcat 节点的 CPU 和 GC 情况。如果某个节点 CPU 明显高于其他节点,先看是不是负载均衡策略问题,再看应用的线程池配置是不是合理。
Tomcat 线程池参数在 server.xml 的 Connector 里,我常用的配置是:
xml复制<Connector port="8080"
protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443"
maxThreads="400"
minSpareThreads="20"
acceptCount="200"
maxKeepAliveRequests="100" />
maxThreads 不是越大越好。线程越多,CPU 上下文切换越频繁,反而降低吞吐。一般先给 200 到 400,结合压测结果调整。同时 JVM 堆内存也要给足,否则压测跑几分钟就开始 Full GC,表现就是接口响应时间周期性抖成一条“波浪线”。启动脚本里至少加上:
bash复制JAVA_OPTS="-Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200"
这里 -Xms 和 -Xmx 设成一样,避免运行期堆动态扩容带来的停顿。
压测阶段还有个常见误伤:压测工具没有模拟浏览器 Cookie,每次请求都是新会话,几万并发跑下来,Redis 里会堆积大量无人认领的 Session。虽然 Spring Session 有后台清理机制,但突发流量下 Redis 内存会涨得很快。所以压测前最好确认接口不创建 Session,或者把压测场景设置为带 Cookie 的连续请求。
第四项,检查日志里的 IP 和后端状态。生产上出问题需要定位用户请求具体落在哪台 Tomcat,强烈建议在 Tomcat 的 access log 里加上响应时间字段,方便和 Nginx 日志对比。server.xml 里 Host 下的 AccessLogValve 可以调整 pattern:
xml复制<Valve className="org.apache.catalina.valves.AccessLogValve"
directory="logs"
prefix="localhost_access_log"
suffix=".txt"
pattern="%h %l %u %t "%r" %s %b %D" />
%D 是请求处理耗时,单位毫秒。有了它,排查慢请求时能准确判断时间是花在 Nginx 到 Tomcat 的路上,还是 Tomcat 自身处理上。
7. 写在最后的一次真实教训
最后讲一个我印象很深的失误。有次给客户做集群改造,Nginx、Redis、两台 Tomcat 都配好了,内网测试一切正常,Session 也能共享。结果上线第二天,有用户反馈“登录状态一会儿有一会儿没有”。查了很久才发现,客户现场有多个应用部署在同一域名下,应用 A 没接入 Spring Session,还在往 JSESSIONID 里存用户登录态;应用 B 接入了 Spring Session,用的是 SESSION Cookie。两个应用的前端跳转时,把对方的 Cookie 带了过去,服务端又各自按自己的 Session 键去找,自然找不到。
这个问题的根源是多个应用混用时没有统一会话方案。教训就是:Session 共享不是把某一个应用的数据搬进 Redis 就完事,要站在整个域名、整条业务链路的角度看哪些应用共用一个会话体系。前端跳转到不同应用时,Cookie 能不能带上、域名和路径是否匹配、会话 Key 是否一致,每一点都要列在检查清单里。
集群部署本身并不难,难的是你想清楚每一步在解决什么问题。负载均衡解决的是“请求往哪去”,Session 共享解决的是“用户状态从哪来”。如果只做前者,系统一旦流量上来就会以另一种方式出故障;如果只做后者,单点风险依旧存在。把这两件事一起搞定,再经过故障演练验证,Java Web 服务的集群才算真正立住了。以我个人的经验,先把应用改造成无状态,让 Session 进 Redis,再去做多实例和负载均衡,这个顺序踩坑最少。
