Tomcat中JSESSIONID生成原理与Session创建条件解析 1. JSESSIONID 到底是什么从一次“ID 每次都在变”的排查说起上个月有个同学拿了一段日志来问我说他的系统登录之后明明能看到用户信息但下一个请求又提示未登录抓包一看两次请求带的JSESSIONID完全不一样。他第一反应是“Tomcat 生成 ID 的逻辑有 bug”让我帮忙看看源码。我看完他贴出的响应头就笑了——问题根本不在生成算法而在于他每一次请求都在“重新创建一个 session”服务端自然就每一次都发一枚全新的JSESSIONID回去。这个场景其实特别典型。很多人对JSESSIONID的理解停留在“浏览器 Cookie 里那串看起来像乱码的字符”但真被问到“它是谁生成的、什么时候生成的、为什么有时候请求里根本没有它”时就说不太清楚了。而这几个问题恰恰是排查会话丢失、集群粘滞失效、内存里 session 数量暴涨这类故障的起点。这篇内容我想把Tomcat 中 JSESSIONID 的生成原理与生成条件一次讲透它由哪个组件负责生成、随机字节从哪来、长度为什么是 32 个字符、什么请求会触发创建、什么请求不会、生成的 ID 又是在什么时机被写回浏览器的。适合已经会用 session、但没往下翻过源码的后端同学也适合正在处理会话相关线上问题、需要一个可复现排查路径的人。文中涉及源码的部分我会贴出简化后的形式逻辑走向和真实实现一致细节注解做了删减方便阅读。1.1 Cookie 里那串字符与内存中的 Session 对象是一对一映射先把概念对齐。Servlet 规范里有一个HttpSession接口它代表“服务端保存的一段会话状态”本身不规定怎么标识。Tomcat 作为容器用一个字符串sessionId来标识每一个HttpSession实例这个字符串默认就放在名为JSESSIONID的 Cookie 里所以平时大家口头上说的“JSESSIONID”准确讲是指“Tomcat 分配给某个会话的标识符”而它落地到浏览器的形态只是一枚普通的 Cookie。对应关系非常直接服务端有个ConcurrentHashMapString, Sessionkey 就是 sessionIdvalue 是StandardSession对象。浏览器下次带着同一枚 Cookie 过来Tomcat 拿这个字符串去 Map 里查查到了就把对应的StandardSession挂到这次请求上于是request.getSession()返回的就是同一个对象之前塞进去的属性全都还在查不到就说明这枚 ID 已经过期或者根本不是本机签发的容器会当作“没有会话”处理。这里有个很容易被忽略的点sessionId 是服务端生成并持有的浏览器只是被动保存。所以当你在抓包里看到 ID 变化要判断的是“服务端是不是新建了一个 session”而不是“浏览器把旧 Cookie 弄丢了”。这两种情况的修复方向完全不同前者要查代码里谁在触发创建后者要查 Cookie 的 Path、Domain、Secure 这些下发属性。我在实际排查里遇到的比例大概是七三开绝大多数是前者。还有一层JSESSIONID这个名字是可以改的session-config里有cookie-config的name属性规范也允许容器自定义。但默认值就是JSESSIONID如果没有特殊需求我不建议改因为大量中间件、网关、日志分析工具都默认按这个名字做识别改了之后排查成本会上升收益却几乎没有。1.2 Manager、SessionIdGenerator、StandardSession 三个角色的分工要理解生成过程得先知道 Tomcat 内部是谁在干活。这三者职责分得很清楚Manager管生命周期和容器SessionIdGenerator管“造字符串”StandardSession是数据载体。Manager接口的实现类最常见的是StandardManager它的职责是创建、查找、过期、持久化 session。它内部持有一个SessionIdGenerator实例这个接口只有一个核心方法generateSessionId()带 route 参数的重载是 Tomcat 自己的扩展。真正的实现类在不同版本里叫法略有差异较早的版本是StandardSessionIdGenerator它继承自SessionIdGeneratorBase随机数、长度、编码这些逻辑都在基类里。StandardSession则是那个被放进 Map 的对象它实现了HttpSession内部保存id、creationTime、lastAccessedTime、maxInactiveInterval、attributes、isValid等字段。sessionId 最终是setId()到它身上的。创建链路简化后大概是这样// ManagerBase 中的关键逻辑简化 public Session createSession(String sessionId) { // 1. 数量上限检查 if ((maxActiveSessions 0) (getActiveSessions() maxActiveSessions)) { throw new TooManyActiveSessionsException(...); } // 2. 没传 ID 就自己生成一个 if (sessionId null) { sessionId generateSessionId(); } // 3. 建对象、setId、登记到 Map Session session createEmptySession(); session.setNew(true); session.setValid(true); session.setCreationTime(System.currentTimeMillis()); session.setMaxInactiveInterval(getContext().getSessionTimeout() * 60); session.setId(sessionId); sessionManager.add(session); // 放进 ConcurrentHashMap return session; }注意第 3 步里的getContext().getSessionTimeout() * 60这个sessionTimeout单位是分钟来自web.xml的session-timeout默认 30所以默认的maxInactiveInterval是 1800 秒。我在排查“会话为什么 20 分钟就掉了”的问题时经常发现是某个web.xml或者ServletContext的初始化参数把默认值改掉了而不是容器行为异常。顺带说一个真实存在的分工细节generateSessionId()这个方法在ManagerBase里不是简单转发它带了一轮去重循环这个我放到第 3 章展开。2. 生成条件一次请求到底在什么时刻才会真正产出 JSESSIONID“什么条件下会生成 JSESSIONID”这个问题比生成算法更值得先搞清楚因为它直接决定你的应用会不会在不知不觉中产生海量空 session。结论先说只有当容器决定创建一个新的HttpSession对象时才会生成新的 JSESSIONID。访问一个 Servlet、一个 JSP、一个静态资源本身都不构成充分条件。2.1 getSession() 与 getSession(false) 的分水岭HttpServletRequest上有两个重载getSession()等价于getSession(true)语义是“没有就给我建一个”getSession(false)语义是“有就给我没有拉倒返回 null”。这一行代码的选择就是最直接的开关。// 会创建没有会话就新建并生成新的 JSESSIONID HttpSession session request.getSession(); // 不会创建没有会话就返回 null不产生任何新对象 HttpSession session request.getSession(false); if (session null) { // 自己决定是要求重新登录还是放行 }我在代码评审里最常提的一条建议就是读操作优先用 false。比如一个接口只是想看看当前用户有没有会话、或者做埋点统计用getSession()就会给每一个匿名访客都建一个 session这些对象在 30 分钟内不会被销毁堆内存和 Map 的容量都会被白白消耗。有过一个场景是首页接口用了getSession()取某个可选属性结果大促期间匿名流量把 session 数量顶到了几十万老年代一直在抖。改成getSession(false)加上判空之后现象直接消失。反过来登录成功之后必须用getSession()或者先 false 再显式创建否则写进去的用户信息在下一次请求里就找不到了。这个“该建的时候建、不该建的时候别建”的取舍几乎决定了你应用的会话规模。2.2 JSP 的隐式 session 与框架的隐性调用即便你的代码里一次getSession()都没写session 也可能被创建。最常见的是 JSP% page %指令里有个session属性默认值是true意思是这个 JSP 编译出来的 Servlet 里会自动带上HttpSession session request.getSession();这一句。也就是说只要有人访问了一个没关 session 的 JSP 页面就会产生一个 session 和一枚 JSESSIONID。纯 JSP 页面上线一段时间后 session 表越来越大八成就是这个原因。处理方式很直接% page contentTypetext/html;charsetUTF-8 sessionfalse %关掉之后页面里就不能再用隐式的session变量了需要的地方自己显式取。对于纯粹的静态展示页、错误页、登录页这个开关应该默认关掉。另一类是框架层面的隐式调用。很多 Web 框架会为了“方便开发者”在请求进入业务代码之前就准备好会话上下文有的还会在响应结束时主动保存一次会话状态。这类行为通常有开关比如统一配置成按需创建或者无状态模式。判断方法很简单在你的业务代码第一行打个断点看request.getSession(false)是不是已经不为 null 了是的话就说明上游有东西替你创建了。还有一个隐蔽的来源某些安全组件在做 CSRF 防护、验证码、登录失败计数时会使用 session 存储状态这些逻辑往往藏在过滤器里业务代码完全无感知。所以排查“谁创建的 session”光看 Controller 是不够的过滤器链要一起看。2.3 明确不会创建 session 的几类请求列一下我确认过、不会触发 session 创建的典型情况方便对照请求类型是否创建 session说明静态资源js/css/图片否由默认的静态资源 Servlet 处理不碰 sessiongetSession(false)且无会话否返回 null无副作用sessionfalse的 JSP否编译产物里没有 getSession 调用明确配置为无状态的接口否依赖具体框架的会话策略配置带有效 JSESSIONID 的请求否复用命中已有 session不生成新 ID访问不存在的路径返回 404否请求没进到业务 Servlet反过来会创建的情况也很清楚代码里调了getSession()、访问了默认 session 的 JSP、带了失效或伪造的 JSESSIONID 然后又调了getSession()、以及触发了changeSessionId()之类的显式换 ID 操作。这里有个容易混淆的边界带着一个“无效的 JSESSIONID”过来容器不会立刻生成新的只有当你调用getSession()时才会。因为无效 ID 在查找阶段就被判定为“无会话”此时请求上的 session 引用是空的。所以“Cookie 里有旧 ID”和“服务端有会话”是两件事这一点在接受第三方跳转回跳的场景里特别容易出问题。2.4 怎么确认这次请求是否真的建了 session实测手段光看代码有时候不够尤其是大型项目里过滤器层层嵌套。我一般用两种手段确认。第一种是看响应头里有没有Set-Cookie: JSESSIONID...。只有新建 session 时才会下发这枚 Cookie复用已有会话时响应头里不会有它。这是最直接的判据用 curl 一行就能看到# 只看响应头确认是否下发了新的 JSESSIONID curl -I -s http://127.0.0.1:8080/demo/index.jsp | grep -i set-cookie # 带上已有 Cookie 再请求一次正常情况下这次不应该再下发 curl -I -s -H Cookie: JSESSIONIDABCDEF0123456789ABCDEF0123456789 \ http://127.0.0.1:8080/demo/index.jsp | grep -i set-cookie如果第二次依然返回了Set-Cookie而且值还不一样说明服务端在每次请求时都新建了会话问题基本锁定在“代码里无条件调用了getSession()”或者“Cookie 没能被正确回传”。第二种是开容器的会话管理日志或者用管理端点看活跃会话数。多数版本的 Tomcat 可以通过 JMX 拿到activeSessions压测时盯着这个数字的变化曲线能非常快地判断“是不是有意外创建”。如果 QPS 稳定但 activeSessions 单调上涨不回落那基本就是匿名会话泄漏。提示判断“是不是新建了会话”不要看浏览器开发者工具里 Cookie 面板显示的值那个面板展示的是当前存储状态不是本次响应的下发行为。看 Network 里的 Response Headers 才准确。3. 16 字节怎么变成 32 个字符随机数来源、长度换算与 jvmRoute 后缀搞清楚“什么时候生成”之后再看“怎么生成”。这部分是标题里“生成原理”的核心也是很多面试题会问的地方为什么 Tomcat 的 sessionId 是 32 位十六进制大写字符3.1 sessionIdLength 的默认值与编码后的字符数SessionIdGeneratorBase里有一个属性sessionIdLength默认值是16。注意这个 16 的单位是字节不是字符。生成流程是先取 16 个随机字节然后把每个字节拆成高 4 位和低 4 位各自映射成一个十六进制字符所以 16 字节最终输出32 个字符。// generateSessionId 的简化形式逻辑与 Tomcat 一致 public String generateSessionId(String route) { byte[] random new byte[getSessionIdLength()]; // 默认 16 字节 int resultLenBytes 0; StringBuilder id new StringBuilder(); while (resultLenBytes getSessionIdLength()) { getRandomBytes(random); // 用 SecureRandom 填满 random getDigest().digest(random); // 默认不做摘要原样返回 for (int j 0; j random.length resultLenBytes getSessionIdLength(); j) { byte b1 (byte) ((random[j] 0xf0) 4); byte b2 (byte) (random[j] 0x0f); id.append(b1 10 ? (char) (0 b1) : (char) (A b1 - 10)); id.append(b2 10 ? (char) (0 b2) : (char) (A b2 - 10)); resultLenBytes 2; } } if (route ! null route.length() 0) { id.append(.).append(route); // 集群路由后缀 } return id.toString(); }所以你在浏览器里看到的典型值长这样3F2A9C1B7D4E6A8B0C2D4E6F8A1B3C5D32 位、全大写、没有连字符。看到一个符合这个特征的字符串基本可以确认是 Tomcat 默认生成器产出的。长度是可以调的在context.xml里给 Manager 加属性即可Context Manager classNameorg.apache.catalina.session.StandardManager sessionIdLength32 maxActiveSessions5000/ /ContextsessionIdLength32意味着 32 字节随机数输出 64 个字符。要不要调我的看法是默认的 16 字节已经远远够用了128 位随机空间的碰撞概率在工程上可以忽略把长度翻倍带来的收益微乎其微却会让 Cookie 变大、日志变长、索引变宽。真正需要关注的是随机源质量不是长度。顺便提醒一句改了长度之后如果集群里各节点配置不一致会出现“A 节点签发的 ID 被 B 节点认为是非法”的情况。这类问题排查起来非常费时间因为表现是“偶发丢会话”而不是稳定失败。集群环境的sessionIdLength、jvmRoute这类参数必须统一。3.2 随机数来自 SecureRandom以及熵池不足惹的祸getRandomBytes()底层用的是java.security.SecureRandom不是java.util.Random。这一点很关键Random是线性同余种子一旦被推测出来后续所有输出都能算出来用它生成会话标识等于把会话劫持的门槛降到了“猜种子”。SecureRandom走的是密码学安全的伪随机数生成器默认算法在多数 JDK 上是SHA1PRNG或NativePRNG安全性完全不同。SessionIdGeneratorBase提供了三个属性让你干预随机源的选择属性作用常见用途secureRandomClass指定 SecureRandom 实现类需要特定实现时使用secureRandomProvider指定安全提供者多提供者环境下明确选择secureRandomAlgorithm指定算法名更换随机算法的默认选择绝大多数项目不需要动这三个属性。但有一种情况必须知道SecureRandom 首次初始化可能需要采集熵。在某些精简的虚拟化环境里系统的随机源在启动初期熵不足初始化会阻塞表现出来就是“容器启动卡住”或者“启动后第一个请求卡十几秒”之后一切正常。我在一个容器化部署的项目里遇到过这个问题现象特别迷惑本地压测毫无问题一上生产环境首次健康检查就超时重启几次之后又好了。最后定位就是启动时初始化SecureRandom阻塞。解决办法是在 JVM 启动参数里把随机源指向非阻塞的设备# 在 JVM 启动参数中指定熵源避免初始化阶段阻塞 JAVA_OPTS$JAVA_OPTS -Djava.security.egdfile:/dev/./urandom这个参数的具体写法与 JDK 版本有关有的版本需要file:/dev/urandom有的版本file:/dev/./urandom这种带./的写法才生效这属于历史遗留的绕行方式。改完之后务必做一次压测验证确认会话 ID 的随机性没有退化——虽然从工程角度讲 urandom 在初始化之后质量足够但谨慎一点总没坏处。注意不要为了“启动快”就把随机源换成java.util.Random或者自己写的伪随机实现。会话标识是身份凭证一旦可预测后面所有的权限校验都形同虚设。3.3 jvmRoute 后缀集群里的路由标签如果你在 ID 里看到类似3F2A9C1B7D4E6A8B0C2D4E6F8A1B3C5D.node1这样的形式那个.node1就是jvmRoute。它配置在server.xml的 Engine 上Engine nameCatalina defaultHostlocalhost jvmRoutenode1它的作用不是增强随机性而是给前置的负载均衡器一个“粘滞”依据请求进来时均衡器看 Cookie 里的后缀直接把请求转发到对应节点从而保证同一个会话始终落在同一台机器上。这是最经典的会话保持方案实现简单、没有额外组件依赖。代价也很明显节点宕机之后粘在那台机器上的会话全部失效用户需要重新登录。所以这个方案适合“会话丢失可接受”的场景比如内部系统面向公网用户且对体验要求高的通常还要配合会话复制或者集中式会话存储。关于 jvmRoute 我有两个实操提醒。第一所有节点的 jvmRoute 必须唯一且与均衡器配置严格对应写成一样或者拼错一个字母表现就是“部分用户会话随机丢失”。第二不要在运行期动态改 jvmRoute改了之后旧 ID 里的后缀失效等于把所有在线用户踢下线。3.4 碰撞检测生成之后还要过一道去重随机数生成完就完事了吗没有。ManagerBase里还有一个去重循环这一点很多人不知道protected String generateSessionId() { String result null; do { if (result ! null) { duplicates; // 计数器可通过 JMX 观察 } result sessionIdGenerator.generateSessionId(); } while (sessions.containsKey(result)); return result; }逻辑就是生成一个 ID去 Map 里查一下有没有重复重复就重来。128 位随机空间下这个循环几乎不可能执行第二次但代码里保留了这个保护属于“防御性设计”。duplicates这个计数器也是一个很好的观测点如果它非零且在增长说明你的随机源出了问题比如被换成了低熵实现这时候要立刻检查随机算法配置。这一段代码还解释了一个现象同一个节点上永远不会出现两个相同的 sessionId所以如果你在日志里看到两个不同的会话共用同一个 ID那不是碰撞而是会话被复制、被伪造或者日志打印逻辑写错了比如打印了常量。这两种情况的排查方向完全不同别被“碰撞”这个词带偏。4. 从对象到浏览器Set-Cookie 的下发时机与那几个关键属性ID 生成出来、StandardSession建好、放进 Map 之后还有最后一步把它送回浏览器。这一步是通过响应头的Set-Cookie完成的属性配置错一个前端就可能永远拿不到这枚 Cookie。4.1 什么时候才会写入 Set-Cookie规则很明确只有当本次请求新建了一个 session 时容器才会在响应里追加Set-Cookie: JSESSIONID...。复用已有会话时不会重复下发这是 HTTP 语义决定的——没有必要每次都告诉浏览器同一个值。所以你在排查时会看到一个很实用的规律响应头里有Set-Cookie: JSESSIONID...→ 本次请求创建了新会话值得追查是谁触发的。响应头里没有但请求头里带了Cookie: JSESSIONID...→ 会话被正常复用。响应头里没有请求头里也没有 → 会话不存在且本次请求没用到会话。还有一种情况是“响应头里下发了但浏览器没保存”。这就要看属性了。4.2 Path、Domain、Secure、HttpOnly、SameSite 逐个拆Tomcat 支持在context.xml或者应用内的META-INF/context.xml里配置 Cookie 属性Context sessionCookiePath/demo sessionCookieDomain.example.internal useHttpOnlytrue sessionCookieSecuretrue CookieProcessor classNameorg.apache.tomcat.util.http.Rfc6265CookieProcessor sameSiteCookieslax/ /Context逐个说明它们的影响这些都是我在真实项目里踩过的Path默认值通常是应用的上下文路径sessionCookiePath可以改成/让整站共享。如果前后端分离部署在不同路径下Path 配小了就表现为“接口请求里始终没有 Cookie”因为浏览器只会在 Path 匹配的请求上发送它。这个问题在网关做路径重写之后特别常见因为浏览器看到的是重写后的路径。Domain跨子域共享会话时必须设置。注意带前导点的写法.example.internal是历史惯例现代浏览器对有无前导点的处理已经趋同但保险起见还是按团队约定来。设置成父域之后所有子域都会带上这枚 Cookie安全上要评估一下是否可接受。Secure为true时浏览器只在 HTTPS 请求中发送。开发环境用 HTTP 调试时把它打开就会出现“接口里永远没有 session”的经典现象而且浏览器开发者工具里 Cookie 明明存在只是标记了 Secure。这个坑我至少见过五次每次都是配置从生产抄到本地没改。HttpOnlyTomcat 7 之后默认就是true作用是让前端 JS 读不到document.cookie里的这枚值。这里有个高频误解要澄清HttpOnly 不等于“浏览器开发者工具里看不到”。在 Application 面板的 Cookie 列表里依然能看到 JSESSIONID只是document.cookie拿不到。很多人以为“看不到就是没下发”跑到后端一顿排查其实配置完全正常。SameSite这是现代浏览器默认收紧的一项。如果前端和后端跨站部署或者通过 iframe 嵌入就必须显式设置成合适值并且注意“SameSiteNone必须同时Secure”这条硬性组合要求否则浏览器会直接丢弃这枚 Cookie。表现就是“响应头里有 Set-Cookie但下一个请求就是不带”非常具有迷惑性。4.3 URL 重写encodeURL 的用处与代价除了 CookieServlet 规范还留了另一条路把 sessionId 拼在 URL 里形如;jsessionid3F2A9C1B...。生成方式是调用response.encodeURL(url)String target response.encodeURL(/demo/order/list); // 客户端不支持 Cookie 时输出/demo/order/list;jsessionid3F2A9C1B... // 支持 Cookie 时原样输出不做任何拼接它的设计初衷是照顾不支持 Cookie 的客户端但今天几乎所有浏览器都支持 Cookie这条路更多是作为兜底。副作用却很实在sessionId 会出现在 URL、Referer 头、访问日志、浏览器历史里一旦被翻出来就是完整的会话劫持凭证。所以我建议默认不要用只在明确的兼容场景下开启并且配合短超时。如果你在日志里看到带;jsessionid的请求通常意味着两件事之一客户端 Cookie 被禁用了或者某处代码显式调用了encodeURL。前者是用户环境问题后者是代码问题通过请求头里有没有 Cookie 就能区分。5. 生命周期同一串 ID 会在什么情况下被换掉或作废生成只是开始。实际运维中更多的问题是“ID 明明生成了为什么过一会儿就没了”这就涉及生命周期管理。5.1 超时计数是怎么走的StandardSession上有个lastAccessedTime每次请求命中这个会话Tomcat 都会更新它。后台有个专门的过期线程backgroundProcessorDelay控制的周期任务定期扫描所有会话把now - lastAccessedTime maxInactiveInterval * 1000的标记为过期然后触发sessionDestroyed事件、清空属性、从 Map 里移除。默认maxInactiveInterval是 1800 秒。注意这里有个细节这个“不活跃”指的是没有请求命中该会话而不是“用户没有操作”。如果一个页面每 10 分钟轮询一次接口并且接口里调用了getSession()这个会话就永远不会过期因为每次轮询都算一次访问。我在排查“会话为什么不释放”时经常在接口访问日志里找到某个被遗忘的心跳请求。另外lastAccessedTime的更新是有节制的容器为了性能不会对每个请求都做一次原子更新具体的更新频率与实现有关。这意味着极端情况下一个刚好卡在过期边界上的会话可能多活一小会儿。这一点在写“精确过期”类需求时要注意别把业务逻辑建立在“会话一定在 1800 秒整失效”这个假设上。5.2 changeSessionId登录后主动换 ID 的实践HttpServletRequest.changeSessionId()是 Servlet 3.1 引入的方法作用是在保留会话属性、不新建会话对象的前提下换掉当前的 ID。典型用法是在用户认证成功之后立刻调用// 登录校验通过后换一个全新的 sessionId属性保留 String oldId request.getSession().getId(); request.changeSessionId(); String newId request.getSession().getId(); // oldId ! newId但 session 里的属性一个没丢为什么要有这一步因为存在“会话固定攻击”这类风险攻击者先拿到一枚合法 ID诱导受害者使用这枚 ID受害者登录之后这枚 ID 就成了已认证状态攻击者拿着同样的 ID 就能直接进来。登录后换 ID 让攻击者手里的旧 ID 立刻失效成本极低、收益明确。我的实践建议是只要有登录动作就应该换 ID。这个操作在主流安全框架里通常是自动完成的但如果你自己写登录逻辑一定要记得手动加。我见过一个项目安全扫描报了这个风险点开发同学的反应是“我们用的是 HTTPS不会丢”这就是把传输安全和会话标识安全混为一谈了。5.3 重启、持久化与 ID 的延续StandardManager默认会在容器正常停止时把当前所有会话序列化到SESSIONS.ser启动时再反序列化回来。这个文件通常在work/Catalina/localhost/应用名/下面。这意味着正常重启之后之前的 JSESSIONID 有可能是继续有效的因为会话连同 ID 一起被恢复了。这解释了一个常见困惑“服务重启了为什么用户不用重新登录”答案就是持久化生效了。反过来说如果你希望重启后所有会话强制失效可以把这个机制关掉Manager classNameorg.apache.catalina.session.StandardManager pathname /pathname置空表示不落盘。安全要求高的场景通常会这么做因为磁盘上的会话文件本身就是一份敏感数据。还有一个前提容易被忽略持久化要求 session 里存放的属性对象必须可序列化。如果某个业务对象没实现Serializable容器在保存时会跳过该属性并打日志表现出来就是“重启后会话还在但某个属性变成 null 了”。这个现象很隐蔽因为会话本身没丢只有部分数据丢了。6. 四类高频异常从现象反推生成链路理论讲完来点实战。下面这四种现象我在各种项目里都遇到过排查思路基本可以固化成套路。6.1 每次请求 ID 都不一样这是最常见的求助场景。按可能性从高到低排先看响应头是不是每次都带Set-Cookie。如果每次都带说明服务端每次都在新建会话去查代码里谁调了getSession()重点看过滤器和拦截器尤其是那些“统一处理”的公共逻辑。如果响应头不每次都带但请求头里始终没有 Cookie那就转去查浏览器的保存和回传重点看 Path、Secure、SameSite 三个属性。还有一种特殊情况请求被重定向到了不同的域名或端口。Cookie 是同源隔离的http://a.example.com种下的 Cookie 不会发给http://b.example.com端口变化在某些历史实现的浏览器上也会造成隔离。登录跳转场景里这种情况特别常见表现为“登录成功跳回首页首页又要求登录”。6.2 浏览器里根本看不到 JSESSIONID先分清楚是“没下发”还是“没保存”。看 Network 的 Response Headers有Set-Cookie但 Application 面板里没有那就是被浏览器拒了。常见原因SameSiteNone但没加Secure、Secure在 HTTP 页面上设置、Domain 与当前域名不匹配、Cookie 数量或大小超限。如果 Response Headers 里压根没有Set-Cookie那就是服务端没触发创建。回到第 2 章的条件清单一条条对是不是getSession(false)、是不是sessionfalse的 JSP、是不是请求根本没进业务代码比如被静态资源处理器或者 404 拦下了。这里再强调一次那个高频误解HttpOnly 的 Cookie 在开发者工具里是能看到的看不到就一定不是 HttpOnly 的问题。我见过有人因为这个把useHttpOnly改成 false把安全属性白白关掉了问题却没解决。6.3 集群下会话反复失效或串号集群场景的排查要按层次来。第一层看jvmRoute所有节点的值是否唯一、是否与均衡器配置一致、均衡器是否真的按后缀做了转发。第二层看会话复制或者集中存储是否生效可以通过在节点 A 上写属性、在节点 B 上读属性来验证。第三层看会话 ID 的格式是否一致sessionIdLength不同会导致跨节点识别失败。“串号”这种情况要单独说如果两个用户拿到了同一个 JSESSIONID那不是随机碰撞几乎一定是共享了 Cookie 或者 ID 被硬编码了。检查方向包括测试环境共用域名、反向代理改写了 Cookie 头、某处代码把 sessionId 写成了常量。6.4 启动慢、第一个请求卡住十几秒回到第 3 章讲的熵源问题。特征很明显只在特定环境出现、重启后随机复发、卡住的时间长度不固定、卡过之后一切正常。定位手段是在卡住的时候抓一次线程栈看是不是阻塞在SecureRandom的初始化上。# 容器卡住时抓线程栈搜索 SecureRandom 相关的栈帧 jstack pid /tmp/stack.txt grep -n -A 10 SecureRandom /tmp/stack.txt如果确认是这个原因按要求配置java.security.egd然后做一轮压测确认无回归。另外提醒一点这个问题的解决不应该靠“重启几次碰运气”因为线上扩容时新实例同样会踩到。7. 几条我认为值得固化的经验写了这么多最后分享几个我长期坚持的做法都是踩过坑之后形成的习惯。第一条默认用getSession(false)只在真正需要写入状态时才创建会话。这个习惯能挡掉绝大部分“session 数量异常增长”的问题。写代码时多一个判空运维时少一次半夜排查。第二条任何涉及 Cookie 属性的配置本地开发和线上必须显式区分。Secure、SameSite、Domain这三项最容易从生产抄到本地之后出问题建议在配置中心里按环境分开管理而不是靠人肉记忆。第三条登录成功后务必换 ID。这不是可选的优化项而是基础的安全实践代价只有一行代码。第四条监控duplicates计数和活跃会话数。前者能提前发现随机源异常后者能提前发现会话泄漏。这两个指标都很容易通过 JMX 拿到接入监控的成本很低但能省下大量事后定位的时间。第五条日志里不要完整打印 JSESSIONID。排查时确实需要但生产日志里出现完整会话标识等于给日志系统增加了敏感数据。我一般的做法是只打印前 8 位加后 4 位既能定位又能降低风险。这套东西看下来其实核心就一句话JSESSIONID 不是凭空出现的它是“容器决定创建会话”这个动作的副产物。想清楚这个因果再去看那些“ID 变了”“Cookie 没了”的现象方向基本不会跑偏。