☰
Shiro+Redis幽灵Session排查:前后端分离下匿名访问为何创建Session
2026/10/13 1:03:50 网站建设 项目流程

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 放在请求头里(比如Authorization),Shiro 拿不到 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 创建出来了。即使最终返回了 401,Session 已经写进 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 一个容易被忽略的细节:SessionValidationScheduler

Shiro 有个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: JSESSIONID=xxx,发现 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 配置里替换默认的 SubjectFactory:

@Bean 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 条0
Redis 内存增长(1小时)约 12MB约 0.5MB
401 响应平均耗时85ms12ms
Session 清理压力高,调度器频繁触发低,几乎无垃圾

优化后最明显的变化是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 过期时间是两套机制。如果只设了globalSessionTimeout,Redis 里的 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 这块理顺了,后面能省很多事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询