ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Redis的Tomcat会话共享:原理、配置与踩坑指南

基于Redis的Tomcat会话共享:原理、配置与踩坑指南

简介:针对分布式系统下Tomcat会话无法跨节点共享的痛点,tomcat-redis-session-manager是面向Java Web后端开发者的开源会话管理组件。它通过实现Tomcat的SessionStore接口,将用户session持久化至Redis,从而在负载均衡环境中维持会话一致性,尤其适用于需要多活部署、应用弹性扩容的Web服务场景。压缩包总体约1.99MB,共含40个文件,其中20个Java源码文件用于理解扩展实现,15个依赖JAR包(如Redis客户端与连接池)提供运行支撑,5个XML配置示例展示不同环境下的接入方式;整体目录分类明确,便于按需取用。针对Tomcat 7/8/8.5/9及JDK 1.7/1.8,包内分别准备了适配目录,内含对应版本的JAR和context.xml片段,可帮助读者快速对照自身运行环境选择合适分支,并直接复制配置进行集成;通过阅读源码也能深入理解SessionStore接口如何对接Redis、设置过期策略等关键细节。目前已有611人学习/下载,适合希望为现有Tomcat应用接入统一会话存储的中高级开发者。

1. 十年前的 Tomcat 会话共享组件,为什么 K8s 化、上云时反而又火起来

两个 Tomcat 实例前面挂一个负载均衡,用户刚在 A 节点登录,下一跳被分发到 B 节点,却发现自己又变回未登录状态。这是传统 Servlet 项目在上云、容器化、水平扩容时最先暴露的老毛病:默认 HttpSession 存在单个 JVM 堆里,节点之间天然隔离。tomcat-redis-session-manager的核心思路,是把 HttpSession 的创建、读写、过期机制整体搬到 Redis,让多个 Tomcat 共享同一份会话数据,顺带解决单机重启丢会话的痛点。它适合还在维护老 Java Web 工程、又不想重构业务为无状态、也不想引入 Spring Session 大规模改造的团队;运维和后端都能在不动应用代码的前提下接入。这篇就把它的生命周期、配置参数、落地步骤和常见坑一次讲透。

2. 工作机制与选型依据:Valve、Manager 和序列化器如何分工

2.1 一个请求进来后,Session 在 Tomcat 与 Redis 之间是怎么流转的

很多人以为这个项目只是把 session 从内存换到 Redis,实际没那么简单。Tomcat 的会话管理走的是 Manager + Valve 这套 SPI:Manager 负责生成本地会话对象、维护会话生命周期,Valve 则像过滤器一样挂在请求管道上,在请求结束前把“被改过的会话”同步回存储层。

我常用一段简化时序来理解它。下面这段不是项目的真实源码,只是为了把调用关系讲明白:

// 理解用的简化请求时序,不代表真实实现 public class RedisSessionValve extends ValveBase { private RedisSessionManager manager; public void invoke(Request request, Response response) { String sessionId = findSessionIdFromCookie(request); RedisSession session = (RedisSession) manager.findSession(sessionId); try { // 放行到后面的 Servlet / Filter getNext().invoke(request, response); } finally { // 请求结束后,只把“被改过”的会话写回 Redis if (session != null && session.isDirty()) { manager.save(session); } } } }

关键在isDirty()这个标志:Tomcat 在session.setAttribute()时会把会话标记为“已修改”,Valve 在 finally 里发现这个标记后才执行 Redis 写入。也就是说,每个请求最多只会把被改过的会话同步一次,没有改动的请求不会重复写 Redis。这也是它比“每次请求全量复制 session”的方案更省流量的原因。

所有会话默认按字节流形态存进 Redis,key 一般带一个前缀,常见是tomcat:session:加上会话 ID。会话属性对象的读取发生在findSession()那一刻,反序列化后放进当前节点的 JVM 堆;写入发生在请求结束前。这个“读在后、写在尾”的模型决定了后面很多坑的根源:跨节点之间看到的 session 并不同步到毫秒级,而是“请求结束才可见”。

2.2 为什么不用粘性会话,也不一定非要改造 Spring Session

