资讯动态

Shiro+Redis幽灵Session排查:前后端分离下匿名访问为何创建Session

发布时间:2026/10/13 1:04:00 来源:尧图企业网站定制
1. 问题现场一个让人抓狂的幽灵Session事情是这样的。前段时间我在做一个后台管理系统的重构技术栈选的是 Spring Boot 2.x Shiro Redis前端用 Vue 单独部署典型的前后端分离架构。认证方案上我用 Shiro 做权限控制Session 统一托管到 Redis这样多实例部署时不会出现会话不一致的问题。功能跑起来之后登录、鉴权、菜单权限这些都很正常。但我在做压测和日志巡检的时候发现了一个很诡异的现象Redis 里莫名其妙多出来一大堆 Session 记录而且这些 Session 对应的用户根本没登录过。更离谱的是这些幽灵 Session的过期时间还在不断刷新导致 Redis 内存缓慢上涨。一开始我以为是前端轮询接口导致的后来抓包一看这些请求全是未携带有效凭证的匿名访问——比如前端路由守卫没拦住、用户直接敲 URL、或者 Token 过期后前端还在发请求。按理说Shiro 遇到无权限访问应该直接返回 401压根不该创建 Session。可现实是每一次无权限访问Redis 里就多一条 Session。这个问题不解决线上跑一段时间 Redis 就会被这些垃圾 Session 撑爆。我花了差不多两天时间从 Shiro 的过滤器链一路追到 SessionManager 的源码才把根因彻底搞清楚。这篇文章就把整个排查链路、原理分析和最终方案完整地讲一遍如果你也在用 Shiro Redis 做前后端分离大概率会踩到同一个坑。2. 先搞清楚Shiro 的 Session 到底是在哪一步被创建的2.1 默认的 Subject 创建流程很多人对 Shiro 有个误解以为只有登录成功才会创建 Session。实际上不是的。Shiro 的核心抽象是Subject而Subject的创建和 Session 的创建是两件独立的事。当你发起一个请求ShiroFilter会通过SubjectFactory构建一个Subject实例。在默认的DefaultWebSubjectFactory里只要满足两个条件就会顺手创建一个 Session当前请求需要 Session比如走了authc过滤器当前线程还没有绑定 Session关键点在于Shiro 判断是否需要 Session的逻辑和是否登录成功没有必然关系。只要请求进入了需要认证的过滤器链Shiro 就会尝试getSession()而getSession()在默认配置下是getSession(true)——也就是不存在就创建一个。这就是幽灵 Session 的第一层来源。2.2 前后端分离下的特殊之处传统 Web 应用里Session 靠 Cookie 里的JSESSIONID维持浏览器会自动带上。但前后端分离之后前端通常把 Token 放在请求头里比如AuthorizationShiro 拿不到 Cookie于是每次请求对它来说都是新会话。我当时的配置大致是这样的Bean public DefaultWebSessionManager sessionManager(RedisSessionDAO redisSessionDAO) { DefaultWebSessionManager sessionManager new DefaultWebSessionManager(); sessionManager.setSessionDAO(redisSessionDAO); sessionManager.setGlobalSessionTimeout(1800000); sessionManager.setSessionIdCookieEnabled(false); // 关键关掉了 Cookie sessionManager.setSessionIdUrlRewritingEnabled(false); return sessionManager; }setSessionIdCookieEnabled(false)这一行是前后端分离的常规操作目的是不让 Shiro 往响应里写 Cookie。但副作用是Shiro 失去了通过 Cookie 复用 Session 的能力。如果前端又没有在请求头里带上 SessionId那么每次请求 Shiro 都会认为这是一个全新的会话于是创建新 Session 并写入 Redis。2.3 无权限访问为什么会触发创建这里要区分两个概念认证失败和授权失败。认证失败没登录或者 Token 无效走authc过滤器返回 401。授权失败登录了但没权限走roles或perms过滤器返回 403。问题出在认证失败这条路径上。当请求进入authc过滤器AuthenticatingFilter会调用isAccessAllowed()如果返回 false就会执行onAccessDenied()。而在onAccessDenied()内部Shiro 会先尝试获取 Subject 和 Session用于记录登录失败的状态或者保存SavedRequest。就是这一步把 Session 创建出来了。即使最终返回了 401Session 已经写进 Redis 了。而且因为globalSessionTimeout设了 30 分钟这些垃圾 Session 会在 Redis 里存活半小时期间如果同一个匿名用户再访问还可能刷新过期时间。3. 为什么关掉 Cookie反而让问题更严重3.1 SessionId 的传递链路断了在标准 Web 场景下Session 的复用依赖 SessionId 的传递。Shiro 默认通过 Cookie 传递JSESSIONID浏览器每次请求自动带上服务端就能根据这个 ID 找到已有 Session。前后端分离后我们关掉了 Cookie理论上应该改用请求头传递 SessionId。但Shiro 默认并不从请求头读 SessionId它只认 Cookie 和 URL 重写。所以如果你只是关掉 Cookie又没有自定义SessionIdResolver那么 SessionId 的传递链路就彻底断了。结果就是每个请求都是新会话每个请求都可能创建新 Session。这不仅仅是无权限访问的问题任何需要 Session 的请求都会这样。3.2 匿名请求的放大效应更麻烦的是匿名请求。前端路由守卫如果写得不够严谨或者用户手动改 URL很容易产生大量未携带 Token 的请求。这些请求打到后端Shiro 一看没有凭证走authc失败流程顺手创建一个 Session 写 Redis。我当时的日志里每分钟有几十条这样的匿名请求全是 401。对应到 Redis就是每分钟几十条新 Session。压测的时候这个数字被放大到每秒几百条Redis 的keyspace_hits直接被打崩。3.3 一个容易被忽略的细节SessionValidationSchedulerShiro 有个SessionValidationScheduler默认会定时扫描过期 Session 并清理。但这个调度器只清理已过期的 Session对于这种刚创建就被遗弃的 Session它无能为力。而且如果调度器配置不当比如没启用过期 Session 会一直堆在 Redis 里。我当时的配置里sessionValidationSchedulerEnabled是默认的 true但因为 Session 创建得太快清理速度根本跟不上创建速度。4. 根因定位从过滤器链到 SessionManager 的完整追踪4.1 第一步确认 Session 是谁创建的我在RedisSessionDAO的doCreate方法里加了一行日志打印当前线程的堆栈Override protected Serializable doCreate(Session session) { Serializable sessionId super.doCreate(session); // 打印堆栈定位创建来源 new Throwable(Session created here).printStackTrace(); return sessionId; }跑了一次匿名请求堆栈清晰地指向了DefaultWebSessionManager.createSession()再往上就是AbstractNativeSessionManager.getSession()最终调用方是AuthenticatingFilter.onAccessDenied()。根因确认无权限访问时Shiro 在认证失败处理流程中创建了 Session。4.2 第二步确认 SessionId 为什么没被复用我在请求头里手动加了一个Cookie: JSESSIONIDxxx发现 Session 能被复用。这说明问题不在 SessionManager而在SessionId 的传递方式。前端用的是 axios默认不会带 Cookie跨域情况下也没有在请求头里放 SessionId。所以每次请求对 Shiro 来说都是全新的。4.3 第三步确认关闭 Session 创建的可行性既然根因是无权限访问时创建了不必要的 Session那最直接的思路就是让 Shiro 在认证失败时不要创建 Session。Shiro 提供了SessionManager的setSessionIdCookieEnabled和setSessionIdUrlRewritingEnabled但这两个只是控制 SessionId 的传递方式不控制创建时机。真正能控制创建时机的是SubjectFactory和SessionManager的组合。我查了 Shiro 的源码发现DefaultWebSessionManager有一个createSession的入口但它是被getSession(true)触发的。如果我们能让认证失败流程走getSession(false)就不会创建 Session。5. 解决方案三套组合拳按场景选5.1 方案一自定义 SubjectFactory禁止匿名创建 Session这是最彻底的方案。核心思路是只有认证成功的请求才允许创建 Session匿名请求一律不创建。public class StatelessSubjectFactory extends DefaultWebSubjectFactory { Override public Subject createSubject(SubjectContext context) { // 如果当前请求没有认证凭证不创建 Session if (!context.isAuthenticated()) { context.setSessionCreationEnabled(false); } return super.createSubject(context); } }然后在 Shiro 配置里替换默认的 SubjectFactoryBean public SubjectFactory subjectFactory() { return new StatelessSubjectFactory(); } Bean public SecurityManager securityManager(RedisSessionDAO redisSessionDAO) { DefaultWebSecurityManager securityManager new DefaultWebSecurityManager(); securityManager.setSessionManager(sessionManager(redisSessionDAO)); securityManager.setSubjectFactory(subjectFactory()); return securityManager; }这个方案的好处是从源头掐断匿名请求根本不会走到创建 Session 的逻辑。实测下来Redis 里的垃圾 Session 直接归零。但要注意context.isAuthenticated()的判断时机是在 Subject 创建阶段此时认证还没真正执行。所以更严谨的做法是结合SessionCreationEnabled的上下文传递确保只有明确需要 Session 的请求才开启。5.2 方案二自定义 SessionManager重写 createSession如果你不想动 SubjectFactory可以退一步在 SessionManager 层面拦截。public class NoAnonymousSessionManager extends DefaultWebSessionManager { Override protected Session createSession(SessionContext context) { // 检查当前请求是否携带有效凭证 HttpServletRequest request WebUtils.getHttpRequest(ServletRequestContextHolder.getRequest()); String token request.getHeader(Authorization); if (token null || token.isEmpty()) { // 匿名请求不创建 Session return null; } return super.createSession(context); } }这个方案更直观直接看请求头里有没有 Token。但缺点是耦合了业务逻辑SessionManager 本来不该关心 Token 长什么样。而且如果 Token 无效但存在还是会创建 Session。5.3 方案三前端配合无权限时不发请求后端方案是治本但前端也得配合。我当时的做法是在 axios 的请求拦截器里统一检查 Token 是否存在不存在直接跳登录页不发请求。在响应拦截器里遇到 401 清除本地 Token 并跳登录页避免重复请求。路由守卫里对未登录用户直接拦截不进入需要权限的页面。// axios 请求拦截器 axios.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] token; } else if (config.url ! /login) { // 无 Token 且不是登录请求直接拒绝 return Promise.reject(new Error(未登录)); } return config; });这三套组合拳下来Redis 里的垃圾 Session 问题彻底解决。我最终采用的是方案一 方案三后端从源头禁止匿名创建 Session前端减少无效请求。6. 实测数据与踩坑记录6.1 优化前后的 Redis 对比指标优化前优化后匿名请求产生的 Session 数每分钟约 40 条0Redis 内存增长1小时约 12MB约 0.5MB401 响应平均耗时85ms12msSession 清理压力高调度器频繁触发低几乎无垃圾优化后最明显的变化是401 响应变快了。因为不再创建 Session省去了 Redis 写入和序列化的开销响应时间从 85ms 降到 12ms。6.2 踩过的坑SessionCreationEnabled 的传递在实现方案一的时候我一开始只设置了context.setSessionCreationEnabled(false)但发现还是会有 Session 创建。后来查源码才发现SubjectContext的sessionCreationEnabled属性在DefaultSecurityManager.createSubject()里会被重新计算如果SubjectContext是从Subject.Builder构建的这个属性可能被覆盖。正确的做法是在SubjectFactory里同时检查context.resolveSession()和context.isSessionCreationEnabled()确保逻辑一致。6.3 踩过的坑RedisSessionDAO 的过期时间还有一个坑是RedisSessionDAO的过期时间。Shiro 的globalSessionTimeout和 Redis 的 key 过期时间是两套机制。如果只设了globalSessionTimeoutRedis 里的 key 不会自动过期需要RedisSessionDAO在update时手动设置expire。Override public void update(Session session) throws UnknownSessionException { byte[] key getByteKey(session.getId()); byte[] value serialize(session); // 设置 Redis 过期时间单位秒 redisTemplate.opsForValue().set(key, value, session.getTimeout() / 1000, TimeUnit.SECONDS); }如果不设这个Session 会永久留在 Redis 里比幽灵 Session 还可怕。7. 给后来者的几条实操建议第一前后端分离下Shiro 的 Session 管理一定要重新设计。默认配置是为传统 Web 准备的直接拿来用必踩坑。要么彻底无状态用 JWT不用 Session要么自定义 SessionId 的传递方式比如从请求头读。第二认证失败和授权失败要分开处理。认证失败走 401授权失败走 403两者的过滤器链和 Session 创建逻辑不一样。我见过有人把两者混在一起导致权限问题排查了半天。第三Redis 里的 Session 一定要设过期时间。不管是RedisSessionDAO还是自定义实现都要确保 key 有 TTL。否则一旦有垃圾 Session就是永久占用内存。第四压测的时候重点看匿名请求。正常业务请求的 Session 创建是可控的真正失控的是匿名请求。压测脚本里加一些不带 Token 的请求很容易复现这个问题。第五日志里加 Session 创建堆栈。这个技巧在排查 Session 相关问题时特别有用能直接定位到是哪段代码触发的创建。生产环境可以改成采样打印避免日志爆炸。这套方案我在两个项目里都验证过Spring Boot 2.3 和 2.7 都跑得通。Shiro 虽然老但在一些存量系统里还是主力把 Session 这块理顺了后面能省很多事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