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。
对应关系非常直接:服务端有个ConcurrentHashMap<String, Session>,key 就是 sessionId,value 是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 contentType="text/html;charset=UTF-8" session="false" %>关掉之后页面里就不能再用隐式的session变量了,需要的地方自己显式取。对于纯粹的静态展示页、错误页、登录页,这个开关应该默认关掉。
另一类是框架层面的隐式调用。很多 Web 框架会为了“方便开发者”在请求进入业务代码之前就准备好会话上下文,有的还会在响应结束时主动保存一次会话状态。这类行为通常有开关,比如统一配置成按需创建或者无状态模式。判断方法很简单:在你的业务代码第一行打个断点,看request.getSession(false)是不是已经不为 null 了,是的话就说明上游有东西替你创建了。
还有一个隐蔽的来源:某些安全组件在做 CSRF 防护、验证码、登录失败计数时会使用 session 存储状态,这些逻辑往往藏在过滤器里,业务代码完全无感知。所以排查“谁创建的 session”,光看 Controller 是不够的,过滤器链要一起看。
2.3 明确不会创建 session 的几类请求
列一下我确认过、不会触发 session 创建的典型情况,方便对照:
| 请求类型 | 是否创建 session | 说明 |
|---|---|---|
| 静态资源(js/css/图片) | 否 | 由默认的静态资源 Servlet 处理,不碰 session |
getSession(false)且无会话 | 否 | 返回 null,无副作用 |
session="false"的 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: JSESSIONID=ABCDEF0123456789ABCDEF0123456789" \ 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(); }所以你在浏览器里看到的典型值长这样:3F2A9C1B7D4E6A8B0C2D4E6F8A1B3C5D,32 位、全大写、没有连字符。看到一个符合这个特征的字符串,基本可以确认是 Tomcat 默认生成器产出的。
长度是可以调的,在context.xml里给 Manager 加属性即可:
<Context> <Manager className="org.apache.catalina.session.StandardManager" sessionIdLength="32" maxActiveSessions="5000"/> </Context>sessionIdLength="32"意味着 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.egd=file:/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 name="Catalina" defaultHost="localhost" jvmRoute="node1">它的作用不是增强随机性,而是给前置的负载均衡器一个“粘滞”依据:请求进来时,均衡器看 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" useHttpOnly="true" sessionCookieSecure="true"> <CookieProcessor className="org.apache.tomcat.util.http.Rfc6265CookieProcessor" sameSiteCookies="lax"/> </Context>逐个说明它们的影响,这些都是我在真实项目里踩过的:
Path:默认值通常是应用的上下文路径,sessionCookiePath可以改成/让整站共享。如果前后端分离部署在不同路径下,Path 配小了就表现为“接口请求里始终没有 Cookie”,因为浏览器只会在 Path 匹配的请求上发送它。这个问题在网关做路径重写之后特别常见,因为浏览器看到的是重写后的路径。
Domain:跨子域共享会话时必须设置。注意带前导点的写法(.example.internal)是历史惯例,现代浏览器对有无前导点的处理已经趋同,但保险起见还是按团队约定来。设置成父域之后,所有子域都会带上这枚 Cookie,安全上要评估一下是否可接受。
Secure:为true时浏览器只在 HTTPS 请求中发送。开发环境用 HTTP 调试时把它打开,就会出现“接口里永远没有 session”的经典现象,而且浏览器开发者工具里 Cookie 明明存在,只是标记了 Secure。这个坑我至少见过五次,每次都是配置从生产抄到本地没改。
HttpOnly:Tomcat 7 之后默认就是true,作用是让前端 JS 读不到document.cookie里的这枚值。这里有个高频误解要澄清:HttpOnly 不等于“浏览器开发者工具里看不到”。在 Application 面板的 Cookie 列表里依然能看到 JSESSIONID,只是document.cookie拿不到。很多人以为“看不到就是没下发”,跑到后端一顿排查,其实配置完全正常。
SameSite:这是现代浏览器默认收紧的一项。如果前端和后端跨站部署,或者通过 iframe 嵌入,就必须显式设置成合适值,并且注意“SameSite=None必须同时Secure”这条硬性组合要求,否则浏览器会直接丢弃这枚 Cookie。表现就是“响应头里有 Set-Cookie,但下一个请求就是不带”,非常具有迷惑性。
4.3 URL 重写:encodeURL 的用处与代价
除了 Cookie,Servlet 规范还留了另一条路:把 sessionId 拼在 URL 里,形如;jsessionid=3F2A9C1B...。生成方式是调用response.encodeURL(url):
String target = response.encodeURL("/demo/order/list"); // 客户端不支持 Cookie 时输出:/demo/order/list;jsessionid=3F2A9C1B... // 支持 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 className="org.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 面板里没有,那就是被浏览器拒了。常见原因:SameSite=None但没加Secure、Secure在 HTTP 页面上设置、Domain 与当前域名不匹配、Cookie 数量或大小超限。
如果 Response Headers 里压根没有Set-Cookie,那就是服务端没触发创建。回到第 2 章的条件清单,一条条对:是不是getSession(false)、是不是session="false"的 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 没了”的现象,方向基本不会跑偏。