很多团队第一反应是给负载均衡开 sticky,让同一用户的请求永远打到同一台 Tomcat。这个做法在只有两台节点时很顺手,但节点重启、扩缩容、发布时照样丢会话,而且容器环境下负载均衡工具未必愿意一直维持长连接和 cookie 路由。换成tomcat-redis-session-manager之后,后端节点可以比较随意地上下线,会话本体在 Redis 里,谁处理这个请求都能把会话捞回来。

下面这张表是我在做选型时经常用的对比:

维度粘性会话tomcat-redis-session-managerSpring Session
是否支持非粘性负载不支持支持支持
应用代码改造量几乎为零零代码,只改配置需要换 Session API、加依赖、处理序列化
节点重启后会话丢失Redis 里还在Redis 里还在
对老旧 Servlet 项目友好度高高一般
排障成本简单需要懂 Redis 与序列化需要排查 Spring 容器
对 Redis 故障的敏感度无高高

Spring Session 功能强、生态好,但它对老项目意味着引入 Spring 依赖体系,还需要把应用内所有getSession()的写法逐步迁移到 Spring Session 的 API,工作量不可小看。如果目标只是“多节点共享会话”,同时还想保留 Servlet 原有写法,tomcat-redis-session-manager这类 Valve + Manager 方案是性价比最高的选项。

2.3 序列化方式决定你能往 Session 里塞什么

这个项目本质上是把 Session 对象变成字节流。默认情况下,它使用 JDK 序列化,也就是要求所有会话属性实现java.io.Serializable,否则在请求结束时save()会抛出异常。而且 JDK 序列化对类版本敏感:某个属性的类如果改了字段,老会话反序列化时可能直接失败。

常见的做法是,在 fork 版本支持的前提下,把序列化器换成 Kryo 或 JSON。Kryo 序列化体积小、速度快,但和 JDK 序列化一样对类结构变化敏感;JSON 好读、容错强,但对循环引用和时间类型支持不统一。配置通常是这样:

<Manager className="com.orangefunction.tomcat.redissessions.RedisSessionManager" serializer="com.example.CustomSessionSerializer"> </Manager>

注意,serializer这个参数不是每个 fork 都有,要看源码里的抽象接口。很多老的版本根本不给配置项,直接用 JDK 序列化。判断依据很简单:打开 jar 包的类列表,如果能看到Serializer接口和对应的实现,说明可以切换;否则别白费劲。序列化器这个选择,会直接影响你会话里能放什么、放多大,务必在接入前先确认。

3. 在 Tomcat 9 上落地:jar 准备、context.xml 接入与参数逐项拆解

3.1 准备 jar 包:让 Maven 帮你把依赖一起算清楚

这个项目本身比较老,不同 fork 对 Tomcat 和 Redis 客户端版本的兼容性各不相同。拿到源码后,不要只打包自身,还要把它的第三方依赖一并收集,否则启动时会报ClassNotFoundException,最常见的就是缺 Jedis 或 commons-pool。

我一般先跑 Maven 打包,再用dependency:copy-dependencies把全部依赖复制出来,避免手工查找:

# 先打包 manager 本体 mvn clean package -Dmaven.test.skip=true # 把依赖 jar 统一收集到 target/deps,避免漏掉 jedis/commons-pool mvn dependency:copy-dependencies -DoutputDirectory=target/deps # 查看依赖清单,确认有没有重复 jar 或超高版本冲突 ls -lh target/deps

打包完成后,把 manager 本体和依赖全部复制到 Tomcat 共享库目录,让所有应用都能加载:

