简介:面向Tomcat8与JDK1.8环境下需要实现分布式会话共享的Java开发者,这份包集合提供将Redis接入Tomcat Session管理的依赖与配置参考,帮助解决多节点部署中会话无法共享、内存存储效率有限的问题。压缩包共6个文件,以4个jar为主,另含1个xml配置示例和1个txt说明,整体约445KB,便于直接放入Tomcat lib目录或纳入项目依赖体系。jar文件覆盖会话管理器、Redis客户端及连接池等运行组件,xml与txt则提供配置和使用线索,适合在集群、负载均衡场景中验证会话一致性。目前已有940人学习,说明该方向具有稳定的实践需求。读者可快速集齐会话共享所需包体,减少逐项查找依赖与版本匹配的时间,并借助配置示例理解序列化、连接参数与存储位置的设置思路,为测试和性能调优打下基础。
1. 单机跑得好好的登录态,一上集群就散架:这套包到底解决什么
单机 Tomcat 跑得好好的应用,前面一挂负载均衡、后面扩到两台,登录态就开始奇怪——刷新一下掉线、下单到一半跳登录页、后台看到 A 节点有会话、B 节点查不到。根源不在业务代码:Tomcat 默认的StandardManager只把会话写在当前 JVM 堆里,进程一换就归零。tomcat-redis-session-manager 这套包集合干的就是这件事,把会话的公共存储换成 Redis,让集群里任意一台 Tomcat 8 都能读到同一份 Session,过期、序列化、并发读写的细节全部交给 Manager 统一托管。它适合已经上到 Tomcat 8、正在做无状态化迁移、又不想立刻把会话整体迁到别的会话框架上的团队;也适合手里只有一台机器,想先把“会话外置”这条路完整跑通再谈扩容的人。
2. 拆包与版本对齐:tomcat8 下这几个 jar 一个都不能错位
拿到包集合第一步不是改配置,是把里面的东西认全。很多“配了不生效”的现场,最后查下来不是参数写错,而是某个 jar 根本没被 Tomcat 的类加载器看见,或者版本对不上导致 Manager 在启动阶段就抛NoClassDefFoundError被吞掉。这一章先把文件职责、版本边界和落盘位置捋清楚,后面配context.xml才有意义。
2.1 包集合里每个文件分别管哪一段
一个能用的 tomcat-redis-session-manager 包集合,核心就是三层:会话管理器本身、Redis 客户端、连接池。三者缺一不可,而且必须放在同一个类加载层级里,否则运行到第一次写会话才会报错,排查起来很被动。
| 文件 | 职责 | 正确落位 | 缺失或错位时的表现 |
|---|---|---|---|
| 会话管理器主包(RedisSessionManager 实现) | 实现 Tomcat 的 Manager 接口,接管会话的创建、查找、写回、过期 | $CATALINA_HOME/lib | context.xml里 className 解析失败,Tomcat 启动日志报ClassNotFoundException,Manager 静默回退到默认实现 |
| Redis 客户端包 | 提供 Jedis 连接与命令封装 | $CATALINA_HOME/lib | 首次写会话时报NoClassDefFoundError: redis/clients/jedis/Jedis |
| 连接池包 | 提供连接复用、空闲回收、借还超时 | $CATALINA_HOME/lib | 启动阶段报NoClassDefFoundError: org/apache/commons/pool2/...,或运行几分钟后连接耗尽 |
| 配置样例 / 源码 | 参考用,不参与运行 | 不部署 | 无影响 |
判断主包里的 Manager 实现类全限定名,最稳的办法是直接翻 MANIFEST 或者解包看目录结构,不要凭记忆抄:
# 列出包里所有带 SessionManager 字样的类,确认实现类的真实包路径 jar tf redis-session-manager-tomcat8.jar | grep -iE 'SessionManager|Manager.class' # 顺便把 MANIFEST 打出来,看 Build-Jdk、Implementation-Version 这些线索 unzip -p redis-session-manager-tomcat8.jar META-INF/MANIFEST.MF第一条命令输出的是类文件路径,把/换成.、去掉.class就是全限定名。示例里我统一写成com.example.tomcat.redissessions.RedisSessionManager,你按自己包里的实际值替换。第二条命令用来确认打包时的 JDK 版本,如果是用 JDK 11 编译、而线上跑的是 JDK 8,启动会直接报UnsupportedClassVersionError,这个错误在catalina.out里很显眼,但很多人第一眼会以为是 Tomcat 版本问题。
2.2 Tomcat 7 的包为什么不能直接丢进 Tomcat 8
这是最容易翻车的一处。会话管理器是贴着 Tomcat 内部 API 写的,它要继承ManagerBase、要操作StandardSession、要自己生成 session id,这些类在 Tomcat 7、8.0、8.5、9 之间的方法签名和可见性都动过。
具体来说,Tomcat 8 把会话 id 的生成逻辑往SessionIdGenerator上收,ManagerBase里若干 protected 方法的参数列表和 Tomcat 7 不一致;StandardSession内部对access、isValid、expire的处理顺序也调整过。用 Tomcat 7 编译出来的包放到 Tomcat 8 上,编译期没问题,运行期会在第一次createSession时抛NoSuchMethodError或者AbstractMethodError——这两个异常的迷惑性在于,堆栈里看不到业务代码,全在 Tomcat 的 valve 链里。
血泪经验是:主包和 Tomcat 的对应关系必须精确到次版本。8.0.x 和 8.5.x 在会话这块不是一个实现,8.0 的包放 8.5 上大概率能启动、能读到已有会话,但新建会话时莫名其妙不落库;反过来 8.5 的包放 8.0 上,可能连启动都过不去。所以拿到包集合先看一眼目录名或 MANIFEST 里标的 Tomcat 版本,对不上就别硬试,换包比调参省时间。
还有一点常被忽略:Tomcat 8 的$CATALINA_HOME/lib里自带tomcat-dbcp.jar,它内部 shaded 了一份连接池实现,但包名是org.apache.tomcat.dbcp.pool2,和标准的org.apache.commons.pool2不冲突。所以连接池包可以放心放进去,不需要为了避让去改包名。真正要小心的是项目WEB-INF/lib里如果也带了一份老版本连接池,双份加载会在运行期出现“池子明明没满却借不到连接”的玄学现象。
2.3 为什么必须落在 $CATALINA_HOME/lib 而不是 WEB-INF/lib
Tomcat 的类加载是分层的:容器自己的类(包括Manager、StandardSession)由 common classloader 加载,Web 应用由 webapp classloader 加载,后者能看到前者,前者看不到后者。context.xml里的<Manager>节点是在容器启动、解析 Context 的时候实例化的,这个时机 webapp classloader 可能还没建立完成,所以 Manager 实现类必须放在容器可见的路径下。
放在WEB-INF/lib的典型症状是:context.xml里 className 已经写对,Tomcat 启动日志里却出现ClassNotFoundException,然后自动回退到StandardManager——注意,是“回退”,不是“启动失败”。也就是说服务照常起来,业务照常能跑,只是会话又回到了单机内存模式,你不做多节点验证根本发现不了。
落盘步骤我一般是这样走的,先备份再覆盖,出问题能一分钟回滚:
# 1) 停服务,确认进程真的退了,避免文件被占用或新旧类混跑 $CATALINA_HOME/bin/shutdown.sh sleep 3 ps -ef | grep -v grep | grep catalina # 2) 备份现有 lib 目录(只备份,不删除) cd $CATALINA_HOME tar -czf lib-backup-$(date +%Y%m%d%H%M).tar.gz lib/ # 3) 把三个包拷进去,注意权限要和目录里其它 jar 一致 cp /path/to/pkg/redis-session-manager-tomcat8.jar $CATALINA_HOME/lib/ cp /path/to/pkg/jedis-*.jar $CATALINA_HOME/lib/ cp /path/to/pkg/commons-pool2-*.jar $CATALINA_HOME/lib/ chmod 644 $CATALINA_HOME/lib/redis-session-manager-tomcat8.jar # 4) 检查有没有同名的旧版本残留,版本冲突基本都藏在这一步 ls -l $CATALINA_HOME/lib/ | grep -iE 'jedis|pool2|session'第三步的chmod 644不是形式主义。用不同账号上传文件时,jar 权限可能变成 600,Tomcat 若以非 root 账号运行就读不到,症状同样是ClassNotFoundException,但你去ls看文件明明在那儿,能查很久。第四步是重点:jedis和commons-pool2只要出现两个不同版本的 jar,类加载顺序不确定,运行期报的错会非常随机。
落位完成后别急着配context.xml,先做一次“类可见性自检”,用 Tomcat 自带的启动参数把类路径打出来,确认 jar 真的进了 common classloader:
# 用 catalina.sh 的调试模式启动,只看类路径那一行,看到就 Ctrl+C $CATALINA_HOME/bin/catalina.sh run -verbose:class 2>&1 | grep -i 'redissession'如果这条命令没有任何输出,说明 jar 没被扫到,先回头查权限和文件名,别往下走。这一步花两分钟,能省掉后面半小时的“为什么 Manager 没生效”。
3. context.xml 里的 Manager 节点:host、连接池和持久化策略怎么配对
jar 落位只是让类能被找到,真正决定行为的是context.xml里那段<Manager>配置。这部分参数不多,但相互之间有约束关系——timeout、maxWait、maxInactiveInterval三个值配反了,表现出来就是“会话偶尔丢”“高并发下登录态抖动”这类很难复现的问题。
3.1 配在哪一层:全局 context.xml 还是单应用
Tomcat 的 Context 配置有三个位置,作用域从大到小:
| 位置 | 作用范围 | 适用场景 |
|---|---|---|
$CATALINA_HOME/conf/context.xml | 该 Tomcat 实例下所有 Web 应用 | 一台机器只跑一个业务应用,或所有应用共用同一套 Redis |
$CATALINA_HOME/conf/Catalina/localhost/<应用名>.xml | 指定应用 | 多应用部署但要分别指向不同 Redis 库,推荐这种方式 |
应用包内META-INF/context.xml | 仅该应用 | 配置跟着制品走,但要确认 Tomcat 没开copyXML,否则会被复制到 conf 下覆盖 |
我一般选第二种。原因很实际:conf/context.xml是全局的,一旦某天在这台机器上加了一个管理后台之类的附属应用,它会莫名其妙也去连业务 Redis,把会话写进同一个库;而放在应用包里的配置,重新部署 war 时如果 Tomcat 开了copyXML,conf 下的副本优先级更高,改了包里的不生效,很容易怀疑人生。
3.2 属性逐项对照
不同分支的包属性名可能略有差异,但下面这些是通用的一组,配之前对着包里 README 核一遍名字。
| 属性 | 示例值 | 说明 | 配错的后果 |
|---|---|---|---|
className | 包内实际全限定名 | Manager 实现类 | 写错则回退默认实现,会话不落 Redis |
host | 10.0.0.21 | Redis 地址 | 建议写内网 IP,不要写localhost之外的域名,避免 DNS 抖动 |
port | 6379 | Redis 端口 | — |
database | 0 | 库编号 | 多应用共用同库会 key 冲突,见第 5 章 |
password | 空或实际密码 | 认证口令 | 空密码写成"",不要漏写这个属性 |
timeout | 2000 | Redis socket 读写超时,毫秒 | 设太大,Redis 卡顿时请求线程全挂住 |
maxActive | 200 | 连接池最大连接数 | 小于并发峰值时出现借连接超时 |
maxIdle | 50 | 最大空闲连接 | 太小会频繁创建销毁连接 |
minIdle | 10 | 最小空闲连接 | 设 0 时流量突增会有冷启动延迟 |
maxWait | 2000 | 借连接最长等待,毫秒 | 必须小于timeout,否则等连接的时间比 Redis 超时还长 |
maxInactiveInterval | 1800 | 会话空闲过期,秒 | 和 Redis TTL 不一致会导致“Redis 里还有、Tomcat 认为已过期” |
sessionPersistPersistencePolicy | ALWAYS | 写回策略,常见取值为每次请求都写或仅变更时写 | 取“仅变更”时,某些框架改的是对象内部状态而非 setAttribute,不会被判定为变更 |
3.3 一份能直接改地址就用的最小配置
<?xml version='1.0' encoding='utf-8'?> <Context> <!-- className 用 2.1 节从 jar 里查出来的真实全限定名替换 --> <Manager className="com.example.tomcat.redissessions.RedisSessionManager" host="10.0.0.21" port="6379" database="3" password="" timeout="2000" maxActive="200" maxIdle="50" minIdle="10" maxWait="2000" maxInactiveInterval="1800" sessionPersistPersistencePolicy="ALWAYS"/> </Context>这段配置里有两个值需要结合业务改。database我用了3而不是默认的0,因为同一套 Redis 往往还跑着缓存、队列,会话单独占一个库,出问题时SELECT 3进去看,不会和业务 key 混在一起。maxInactiveInterval写成 1800 秒,意味着用户半小时不操作就要重新登录,这个值要和产品确认,不要照抄。
如果你的包版本用的是嵌套连接池节点写法,把上面四个池参数挪进子节点即可,其余属性仍挂在<Manager>上:
<Manager className="com.example.tomcat.redissessions.RedisSessionManager" host="10.0.0.21" port="6379" database="3" password=""> <JedisPoolConfig maxTotal="200" maxIdle="50" minIdle="10" maxWaitMillis="2000"/> </Manager>两种写法不要混用。我见过同一份配置里maxActive和maxTotal同时写了两个不同值,最后生效的是哪个取决于解析顺序,压测时连接数怎么都对不上预期。
3.4 timeout、maxWait、maxInactiveInterval 三个值的推导关系
这三个值不是各调各的,它们串在一条时间链上:
maxWait(借连接等待)必须小于timeout(Redis 读写超时)。否则一次请求可能先花 3 秒等连接,再花 2 秒等 Redis,加起来 5 秒还没返回,前端网关早就超时了,用户看到的是白屏而不是“慢”。timeout加上maxWait的总和,要小于 Nginx 或网关的proxy_read_timeout。常见网关默认 60 秒,看起来余量很大,但 Tomcat 的请求线程是有限的(默认 200),一旦有几十个线程卡在 Redis 上,后面的请求就排不上队,表现出来是“整个站点卡住”而不是“某个接口慢”。maxInactiveInterval决定会话在 Tomcat 侧什么时候失效,而 Redis 侧的 key 也有 TTL。理想状态是两者一致,让 Redis 自动清理。如果 Redis TTL 比maxInactiveInterval短,会出现“用户还在操作、会话突然没了”;反过来 TTL 更长,Redis 里会堆积大量已经没人访问的会话 key,内存缓慢上涨。
我一般按maxWait ≤ timeout、timeout + maxWait < 网关超时 / 2这两条硬约束先卡死,再根据压测结果上下微调。保守起点是maxWait=2000、timeout=2000,Redis 在同机房内网时这个值足够,跨机房再往上加。
配置改完重启,别只看“启动成功”四个字。去logs/catalina.out里 grep 一下 Manager 的初始化日志,确认实例化的是你配的那个类,而不是StandardManager:
grep -iE 'Manager|RedisSession' $CATALINA_HOME/logs/catalina.out | tail -20看到 Manager 相关类名出现在日志里,才说明配置真的被解析了。这一步不做,后面所有验证都是在验证默认实现。
4. 序列化边界:Session 里塞进去的对象为什么会一存就炸
会话外置之后,所有放进 Session 的属性都要经过一次序列化才能进 Redis。这一步的失败方式特别隐蔽——不是抛给业务方一个异常,而是在 Manager 内部被 catch 掉,业务侧只看到“这个属性读出来是 null”。所以序列化这块要提前验,不能等联调时靠猜。
4.1 会话写进 Redis 的那条链
一次request.getSession().setAttribute("user", userObj)在背后走的是:StandardSession.setAttribute先更新内存里的属性表并标记 dirty,请求结束时 Manager 判断是否需要持久化,需要则调用序列化器把整个会话(或变更部分)打成字节数组,再通过 Jedis 写进 Redis 的对应 key。
这条链上任何一环失败,业务代码都感知不到。序列化器抛NotSerializableException,Manager 记一条日志,请求正常返回,用户下一次请求读出来的属性就是 null。所以第 4 章的核心不是“怎么配”,而是“怎么在开发阶段把这个错提前暴露出来”。
4.2 JDK 序列化的四条硬约束
这套包默认走的是 JDK 原生序列化,规则很硬:
- 对象必须实现
java.io.Serializable。注意是对象本身,不是它的父类——父类没实现、子类实现了,父类的字段不会被序列化,反序列化后是默认值。 - 所有非
transient的实例字段必须递归可序列化。你的 DTO 里挂了一个java.sql.Connection、一个ThreadLocal、一个 Spring 的ApplicationContext引用,整个对象就废了。 serialVersionUID必须显式声明。不写的话 JVM 会根据类结构算一个,任何一次加字段、改方法签名都会让它变,结果是“旧会话反序列化失败”,而这个错误只在滚动发布期间出现,测试环境单实例根本复现不了。- 内部类、匿名类、Lambda 捕获的
this$0引用是重灾区。一个非静态内部类实例会隐式持有外部类引用,外部类不可序列化,整个对象就不可序列化——报错信息里只会说内部类不可序列化,不会告诉你根因在外部类。
4.3 写个探针把可疑对象先过一遍
与其等联调时一个个属性试,不如在本地写个十几行的小工具,把准备放进 Session 的对象全过一遍。原理很简单:ObjectOutputStream写一遍,ObjectInputStream读回来,能走通就是可序列化的。
import java.io.*; public class SessionSerialProbe { /** * 把对象完整走一遍 JDK 序列化 + 反序列化,返回反序列化后的副本。 * 走不通会抛异常,异常信息会带出具体是哪个字段/哪个类不可序列化。 */ public static Object roundTrip(Object src) throws Exception { if (src == null) { return null; } ByteArrayOutputStream bos = new ByteArrayOutputStream(); try (ObjectOutputStream oos = new ObjectOutputStream(bos)) { oos.writeObject(src); oos.flush(); } byte[] bytes = bos.toByteArray(); try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) { return ois.readObject(); } } public static void main(String[] args) { // 换成你实际要放进 Session 的对象 Object[] candidates = new Object[]{ new UserDTO(1001L, "tester"), new java.util.HashMap<String, Object>() {{ put("role", "admin"); put("loginAt", System.currentTimeMillis()); }} }; for (Object c : candidates) { String name = c.getClass().getName(); try { Object copy = roundTrip(c); System.out.println("[OK] " + name + " -> " + copy); } catch (Exception e) { // 打印完整堆栈,根因通常在 cause 链的最里层 System.out.println("[FAIL] " + name); e.printStackTrace(System.out); } } } }两个细节值得说。第一,roundTrip返回的是副本而不是原对象,这很重要——只有真正写完再读回来,才能发现“写的时候没问题、读的时候字段丢失”这类不对称问题。第二,main里我特意放了一个匿名内部类构造的HashMap(双大括号初始化),这类写法会生成一个匿名子类,如果你的包路径里有不可序列化的东西,它是最早暴露问题的地方之一。
UserDTO长这样,重点是显式serialVersionUID和所有字段类型都简单:
import java.io.Serializable; public class UserDTO implements Serializable { // 显式声明,任何字段调整都手动改这个值,避免滚动发布期间反序列化失败 private static final long serialVersionUID = 2024010101L; private Long userId; private String userName; public UserDTO(Long userId, String userName) { this.userId = userId; this.userName = userName; } @Override public String toString() { return "UserDTO{userId=" + userId + ", userName='" + userName + "'}"; } }跑一遍探针,[OK]的对象才允许进 Session。这一步在开发机上做,成本是一分钟;放到联调环境做,成本是按小时算的,因为你要在几十个属性里二分定位。
4.4 序列化失败的日志长什么样
失败时catalina.out里通常是这样一行:
SEVERE [http-nio-8080-exec-3] org.apache.catalina.session.ManagerBase ... java.io.NotSerializableException: com.example.service.OrderService关键是最后那行类名。它指向的是“不可序列化的那个字段的类型”,不一定是你的 Session 属性本身的类型。比如你把一个 Service 注入到了 DTO 里当字段,报的是OrderService,但问题在OrderDTO。顺着这个类名往上找谁引用了它,比从头 review 代码快得多。
提示:滚动发布时最容易踩
serialVersionUID这个坑。新旧两个版本的类结构只要有一个字段差异,旧会话在新实例上就反序列化失败。要么把serialVersionUID写死并保证兼容,要么发布时清空该应用的会话 key 让用户重新登录。
5. 五条会静默吃掉会话的坑:从 jar 位置到过期时间
下面这五条都是我在不同环境里真实遇到过的,共同点是:服务不报错、监控不告警、只有用户投诉“登录状态保不住”。每条按现象、原因、解决三步写,遇到了可以按顺序对照。
5.1 会话就是不落 Redis,业务一切正常
现象:context.xml配好了,服务正常启动,登录也正常,但redis-cli里一个会话 key 都看不到;扩容到两台后,跨节点访问立刻掉登录态。
原因:Manager 实现类没被容器加载到,Tomcat 静默回退到了StandardManager。触发条件是 jar 放在了WEB-INF/lib而不是$CATALINA_HOME/lib,或者 jar 文件权限是 600 而 Tomcat 以其他账号运行。
解决:先按 2.3 节的类路径自检确认 jar 可见;再 grepcatalina.out里的 Manager 初始化日志,确认实例化的是自定义类。两个都通过了才继续往下查配置。
5.2 Redis 挂掉时 Tomcat 起不来,或起来后疯狂刷异常
现象:Redis 做一次主从切换,Tomcat 重启后卡在启动阶段不返回;或者勉强起来了,日志里每秒几百条连接异常,CPU 被打满。
原因:Manager 在初始化时会尝试建立连接池并做一次连通性检查,Redis 不可达时这一步会阻塞到timeout;而请求路径上每次getSession都要读 Redis,异常被反复抛出又没有降级逻辑,日志量直接把磁盘写满。
解决:一是把timeout和maxWait调小(比如都设 1000),让失败快速返回而不是卡住线程;二是在接入层对“Redis 不可用”做兜底策略,比如允许降级到本机会话,代价是这段时间内的跨节点会话不共享——这个决定要和业务确认,不能自己拍。监控上给 Redis 连接失败次数单独设告警,别等用户投诉才发现。
5.3 会话属性偶尔读出来是 null
现象:用户信息、购物车这类放在 Session 里的对象,大部分时候能读到,偶尔返回 null,刷新一下又好了。
原因:属性对象在某次写回时序列化失败,Manager 吞掉了异常,内存里的属性还在(所以当前请求正常),但 Redis 里存的是上一次成功写入的旧值或者干脆没有。常见触发点是属性对象里挂了一个不可序列化的字段,比如某个 ORM 的代理对象、某个带线程本地变量的工具类。
解决:按 4.3 节的探针在开发阶段全量过一遍;线上排查时把 Manager 相关日志级别调到 DEBUG,观察写回阶段有没有NotSerializableException。另外,把“Session 属性读取为空”在业务侧当成一个显式分支处理,别默认它一定有值。
5.4 两套应用互相把对方的会话覆盖了
现象:同一台 Tomcat 上部署了两个应用,分别是前台和后台,两边登录后互相踢下线;或者redis-cli里看到 key 数量比实际在线人数多一倍。
原因:两个应用都配了database=0,而会话 key 的命名前缀是固定的,没有按应用名区分,于是两边的会话写进了同一批 key 里。
解决:最简单是给每个应用分配不同的database;如果 Redis 实例不允许分库,就在配置里改会话 key 的前缀,让两个应用的前缀不同。改完一定要清一次旧 key,否则存量脏数据会继续引发“随机掉线”,让人误以为没改对。
5.5 用户半小时没操作就被踢,但产品说设的是两小时
现象:maxInactiveInterval明明配的 7200 秒,用户反馈半小时左右就要重新登录。
原因:Redis 侧的 key TTL 由另一处逻辑决定,可能沿用了默认的 1800 秒,也可能被别的清理脚本按固定周期扫掉了。两边不一致时,谁先到期听谁的。
解决:把maxInactiveInterval和实际写进 Redis 的 TTL 对齐,用一次TTL命令验证:
redis-cli -h 10.0.0.21 -p 6379 -n 3 --scan --pattern 'tomcat:session:*' | head -1 \ | xargs -I{} redis-cli -h 10.0.0.21 -p 6379 -n 3 TTL {}返回值应该接近maxInactiveInterval而不是 1800。如果差得远,去查有没有别的定时任务在扫这个库。
6. 上线前的会话验证闭环:redis-cli 里该看到什么,压测时该盯哪几个数
配置改完、服务起来,很多人就直接上负载均衡了。我的习惯是无论多急,上线前必须走一遍下面这个闭环,四步,十分钟,能挡掉九成的会话问题。
第一步,确认 key 真的写进去了。用SCAN而不是KEYS,前者不会阻塞 Redis:
# 用 SCAN 游标遍历,生产环境也不怕 redis-cli -h 10.0.0.21 -p 6379 -n 3 SCAN 0 MATCH 'tomcat:session:*' COUNT 100正常应该能看到一批 key。一个都没有,回到第 5.1 节;有 key 但数量远小于在线人数,回到第 5.3 节。
第二步,确认 TTL 和配置一致:
# 拿上一步返回的任意一个 key,看剩余存活时间 redis-cli -h 10.0.0.21 -p 6379 -n 3 TTL tomcat:session:<你的sessionid>返回值应该接近maxInactiveInterval减去已经过去的时间。如果是 -1(永不过期)或者个位数,说明过期策略没配上,长时间运行会撑爆 Redis 内存。
第三步,做一次真正的跨节点验证。这一步不能靠“看起来正常”,要用 curl 手工带 cookie:
# 第一个请求打到 A 节点,拿到 JSESSIONID curl -i -c /tmp/cookie.txt http://10.0.0.31:8080/app/login # 第二个请求打到 B 节点,带上同一个 cookie,看是否还是登录态 curl -i -b /tmp/cookie.txt http://10.0.0.32:8080/app/profile如果第二个请求返回 302 跳登录页,说明会话没共享成功。这时候不要怀疑配置,先检查两边的database、key 前缀、maxInactiveInterval是否完全一致——三个值里任何一个不同都会导致这个结果。
第四步,压测时盯三个数。第一个是 Redis 的连接数,redis-cli info clients里的connected_clients,它应该稳定在maxActive附近而不是持续爬升,持续爬升说明连接没被归还,通常是某条异常路径漏了close。第二个是 Tomcat 的请求线程数,/manager/status或者 JMX 里看 busy threads,如果长时间接近上限,大概率是timeout设太大导致线程堆积。第三个是会话 key 的增长率,用两次SCAN间隔一分钟对比数量,如果在线用户数稳定但 key 数持续上涨,说明过期没生效或者每次请求都新建了会话。
关于压测脚本,我一般不用现成的工具直接打,而是写个小脚本控制“同一批用户反复访问不同节点”这个模式,因为会话问题只有在跨节点、且 cookie 复用的场景下才会暴露。单节点压测再高的 QPS 也验不出会话共享有没有生效。
最后说一个我自己的教训。早年做一次扩容,配置都改好了,压测也过了,上线第二天开始零星有用户投诉“下单页面跳登录”。查了两天才发现是灰度期间新旧实例的serialVersionUID不一致,旧实例写进去的会话新实例读不出来,而灰度流量只有 5%,所以压测阶段完全没暴露。从那以后我每次滚动发布,都会在发布前把该应用的会话 key 前缀单独列出来,确认新旧版本的 DTO 结构兼容,不兼容就先清空再发。这个动作花不了一分钟,但能挡掉一整类只在灰度窗口出现的诡异掉线。希望帮到你。
本文还有配套的精品资源,点击获取