我先帮你把这篇博客文章直接写出来了。以“资深后端开发分享踩坑经验”的口吻来写围绕 Spring Boot 会话过期跟踪这个主题从机制原理、配置、代码实现、分布式方案到前后端分离、实战问题排查一次性讲透。1. 会话过期这事真没你想的那么简单我们做 Java Web 开发几乎每天都在跟 Session 打交道但“会话过期”这四个字往往要到线上出了问题才会被认真对待。用户用着用着突然被踢下线明明刚操作完还是提示“登录已过期”或者后台在线人数统计永远不准……这些坑我几乎都在 Spring Boot 项目里踩过一遍。先说清楚为什么“跟踪会话过期”是一个值得单独拿出来讲的话题。因为现在做 Spring Boot 开发很多人已经习惯了前后端分离接口用 Token 做认证对传统的 HttpSession 反而陌生了。可实际上无论是企业内部管理系统、后台 OA还是基于 Spring Security 的旧项目改造依然有大量系统在依赖服务端 Session 来维持登录态。这时候会话什么时候过期、过期了怎么感知、如何主动把过期的会话清理掉、如何防止别人伪造一个已过期的会话去请求接口这些都是真实存在且必须解决的问题。更麻烦的是单机环境下的 Session 过期跟踪不难但一旦上了 Nginx 负载均衡部署了多个 Spring Boot 实例Session 的创建、销毁、过期通知就变成了一个分布式问题。如果不提前设计好方案等到用户量上来再改代价会非常大。这篇文章我就从实际项目角度出发把 Spring Boot 里跟踪会话过期的各种姿势讲一遍从最基础的 HttpSessionListener、到过滤器拦截器、再到 Spring Session Redis 的分布式方案以及前后端分离场景下 Token 过期跟踪的工程实践。不是教科书式的罗列而是每一个方案在什么场景下用、有什么坑、怎么避免尽量讲透。2. 先搞清楚 Session 是怎么“过期”的2.1 会话超时计算的底层逻辑很多人对 Session 过期有个误解觉得浏览器的 Cookie 关了Session 就销毁了。其实不是这样。Cookie 只是保存了 JSESSIONID 这个会话标识服务端的 Session 对象是独立存在的。真正决定会话生死的是服务端“最后一次访问”这个会话的时间。Servlet 容器判断 Session 是否过期不是用一个每天半夜刷新的定时器去扫描所有 Session而是基于一种比较巧妙的设计每个 Session 对象在创建时会记录最后访问时间lastAccessedTime容器在每次请求访问这个 Session 时会更新这个时间。当检查一个 Session 是否有效时取当前时间减去最后访问时间如果超过设定的超时时间比如 30 分钟就判定它过期了。也就是说这是一个“空闲超时”的概念不是“绝对超时”。哪怕用户登录后挂机一整天只要每隔一段时间有请求带着这个 JSESSIONID 过来Session 就一直有效。这个设计的好处很明显对于长时间在线的用户不会因为他登录超过某个固定时间就被强制下线。但坏处也很明显如果你想实现“无论用户是否有操作登录 2 小时后必须重新认证”这种银行级别的安全策略光靠 Session 机制本身是做不到的需要自己去实现绝对超时逻辑。2.2 Spring Boot 中会话超时配置的几种方式Spring Boot 里配置会话超时时间非常简单在 application.properties 或 application.yml 里加一行就行server: servlet: session: timeout: 30m这个配置的单位是 Duration可以写 30s、15m、1h 等。如果不带单位默认单位是秒但强烈建议写清楚时间单位可读性好也避免和某些旧版本的默认单位秒混淆。这里有一个关键点server.servlet.session.timeout这个配置只对 Spring Boot 内嵌的 Tomcat、Jetty、Undertow 生效。如果你是把应用打成 WAR 包部署到外部 Tomcat 容器里这个配置就会被外部容器的配置覆盖。外部容器的会话超时是在conf/web.xml里通过session-configsession-timeout30/session-timeout/session-config设置的单位是分钟。这个坑我遇到过好几次本地启动好好的 30 分钟过期部署到客户的 WebLogic 上变成了 15 分钟排查了大半天才发现是外部容器的配置在作怪。2.3 最小化捕获是什么让一个 Session 变成“过期”当一个过期的 Session 被请求访问时Servlet 容器会把它作废并抛出一个HttpSessionRequiredException在 Spring MVC 拦截器或HttpSession参数绑定场景下或者直接创建一个新 Session如果你在代码里调用request.getSession(true)。这给“跟踪会话过期”带来了一个核心难点Session 的过期销毁并不总是能被开发者主动感知到它在容器内部就静默地发生了。所以跟踪会话过期的本质就是两点第一在会话真正过期时得到一个通知好去清理关联的业务数据比如在线用户列表第二在某个接口请求过来时能够提前判断这个 Session 是不是已经过期了好返回一个友好的提示而不是让用户看到一个诡异的 500 错误。3. HttpSessionListener最传统的会话过期感知方案3.1 通过监听器捕获会话创建与销毁Servlet 规范里早就给我们提供了HttpSessionListener接口它有两个方法sessionCreated和sessionDestroyed。只要用Component标注一下Spring Boot 就会自动注册这个监听器。Component public class SessionEventListener implements HttpSessionListener { Override public void sessionCreated(HttpSessionEvent se) { HttpSession session se.getSession(); System.out.println(会话创建: session.getId()); // 这里可以记录会话创建时间、绑定用户信息等 } Override public void sessionDestroyed(HttpSessionEvent se) { HttpSession session se.getSession(); System.out.println(会话销毁: session.getId()); // 这里可以清理在线用户缓存、统计指标 } }这可以说是最正统的“跟踪会话过期”方案。当 Session 因为超时被容器销毁时sessionDestroyed会被调用当通过session.invalidate()主动失效时也会被调用。所以它不仅能感知“超时过期”还能感知“主动踢人下线”。但这里有一个很多人不知道的细节sessionDestroyed触发时Session 对象已经被容器标记为失效你无法从这个已经失效的 Session 里读取属性。如果你想在销毁时拿到用户 ID必须在sessionCreated阶段就把用户信息塞进 Session或者用一个 ConcurrentHashMap 自行维护 SessionId 和用户实体的映射关系在销毁时再做一次反查。3.2 监听器方案在 Spring Boot 中的局限性HttpSessionListener简单直接但它的局限性也很明显。第一它只能感知“会话销毁”这个事件但无法回答“这个会话对应的用户是谁”。因为在 Session 超时销毁的那一刻容器不会再执行任何业务代码去补充用户信息Session 里存的 Attribute 也拿不到了。第二在分布式环境下如果部署了多个 Spring Boot 实例每个实例的监听器只能感知到自己这个 JVM 内部的 Session 创建和销毁。当用户第一次请求在实例 A 创建了会话第二次负载均衡转到实例 B 时实例 B 根本没有这个 Session会要求用户重新登录。这就是会话复制和分布式会话要解决的问题。第三监听器能拿到的是“会话终于过期了”的通知但无法回答“还有多久过期”。如果你要做用户即将超时提醒比如弹窗提示“5 分钟后会话即将过期”监听器方案就不适用了需要主动去查 Session 的最后访问时间和超时阈值的差值。3.3 用监听器维护一份在线用户注册表虽然监听器有局限但维护一份“在线用户注册表”这个需求用它可以实现得很干净。我分享一个实践定义一个UserSessionRegistry组件用 ConcurrentHashMap 保存会话 ID、用户 ID、最近活跃时间。Component public class UserSessionRegistry { private final MapString, UserSession sessionMap new ConcurrentHashMap(); public void add(String sessionId, UserSession userSession) { sessionMap.put(sessionId, userSession); } public void remove(String sessionId) { sessionMap.remove(sessionId); } public UserSession get(String sessionId) { return sessionMap.get(sessionId); } public int countOnlineUsers() { return sessionMap.size(); } public ListUserSession getAllSessions() { return new ArrayList(sessionMap.values()); } }在会话创建时通常是在用户登录成功后把用户信息存入 Session并同时注册到这张表里在sessionDestroyed时根据 SessionId 移除对应记录。这样我们在任意请求里只需要registry.get(sessionId)就能拿到当前用户的完整信息而且能实时统计在线人数。要注意的是Session 过期销毁时sessionDestroyed中拿到的 Session ID 和创建时拿到的应该是同一个 ID这是会话的唯一标识所以用它作为 Map 的 Key 是可靠的。但还有一个边界场景如果应用重启内存里的注册表会全部丢失而浏览器的 Cookie 里还留着一个旧的 JSESSIONID。用户带着这个旧 ID 再访问容器找不到对应的 Session会认为它是过期会话这一点靠监听器是追踪不到的只能靠一次“必须经过认证的请求”去触发重新登录。4. 用 Filter 和 Interceptor 在请求链路中主动判断会话状态4.1 为什么有了监听器还不够监听器负责“事后通知”但在真实请求里我们往往是需要“事前判断”——当用户已经携带一个失效的 Session ID 访问一个需要登录的接口时我们希望直接返回 401 或者重定向到登录页而不是等到 Session 被访问时才被动发现。这个需求就要靠过滤器Filter和拦截器Interceptor来实现。两者的区别很简单Filter 是 Servlet 层面的在请求进入 DispatcherServlet 之前就执行Interceptor 是 Spring MVC 层面的在 HandlerAdapter 调用 Controller 方法之前执行。对于会话跟踪来说用 Interceptor 更合适因为我们可以只拦截特定路径的请求而且可以利用 Spring 的依赖注入优雅地拿到注册表、Redis 客户端等组件。4.2 一个拦截器实现会话有效性校验我这里给你一个可以直接抄作业的实现方式自定义一个 HandlerInterceptor在 preHandle 里判断 Session 是否存在、是否已过期如果没有登录则直接返回 JSON 错误信息。Component public class SessionValidationInterceptor implements HandlerInterceptor { Autowired private UserSessionRegistry sessionRegistry; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录等公开接口 if (request.getRequestURI().startsWith(/api/auth/)) { return true; } HttpSession session request.getSession(false); // 没有会话或者会话是新的说明原来的 sessions 已经过期被容器销毁 if (session null || session.isNew()) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\登录会话已过期请重新登录\}); return false; } Long userId (Long) session.getAttribute(userId); if (userId null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\会话无效请重新登录\}); return false; } // 更新注册表中的最近活跃时间 sessionRegistry.updateActivity(session.getId()); return true; } }上面这段代码里我用了request.getSession(false)这个细节太重要了。getSession(false)的意思是“如果当前没有有效 Session返回 null”而getSession(true)则会强制创建一个新 Session。如果你在过期判断逻辑里用了getSession(true)那所有已过期的请求都会被悄悄创建一个新 Session然后你以为用户还是那个用户实际上已经是一个没有userId属性的全新会话了很容易造成逻辑漏洞。4.3 你在集成拦截器时最容易掉的三个坑第一拦截器放行路径必须精确不能简单用excludePathPatterns(/api/auth/**)就完事了还要考虑静态资源、Swagger 文档、错误页面这些路径。否则会出现静态资源没登录也加载不出来或者登录页被自己拦截的尴尬。第二拦截器里只能做“判断当前请求携带的 Session 是否还有效”无法主动“知道 Session 什么时候过期”。因为容器判断 Session 过期具有惰性访问时才判断不访问它就一直躺在内存里。所以如果你做了在线用户统计内存中注册表里可能混入“确实是有效 Session 但用户已经很久不操作”的血条未清的空记录必须配合定时任务来清理。第三处理 Ajax 请求时不要用response.sendRedirect()这种方式。前后端分离场景下重定向会让浏览器直接跳转到登录页如果前端没处理好用户会看到一个极不友好的空白页。最稳妥的做法是统一返回一个 JSON 状态码比如 401由前端根据状态码跳转路由。4.4 会话固定攻击防护与会话 ID 变更在做会话校验时还有一个安全问题必须考虑会话固定攻击。攻击者可以先自己创建一个 Session然后把它的 JSESSIONID 塞给受害者受害者登录成功后如果没有更换 Session ID攻击者就可以拿着这个 ID “借用”受害者的登录态。Spring Security 默认会在登录成功后更改 Session ID 来防止这种攻击但如果你用的是原生 HttpSession 自己做的登录就必须在登录成功后主动调用request.changeSessionId()或者session.invalidate()后重新创建会话。这里也提醒一下如果你用了changeSessionId()注册表等依赖原 Session ID 的映射必须同步更新不然就会出现用户明明登录了却查不到会话记录的奇怪问题。5. 从单机到集群分布式会话下的过期跟踪5.1 单机 Session 在集群下失效的原理上一节讲的监听器和拦截器方案都默认应用是单实例部署。单机环境下Session 存在 Tomcat 的 Heap 里生命周期和过期销毁都由容器管理一切好说。但线上业务一旦上了 Nginx 做负载均衡问题就来了。假设你有两个 Spring Boot 实例 A 和 B用户第一次请求打到 A在 A 的 JVM 里创建了 Session。第二次请求打到 BB 检查自己内存里没有这个 Session它不会去找 A 要而是直接创建一个新 Session并告诉浏览器把 JSESSIONID 改成新的。用户一脸蒙圈“我明明登录过了怎么又让我登录”解决这个问题有三种常见思路。第一种是“粘性会话Sticky Session”让 Nginx 按用户身份哈希保证同一个用户的所有请求都打到同一个实例上。但这种方式下一旦这台实例宕机该用户的会话就全丢了。第二种是“会话复制”集群之间互相拷贝 Session 状态Tomcat 自带的 DeltaManager 就是这个思路但会话多的时候网络开销大不推荐。第三种是“第三方会话共享”也就是把 Session 存到 Redis 里所有实例共享一份会话数据这是目前 Spring Boot 项目的绝对主流。5.2 Spring Session Redis 整合示例Spring Boot 整合 Spring Session Redis 非常简单加一个依赖、加一行注解就完事了。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.session/groupId artifactIdspring-session-data-redis/artifactId /dependency然后在启动类或配置类上加上EnableRedisHttpSession注解。加了之后Spring 容器中的HttpSession实现会被替换为基于 Redis 的实现。你代码里的request.getSession()拿到的已经是一个从 Redis 里读取的 Session 对象了。SpringBootApplication EnableRedisHttpSession(maxInactiveIntervalInSeconds 1800) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }maxInactiveIntervalInSeconds在这里设置的是 Spring Session 的会话超时时间优先级高于server.servlet.session.timeout单位是秒。建议用 180030 分钟这种整数配置起来清爽。5.3 分布式会话的过期感知Redis 过期事件监听把 Session 放到 Redis 后会话过期跟踪的逻辑就发生了质变会话过期不再是 Tomcat 的监听器来通知你了而是 Redis 的键过期事件来通知你。你可以通过订阅 Redis 的 Keyspace Notifications 来捕获__keyevent0__:expired事件从而感知 Session 过期。不过这里我要给你泼一盆冷水Redis 过期事件监听在工程上不是个好方案。原因有三点。第一Redis 的过期事件不是实时的。Redis 默认每 100ms 扫描一次过期键键真正过期到通知发出中间有延迟。第二Redis 的 Pub/Sub 机制是“发后即忘”的如果监听器挂了事件就丢了不及时消费就找不回来。第三订阅 Keyspace Notifications 会带来 Redis 服务端的额外 CPU 开销而且不同 Redis 版本的过期事件语义还不完全一致。所以我的建议是业务上不要依赖 Redis 过期事件去做精确的会话销毁通知。可以用它来做辅助清理比如延迟几分钟清一次报告但核心的“在线用户状态跟踪”应该基于请求链路中的活跃时间戳来判断。5.4 用 Spring Session 的SessionRepository主动查询会话信息Spring Session 提供了一个非常核心的接口——SessionRepository配合一个FindByIndexNameSessionRepository扩展接口你可以非常方便地按用户名查找所有活跃会话或者主动删除某个指定用户的会话实现“踢人下线”。这在分布式环境下的会话过期跟踪中是最实用的 API 之一。Service public class SessionQueryService { Autowired private FindByIndexNameSessionRepository? extends Session sessionRepository; public ListSession getSessionsByUsername(String username) { return sessionRepository.findByPrincipalName(username).stream().toList(); } public void expireSession(String sessionId) { // 获取会话并主动删除 Session session sessionRepository.findById(sessionId); if (session ! null) { sessionRepository.deleteById(sessionId); } } }注意findByPrincipalName并不是默认就能用的。你需要把用户唯一标识绑定到 Session 的SPRING_SECURITY_CONTEXT中的principal或者手动调用session.setAttribute(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, username)这样 Redis 里才会建立用户名到 Session ID 的索引查询和踢人才有依据。6. 前后端分离场景Token 会话过期跟踪的另类解法6.1 从 HttpSession 到 Token过期语义的转变如果你做的是前后端完全分离的项目前端是 Vue 或 React后端只提供 RESTful API那你大概率不会用 HttpSession而是用 TokenJWT 或 OAuth2 Token。Token 方案下的“会话过期”语义和 HttpSession 完全不一样了。HttpSession 是服务端集中管理的过期时间清晰、可控、可查询而 JWT 是自包含的校验时只需验证签名不需要访问服务端存储。JWT 的exp字段就是过期时间但一旦签发服务端很难主动让它提前失效除非引入黑名单机制。这就导致了一个很尴尬的局面用户被管理员踢下线了但手里的 JWT 还有效依然能访问接口。所以现在工程实践中做 Token 会话跟踪的主流做法是“JWT Redis 黑名单/白名单”的组合方案。JWT 负责无状态认证Redis 负责有状态的会话管理和过期控制。6.2 基于 Redis 的双层会话过期跟踪结构我比较推荐的做法是在用户登录成功后同时生成一组数据一个 JWT过期时间略长比如 2 小时一个 Redis 键例如login:token:{userId}值为当前有效的 token 标识并设置一个和 JWT 过期时间一致的 TTL一个 Redis 键例如login:session:{sessionId}存储用户会话的详情包括最近活跃时间、登录 IP、设备信息TTL 时长可设置得较长一些用于在线状态查询。每次请求经过拦截器时先解析 JWT 并校验签名再检查 Redis 里login:token:{userId}的值是否和当前 token 标识一致。如果不一致说明该用户的登录态已经在别的设备或新会话中被覆盖直接拒绝并返回 401。这种结构的最大优势是“可踢人、可追踪”。你要临时封禁某个用户只需要删除 Redis 中对应的login:token:{userId}键这个用户手里的 JWT 再有效也无法通过校验。你要查看当前在线用户只需要keys login:session:*量大的时候别这么干要用 scan就能拿到所有活跃会话。6.3 滑动过期策略与过期前提醒的实现前面提到 HttpSession 的超时是“空闲超时”有些业务场景也希望 Token 能实现同样的滑动过期效果用户一直操作就一直不让他掉线用户超过 30 分钟没有操作再访问就要求重新登录。实现滑动过期很简单关键在于“每次请求都续期”。在拦截器验证 Token 通过后重新计算 Redis 键的 TTLpublic void refreshTokenExpiration(String userId) { String key login:token: userId; Long ttl redisTemplate.getExpire(key); if (ttl ! null ttl 0) { // 重新设置过期时间等于把空闲时间窗口续上 redisTemplate.expire(key, Duration.ofMinutes(30)); } }如果要实现“过期前 5 分钟弹窗提醒”则需要在每次请求时计算当前 Token 的剩余有效时间。如果剩余时间少于某个阈值比如 5 分钟就在响应头里加一个标记前端发现这个标记后弹窗提示用户“会话即将过期是否保持登录”。前端点击“保持登录”后调用一个续期接口后端更新 Redis 和 JWT 的有效期。这个体验比直接强制踢用户下线要友好得多。7. 实战案例一个可复用的在线用户状态跟踪模块7.1 模块整体设计与数据模型本节我给你一个可以直接用在小型项目里的完整示例一个基于 Spring Boot Redis 的用户在线状态跟踪模块。它要实现以下几个功能用户登录后记录会话信息返回会话 ID每次带会话 ID 的请求过来自动更新最近活跃时间提供接口查询所有在线用户及最后活跃时间提供接口主动删除指定用户的会话实现“踢人下线”。这个设计完全不依赖于 Redis 过期事件核心思路是“用 TTL 兜底过期清理用活跃时间戳进行业务判断”。数据模型我建议用 Hash 结构存储每个用户的会话明细public class UserSessionInfo { private String sessionId; private Long userId; private String username; private String ipAddress; private String userAgent; private LocalDateTime loginTime; private LocalDateTime lastActiveTime; // getter/setter 省略 }Redis 存储结构是online:user:{userId}Hash 的 field 是sessionIdvalue 是上面的对象 JSON 序列化后的字符串。这样设计的好处是一个用户可以在多个设备上同时登录每个设备对应一条 Hash field。7.2 登录、活跃更新、踢人下线的核心代码登录成功后的会话登记public void registerSession(UserSessionInfo info) { String key online:user: info.getUserId(); redisTemplate.opsForHash().put(key, info.getSessionId(), JSON.toJSONString(info)); // 设置 Key 的存活时间如果用户一直活跃不到期 redisTemplate.expire(key, Duration.ofMinutes(60)); }活跃时间更新在拦截器里调用public void updateActiveTime(Long userId, String sessionId) { String key online:user: userId; Object data redisTemplate.opsForHash().get(key, sessionId); if (data null) return; UserSessionInfo info JSON.parseObject(data.toString(), UserSessionInfo.class); info.setLastActiveTime(LocalDateTime.now()); redisTemplate.opsForHash().put(key, sessionId, JSON.toJSONString(info)); }踢人下线管理员在后台点击后触发public void kickOut(Long userId, String sessionId) { String key online:user: userId; redisTemplate.opsForHash().delete(key, sessionId); // 同时删除该会话关联的登录 Token强制其下一次请求失败 String tokenKey login:token: userId : sessionId; redisTemplate.delete(tokenKey); }7.3 清理过期会话的后台定时任务虽然 Redis 的 TTL 会自动让 Hash Key 失效但业务上我们还是希望能够在会话过期后尽快从在线列表里移除而不是等到 Key 整体过期。所以我加了一个定时任务每分钟扫描一次用小项目可以接受把活跃时间距今超过 30 分钟的会话从 Hash 中删掉Component public class SessionCleanupTask { Autowired private UserSessionService sessionService; Scheduled(cron 0 * * * * ?) public void cleanupExpiredSessions() { // 使用 scan 游标方式扫描 online:user:* 的 key SetString keys redisTemplate.scan(online:user:*); for (String key : keys) { MapObject, Object sessions redisTemplate.opsForHash().entries(key); sessions.forEach((sessionId, val) - { UserSessionInfo info JSON.parseObject(val.toString(), UserSessionInfo.class); if (info.getLastActiveTime().isBefore(LocalDateTime.now().minusMinutes(30))) { redisTemplate.opsForHash().delete(key, sessionId); } }); } } }这套定时清扫方案和 HttpSessionListener 相比优势在于它能直接操作业务对象上的时间戳而不是等到容器内部的 Session 被销毁才事后补救。而且它天然支持分布式部署——多个实例的定时任务可以同时跑不会冲突因为大家都操作同一个 Redis。8. 常见问题与调试实战我被这些“过期”坑过8.1 请求带上旧 JSESSIONID为什么 Session 还是丢失排查会话过期问题时首先要明确一个操作顺序浏览器每次请求都会把当前域名的所有 Cookie 带上。如果你的系统改了域名或者前后端不在同一个域名下Cookie 就带不过去。典型的场景是前端在localhost:8080调试后端在localhost:9090跨域请求默认不带 Cookie除非后端设置Access-Control-Allow-Credentials: true并且前端用 axios 时显式声明withCredentials: true。可能还要检查一下 Session Cookie 的Path。如果 Session Cookie 的 Path 是/api那你访问/admin下的请求时浏览器不会携带这个 Cookie服务端就认为没有会话。Spring Boot 里可以配置server.servlet.session.cookie.path默认是根路径/一般不需要改但如果是老项目改造可能会有坑。8.2 Session 明明还没过期为什么 getSession(false) 返回 null常见的情况是应用重启了。Tomcat 重启后所有 Session 对象都从内存中清空但浏览器里还留着 JSESSIONID。下次请求带着这个 ID 过来容器发现找不到对应的 Session会生成一个新的。如果你在代码里用request.getSession(false)新生成的这个 Session 也不会被返回因为false严格限定“必须已经存在”。还有一种情况是 Redis 里的 Session 因内存淘汰策略被提前清掉了。Redismaxmemory-policy如果配置成了allkeys-lru或allkeys-random那么即使 Session 的 TTL 还没到内存不足时也会被强制驱逐。建议大家把 Redis 的淘汰策略设为volatile-lru只淘汰设置了过期时间的键而且要确保 Spring Session 的键都带有 TTL。8.3 监听器被触发两次的问题如果你的项目同时引入了 Spring Security并且 Security 的配置里也注册了HttpSessionEventPublisher监听器而你的业务代码里又写了一个自定义的HttpSessionListener你会发现同一个 Session 的创建和销毁事件可能在多个监听器里重复处理。解决方式是把所有逻辑收敛到一个监听器里或者把 Spring Security 自带的会话事件监听器去掉如果不需要HttpSessionIdChangedListener等功能。8.4 排查“哪个环节判定过期”的调试清单这里是我实际排查时必走的流程分享给你打开浏览器 DevTools 的 Network 面板看请求头里的Cookie字段是否包含 JSESSIONID 或自定义会话 ID。在 Response Headers 里查看Set-Cookie确认 Max-Age 或 Expires 是否符合预期。第一次请求会话在哪个实例创建的就让请求持续打到这个实例排除负载均衡问题。查 Redisttl spring:session:sessions:{sessionId}看 Session 的剩余存活时间是不是被某个操作意外刷新了。看应用日志里的监听器输出确认会话创建和销毁日志是否成对出现。9. 会话过期跟踪的最终方案选型建议讲了这么多最后给你一张选择建议表。以我的经验根据应用规模和架构形态选型基本逃不出这四种项目场景推荐方案理由单机部署、传统服务端渲染HttpSessionListener 拦截器简单直接无额外中间件够用单机或多机部署、前后端分离Token Redis 会话管理跨域友好踢人方便可跟踪活跃集群部署、需要会话共享Spring Session Redis天然支持负载均衡会话集中管理多服务微服务化JWT 无状态认证 Redis 黑名单服务间无共享存储依赖认证逻辑轻量不要一上来就上 Spring Session Redis。如果你的项目就一个实例、几十个人用加 Redis 完全是过度设计。同样的如果系统已经上了微服务治理还坚持用 HttpSession 做登录态那就等着各种跨服务调用的 Session 穿透问题折磨你吧。根据场景选型是这一章最重要的经验。我自己在实际项目里的做法是单体应用阶段用最简单的方式拦截器 注册表跑起来等到了必须横向扩容的时候再抽时间把会话层切换到 Spring Session Redis业务代码几乎不用动因为 HttpSession 这个 API 是一致。踩过几次坑之后我最大的体会就是会话过期跟踪方案最好在一开始就设计为可替换的不要和业务代码强耦合。后面想换方案也就是替换一个组件的事。