cp target/*.jar "$CATALINA_HOME/lib/" cp target/deps/*.jar "$CATALINA_HOME/lib/"

这里有两个容易忽略的点。第一,如果 Tomcat 的 lib 目录里本来就有旧版本 Jedis,请先删掉,防止类加载器先加载旧版本导致 Redis 连接行为异常。第二,不同 fork 的包内部类名可能一样,但实现细节不同,别把两个版本的 jar 同时丢进去,这是很多“配置没问题却启动失败”的根源。

如果项目源码要求依赖的 Jedis 版本和你的 Redis 服务端版本差距特别大,比如 Redis 6 开始默认开启 ACL 和更严格的数据类型检查,建议优先确认这个 fork 是否还在维护,或者寻找对应 Tomcat 小版本的分支,否则可能踩到密码认证协议不兼容的奇怪问题。

3.2 在 context.xml 里接入 Manager 和 Valve

最常见、也最容易控制的接入方式,是在单个应用的META-INF/context.xml里声明 Valve 和 Manager。这样只影响当前应用,其他应用不受牵连。

<Context reloadable="false"> <!-- Valve 必须声明在 Manager 之前 --> <Valve className="com.orangefunction.tomcat.redissessions.RedisSessionHandlerValve" /> <Manager className="com.orangefunction.tomcat.redissessions.RedisSessionManager" host="192.168.1.20" port="6379" database="0" password="${REDIS_SESSION_PASSWORD}" maxInactiveInterval="1800" keyPrefix="tomcat:session:" /> </Context>

Valve 和 Manager 的顺序不能乱:Valve 要负责在请求结束时触发写入动作,Manager 负责提供存储实现。如果 Manager 未初始化,Valve 会直接抛异常。maxInactiveInterval的单位是秒,这里设置 1800 表示会话 30 分钟没人访问就过期,它和后面讲到的 Redis key 过期时间直接挂钩。

这里还要提醒一个 Tomcat 的配置机制:${REDIS_SESSION_PASSWORD}这种占位符在 context.xml 里不一定能读到操作系统环境变量,它默认从 JVM 系统属性和catalina.properties里取值。如果要避免密码硬编码,常见做法是启动脚本里用-D把变量塞进 JVM 系统属性,或者发布时用脚本替换 context.xml 里的占位符。

3.3 必调参数和它们的真实含义

在用这个项目之前,先把关键参数按表过一遍,能省去后面很多“为什么没生效”的排查:

参数默认值作用我常用的设置
hostlocalhostRedis 地址内网 Redis 统一入口
port6379Redis 端口按环境走
database0Redis 库号单独给会话开一个库,比如 2
password空认证密码生产必填
maxInactiveInterval60会话失效秒数与 web.xml 的 session-timeout 保持一致
keyPrefixtomcat:session:Redis key 前缀加业务名,如 order:session:
sessionPersistPolicies无持久化策略SAVE_ON_CHANGE
serializerJDK序列化器默认即可,乱换 Kryo 要压测

sessionPersistPolicies是个好参数,它控制什么时候把会话写回 Redis。常见取值SAVE_ON_CHANGE表示只在会话变更时写,适合读写比例正常的业务;如果你希望求稳,每次请求结束都保存,可以配置成强制保存的策略,但要接受多出来的 Redis 写入流量。

参数改完后,建议用下面这组命令快速确认 Tomcat 是否真的加载了 Manager:

# 在 Tomcat 日志里搜索 RedisSessionManager 相关输出 tail -f "$CATALINA_HOME/logs/catalina.out" | grep -i "redis" # 看端口监听,确认 Redis 连接建立成功 ss -tnp | grep 6379

日志里如果出现无法连接 Redis 的配置错误,多半不是 jar 缺失,而是host和password不对,或者 Redis 服务端要求认证但密码没传。这一步能过滤掉绝大多数低级问题。

3.4 把会话单独放一个 Redis 库,避免和缓存数据互相干扰

很多团队同一个 Redis 实例上既跑业务缓存又跑 session。这样做不是不行,但一旦缓存数据误用了FLUSHDB,会把在线用户全部踢下线。我一般会给 session 单独指定一个 database,比如database="3",同时给keyPrefix加上明显的业务前缀。这样即使和其他数据混用,至少不会因为一个keys *扫库就分不清哪些 key 属于会话。

会话过期和 Redis 的过期机制强绑定:会话被访问时,Manager 会用一次PEXPIRE或EXPIRE命令刷新 key 的存活时间,所以maxInactiveInterval不只是一个 Tomcat 内部概念,它还直接影响 Redis 里的 key 会不会被自动清理。如果番茄时间设得太短,就会出现“用户看页面不超过 60 秒,Redis 里会话没了”的诡异问题。

4. 上线前必须排查的五个坑:序列化翻车、脏标记失效、Cookie 域、Redis 抖动和超时不一致

4.1 坑一:请求到一半报 NotSerializableException,用户界面直接 500

现象:会话可以创建,登录接口也正常,但某个业务请求执行到一半就抛java.io.NotSerializableException,堆栈里指向项目里某个自定义对象。

原因:请求结束时 Valve 检查 session 被改动,触发序列化。只要 session 里任何一个 attribute 对象没有实现java.io.Serializable,或者它内部引用了不可序列化的工具类,整个序列化都会失败。尤其容易忽略的是对象里的非序列化成员,比如直接塞了一个DataSource、Logger或某个 Spring Bean。

解决:先看异常堆栈里的类名,给它实现Serializable,并且把不可序列化的成员标记为transient。如果这个对象是第三方类又没有源码,就不要直接把对象塞进 session,改成只存对象的 ID,使用时再查一次。

// 这种用法会炸:第三方对象没实现 Serializable // session.setAttribute("client", httpClient); // 正确做法:session 里只冗余关键标识 session.setAttribute("clientId", client.getClientId());

4.2 坑二:Session 里的对象内容改了,Redis 里却还是旧值

现象:用户往购物车里加了一件商品,页面也显示加成功了;但下一次请求被负载均衡转发到另一个节点,购物车又少了这件商品。

原因:Valve 判断是否需要写回 Redis 的前提是session.isDirty()。调用setAttribute()才会把这个标记置位。很多人是从getAttribute()取出一个 List 或 Map,直接往里面add/put,对象本身引用没变,Tomcat 的 Session 对象感知不到内容变化,于是dirty永远为 false,Redis 自然不更新。

解决:场景化后,只需要在改完对象后重新调用一次setAttribute(),把同一个对象的引用再放回去,让 Session 把这个动作当作“属性被修改”。

// 这种改法不会触发保存 List<Item> cart = (List<Item>) session.getAttribute("cart"); cart.add(newItem); // 必须再 set 一次,Tomcat 才能感知 session.setAttribute("cart", cart);

这个坑在网上讨论特别多,尤其容易出现在老代码里。因为大多数老代码只依赖变量引用修改,根本不会想到跨 JVM 后“提交变更”变成了显式动作。

4.3 坑三:Cookie 没有配 Domain,多个子域之间登录状态串不起来

现象:主站www.example.com登录后,跳到m.example.com发现未登录;两个 Tomcat 明明共用同一个 Redis,session 也查得到,就是取不到。

原因:浏览器对 Cookie 有域限制。默认JSESSIONIDcookie 只绑定在设置它的那个域名下,子域之间互不发送。即使后端共享 Redis,浏览器端没把会话 ID 带过去,一切都是白搭。

解决:在 Tomcat 的 Cookie 配置里设置cookieDomain,让 JSESSIONID 对整个根域生效。常见做法是在server.xml的 Context 或全局配置段加上:

<CookieProcessor sameSiteCookies="none" cookieDomain=".example.com" />

注意到的关键是域名开头要写成.example.com,这个点号表示所有子域共享。改了之后记得清浏览器缓存或换隐身窗口测试,否则容易觉得“改了没生效”。

4.4 坑四:Redis 抖一下,整个 Tomcat 应用跟着假死

现象:Redis 在满负载或主从切换过程中出现几秒超时,Tomcat 的请求立刻大面积堆积,tcp 连接数暴涨,客户端表现为“页面转圈十秒然后 500”。

原因:每个请求开始时先findSession(),请求结束时还要判断是否需要写回。这两步都依赖 Redis。如果 Redis 卡住,而 Tomcat 线程池在阻塞等待 Jedis 的 socket 响应,请求就会被拖死,进而占满线程池。

解决:连接池超时参数必须显式配置,不要依赖默认。项目常见做法是把 Jedis 连接池的超时缩短到 50~200 毫秒,同时给 Redis 客户端设置合理的最大等待时间,避免线程无限阻塞。

redis.timeout=200 redis.maxTotal=64 redis.maxWaitMillis=200

如果业务真的无法容忍 Redis 故障,那就得在负载均衡器做一层兜底:检测到 Redis 不可用时,临时把流量全部转发到某个“本机兜底会话”节点,至少保证关键读接口不直接打满线程池。这里没有完美的后悔药,只能提前把超时参数压到能接受的范围。

4.5 坑五:maxInactiveInterval与 web.xml 的 session-timeout 不一致

现象:web.xml 里明明配置了 30 分钟会话超时,实际用起来却十几分钟就掉线;或者反过来,后台显示 Redis 里的 key 一直不淘汰,Redis 内存缓慢上涨。

原因:tomcat-redis-session-manager的 Manager 参数优先级高于 web.xml,或者某些实现根本不读 web.xml 的session-timeout。它以maxInactiveInterval为准,而这个值默认可能是 60 秒。如果部署时只改了 web.xml 而忘了改 Manager 配置,用户就会频繁被杀会话。

解决:把两处超时统一。我习惯用配置中心或发布脚本自动生成context.xml的 Manager 参数,并校验maxInactiveInterval必须等于web.xml中的分钟数乘以 60。

# 一个简单校验脚本,发布前检查两处配置是否一致 grep -o 'maxInactiveInterval="[0-9]*"' context.xml grep -o '<session-timeout>[0-9]*</session-timeout>' web.xml

5. 用 curl 和 redis-cli 确认会话真的跨节点共享,再谈故障转移的收口

5.1 两次最关键的验证:Redis 里有 key,且不同 Tomcat 都能恢复会话

配好 Manager 之后,第一步不是急着铺流量,而是在最小环境里做一次手工验证。我用两个 Tomcat 实例加一个 Redis,前面挂一个轮询负载均衡,然后只用 curl 和 redis-cli:

# 清掉旧 cookie,避免干扰 rm -f /tmp/session_test.cookie # 第一次请求:拿一个 JSESSIONID curl -s -i -c /tmp/session_test.cookie http://tomcat-vip:8080/app/login.jsp # 第二次请求:带着同一个 cookie 再来一次 curl -s -b /tmp/session_test.cookie http://tomcat-vip:8080/app/login.jsp

看响应头里的Set-Cookie,如果两次从负载均衡拿到的JSESSIONID是同一个,说明浏览器侧会话还在。但还想确认它不在某个 Tomcat 本地,而是真正在 Redis 里:

# 列出 Redis 里所有 session key redis-cli --scan --pattern 'tomcat:session:*' # 查看某个会话还剩下多久过期 redis-cli ttl tomcat:session:<你的会话ID> # 获取原始字节,看看是不是一堆不可读的序列化数据 redis-cli get tomcat:session:<你的会话ID> | xxd | head -c 200

如果 Redis 里没有这个 key,说明 Manager 根本没接管会话,session 还留在本地 JVM。这时优先怀疑context.xml没生效,或者 Valve 和 Manager 声明顺序反了。

更严格的做法,是把负载均衡临时调整成轮询,连续请求多次,观察后端两台 Tomcat 的访问日志。如果同一个JSESSIONID先后出现在 node1 和 node2 的日志里,而应用状态依然延续,基本可以断定共享成功。我习惯把这个动作固化成验收脚本,每次环境变更后都跑一遍。

5.2 故障转移与体积控制:单点 Redis 不是终点

这个方案的短板很清楚:Redis 本身就是新的单点。Tomcat 可以随便重启,Redis 一挂,会话数据全部不可读。常见做法是为生产环境至少配置 Redis 主从加哨兵,让哨兵自动把写流量切到新主节点。但要注意,很多老版本 Manager 直接使用单点 Jedis 连接,不支持哨兵;如果要用高可用 Redis,就得选择支持JedisSentinelPool的 fork,并在编译前确认。

另一方面,HttpSession 里别放大体积业务数据。这个架构下,一个请求可能经历“反序列化读一次 + 序列化写一次”,对象越大,Redis 和 Tomcat 之间的网络开销越明显。我会把用户基础状态留在 session 里,把订单明细、查询结果这类数据转移到 Redis 的过期 key 或数据库,session 只存业务对象 ID。这样既保留了“多节点共享”的能力,又把单次请求的序列化成本压到最低。

我自己的习惯是在接入后的第一周,把所有会话里的 attribute 名称和估算大小记一轮,凡是超过几十 KB 的对象,一律重新设计。会话共享技术本身不难,难的是让它长期稳定地跑在业务流量上面。希望这篇能帮你少踩几个我已经踩过的坑。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进