☰
Spring Boot+Shiro+Redis前后端分离:如何避免匿名Session堆积
2026/10/13 14:07:16 网站建设 项目流程

1. 问题背景与整体设计思路拆解

前后端分离架构下,Spring Boot 2.x 搭配 Shiro 做认证鉴权,再用 Redis 做分布式 Session 存储,这套组合在几年前的项目里非常常见。我最近接手的一个后台管理系统就是这套技术栈,前端用 Vue 独立部署,后端只提供 RESTful 接口,通过 Token 或者 Cookie 来维持登录态。项目上线后遇到一个很典型的问题:当用户未登录或者权限不足去访问受保护接口时,Shiro 会创建 Session 并写入 Redis,导致 Redis 里堆积大量匿名会话。这个问题在压测和爬虫扫描时尤其明显,Redis 内存涨得飞快,清理起来也麻烦。

这个问题的本质,其实不是 Shiro 的 Bug,而是 Shiro 默认的 Session 管理机制和前后端分离场景之间的“水土不服”。在传统的服务端渲染项目里,用户访问页面时创建 Session 是合理的,因为浏览器会带着 Cookie 来回跳转。但在前后端分离场景下,前端调用接口如果没带有效凭证,后端应该直接返回 401 或 403,而不是先给这个匿名请求分配一个 Session。Shiro 的默认行为是:只要请求进入了 Shiro 过滤器链,并且配置了 SessionManager,它就会在需要的时候创建 Session。这个“需要的时候”包括认证失败、权限校验失败等场景,因为 Shiro 要把这些状态存到 Session 里。

我先把整体解决思路说清楚,后面再展开细节。核心目标有三个:第一,未认证或未授权的请求不要创建 Session;第二,已经创建的合法 Session 要正常存入 Redis;第三,前后端分离下认证失败要返回 JSON 而不是重定向到登录页。围绕这三个目标,方案设计上需要从 Shiro 的过滤器链、SessionManager 的创建策略、以及自定义 Filter 三个层面入手。很多人一上来就去改 Redis 配置或者加拦截器,其实方向偏了,因为 Session 的创建时机是由 Shiro 内部逻辑决定的,外部拦截器拦不住。

为什么选择从 Shiro 过滤器链入手?因为 Shiro 的整个请求处理流程是:请求先经过 FilterChain,每个 Filter 决定是否继续往下走。authc过滤器负责认证,perms和roles过滤器负责授权。当这些过滤器发现当前请求没有认证信息时,默认会调用subject.login()或者直接抛出异常,而在这个过程中,如果 SessionManager 存在,Shiro 就会顺手创建一个 Session 来保存“未认证”这个状态。所以,要阻止 Session 创建,最直接的办法就是让这些过滤器在失败时不要触发 Session 创建逻辑,或者干脆换掉默认的过滤器实现。

另一个关键点是前后端分离下的响应格式。传统 Web 项目里,认证失败会重定向到/login页面,但前后端分离时前端期望收到 JSON,比如{"code": 401, "msg": "未登录"}。Shiro 默认的FormAuthenticationFilter和PermissionsAuthorizationFilter都会做重定向,这显然不合适。所以我们需要自定义这些过滤器,覆盖它们的onAccessDenied方法,直接写出 JSON 响应并终止请求。这样一来,既解决了响应格式问题,也顺带避免了 Session 创建,因为请求在过滤器层面就被截断了,不会继续走到 Session 创建的逻辑里。

还有一个容易被忽略的点:Redis 里已经存在的匿名 Session 怎么清理。即使我们修复了创建逻辑,之前遗留的脏数据还在。这些 Session 通常没有过期时间或者过期时间很长,需要写一个清理任务或者手动扫描删除。我在项目里是加了一个定时任务,每天凌晨扫描 Redis 中shiro:session:前缀的 key,检查其对应的 Session 是否包含认证信息,没有的就直接删除。这个逻辑后面会详细说。

2. 核心细节解析与实操要点

2.1 Shiro Session 创建时机的底层逻辑

要彻底理解这个问题,得先搞清楚 Shiro 在什么情况下会创建 Session。Shiro 的DefaultSessionManager有一个createSession方法,但它不是随便调用的。真正触发创建的是Subject的getSession()方法,而getSession()又会在以下几种场景被调用:一是subject.login()成功后,Shiro 会把认证信息写入 Session;二是subject.login()失败后,Shiro 会把失败次数、锁定状态等写入 Session;三是权限校验失败时,Shiro 会把异常信息写入 Session;四是某些过滤器在onPreHandle阶段主动调用getSession()来检查状态。

在前后端分离场景下,最常触发 Session 创建的就是第二种和第三种。比如用户用错误的 Token 访问接口,authc过滤器会尝试认证,认证失败后 Shiro 会创建一个 Session 来记录这次失败。再比如用户已登录但访问了没有权限的接口,perms过滤器会抛出UnauthorizedException,Shiro 同样会创建 Session 来保存异常信息。这些 Session 对业务毫无价值,纯粹是负担。

那为什么 Shiro 要这么设计?因为在传统 Web 应用里,Session 是跨请求共享状态的唯一手段。比如用户连续输错密码,Shiro 需要记住失败次数来实现锁定策略,这些信息必须存在 Session 里。但在前后端分离场景下,我们通常用 Token 或者 JWT 来承载状态,不需要服务端 Session 来记这些。所以,核心思路就是让 Shiro 在认证和授权失败时不要创建 Session,而是直接返回错误响应。

具体怎么做?有两个层面:一是配置SessionManager的sessionIdCookieEnabled和sessionIdUrlRewritingEnabled为 false,减少不必要的 Session 追踪;二是自定义过滤器,在onAccessDenied里直接写响应,不调用getSession()。我实测下来,光改配置不够,因为 Shiro 内部还是会调用getSession(),必须从过滤器层面截断。

2.2 自定义过滤器与 Session 创建策略的配合

自定义过滤器的写法其实不复杂,关键是继承正确的基类并覆盖正确的方法。对于认证过滤器,通常继承AuthenticatingFilter或FormAuthenticationFilter;对于授权过滤器,继承AuthorizationFilter或PermissionsAuthorizationFilter。我选择继承AuthenticatingFilter和PermissionsAuthorizationFilter,因为这两个基类提供了完整的认证和授权逻辑,只需要覆盖onAccessDenied方法即可。

在onAccessDenied方法里,我们要做三件事:第一,设置响应状态码为 401 或 403;第二,设置响应内容类型为application/json;第三,写出 JSON 格式的错误信息。这里有个细节:不要调用super.onAccessDenied(),因为父类方法会尝试重定向或者创建 Session。直接写响应并返回false,告诉 Shiro 请求处理到此为止。

另外,还需要在 Shiro 的配置类里把这些自定义过滤器注册进去,并替换默认的authc和perms。配置方式有两种:一种是使用ShiroFilterFactoryBean的setFilters方法注册自定义过滤器,然后在filterChainDefinitionMap里引用;另一种是使用@Bean注解直接注册。我推荐第一种,因为可以精确控制过滤器链的顺序和匹配规则。

还有一个关键配置是SessionManager的globalSessionTimeout和deleteInvalidSessions。globalSessionTimeout控制 Session 的过期时间,默认是 30 分钟,对于后台管理系统可以适当延长到 2 小时。deleteInvalidSessions设为 true 可以让 Shiro 自动清理过期 Session,减少 Redis 里的垃圾数据。但要注意,这个清理是惰性的,只有在下一次访问时才会触发,所以还需要配合定时任务做主动清理。

2.3 Redis Session 序列化与存储结构优化

Shiro 的RedisSessionDAO默认使用 JDK 序列化,存到 Redis 里的是二进制数据,可读性差且体积大。我建议换成Jackson或者Fastjson序列化,这样存进去的是 JSON 字符串,方便排查问题。配置方式是在RedisSessionDAO里设置sessionIdGenerator和serializer。不过要注意,Shiro 的Session对象结构比较复杂,直接序列化可能会报错,需要自定义ObjectMapper并注册SimpleModule来处理Session接口。

Redis 的 key 前缀也值得优化。默认是shiro:session:,这个前缀在 Redis 里会占用不少空间,可以缩短为sess:。同时,给 Session 设置合理的 TTL,避免永久驻留。我一般设置 TTL 为globalSessionTimeout的 1.5 倍,这样即使 Shiro 的惰性清理没触发,Redis 也会自动淘汰过期数据。

还有一个坑是Redis 集群模式下的 Session 共享。如果项目用了 Redis Cluster,RedisSessionDAO的save和readSession方法需要确保 key 落在同一个槽上,否则会报CROSSSLOT错误。解决办法是在 key 里加 hash tag,比如sess:{sessionId},这样 Redis 会根据{}里的内容计算槽位,保证同一个 Session 的读写落在同一个节点。

3. 实操过程与核心环节实现

3.1 环境准备与依赖配置

先确认项目的基础依赖。Spring Boot 2.x 的版本我用的 2.3.12.RELEASE,Shiro 用的是 1.7.1,Redis 客户端用的是 Spring Data Redis 的 Lettuce。Maven 依赖里需要引入shiro-spring-boot-web-starter和shiro-redis这两个核心包。注意shiro-redis的版本要和 Shiro 匹配,我用的 3.3.1 版本兼容性不错。

<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring-boot-web-starter</artifactId> <version>1.7.1</version> </dependency> <dependency> <groupId>org.crazycake</groupId> <artifactId>shiro-redis</artifactId> <version>3.3.1</version> </dependency>

配置文件里加上 Redis 的连接信息,以及 Shiro 的 Session 相关配置。我习惯把 Shiro 的配置放在application.yml里,方便不同环境切换。

spring: redis: host: 127.0.0.1 port: 6379 password: database: 0 timeout: 5000 lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 shiro: session: timeout: 7200000 cookie-enabled: false url-rewriting-enabled: false

这里cookie-enabled和url-rewriting-enabled都设为 false,因为前后端分离下我们不用 Cookie 来传 Session ID,而是用 Token。这两个配置能减少 Shiro 对 Session 的依赖,但还不够,后面还要改过滤器。

3.2 自定义认证与授权过滤器

接下来写两个自定义过滤器。第一个是CustomAuthFilter,继承AuthenticatingFilter,覆盖onAccessDenied方法。这个方法在认证失败时被调用,我们直接返回 JSON 响应。

public class CustomAuthFilter extends AuthenticatingFilter { @Override protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) { String token = ((HttpServletRequest) request).getHeader("Authorization"); return new BearerToken(token); } @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setStatus(HttpStatus.UNAUTHORIZED.value()); httpResponse.setContentType("application/json;charset=UTF-8"); httpResponse.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录已过期\"}"); return false; } @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { return super.isAccessAllowed(request, response, mappedValue) || (!isLoginRequest(request, response) && isPermissive(mappedValue)); } }

第二个是CustomPermsFilter,继承PermissionsAuthorizationFilter,同样覆盖onAccessDenied。这个过滤器在权限不足时被调用,返回 403。

public class CustomPermsFilter extends PermissionsAuthorizationFilter { @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws IOException { HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setStatus(HttpStatus.FORBIDDEN.value()); httpResponse.setContentType("application/json;charset=UTF-8"); httpResponse.getWriter().write("{\"code\":403,\"msg\":\"权限不足,请联系管理员\"}"); return false; } }

这两个过滤器的关键点在于:不调用super.onAccessDenied(),因为父类方法会尝试创建 Session 或者重定向。直接写响应并返回 false,请求就终止了,Shiro 不会继续往下走,也就不会创建 Session。

3.3 Shiro 配置类与过滤器链注册

配置类里要做几件事:注册自定义过滤器、配置 SessionManager、配置 RedisSessionDAO、设置过滤器链。我习惯用一个ShiroConfig类集中管理。

@Configuration public class ShiroConfig { @Bean public CustomAuthFilter customAuthFilter() { return new CustomAuthFilter(); } @Bean public CustomPermsFilter customPermsFilter() { return new CustomPermsFilter(); } @Bean public RedisSessionDAO redisSessionDAO(RedisTemplate<String, Object> redisTemplate) { RedisSessionDAO redisSessionDAO = new RedisSessionDAO(); redisSessionDAO.setRedisManager(redisManager()); redisSessionDAO.setExpire(7200); redisSessionDAO.setKeyPrefix("sess:"); return redisSessionDAO; } @Bean public DefaultWebSessionManager sessionManager(RedisSessionDAO redisSessionDAO) { DefaultWebSessionManager sessionManager = new DefaultWebSessionManager(); sessionManager.setSessionDAO(redisSessionDAO); sessionManager.setGlobalSessionTimeout(7200000); sessionManager.setDeleteInvalidSessions(true); sessionManager.setSessionIdCookieEnabled(false); sessionManager.setSessionIdUrlRewritingEnabled(false); return sessionManager; } @Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); Map<String, Filter> filters = new LinkedHashMap<>(); filters.put("authc", customAuthFilter()); filters.put("perms", customPermsFilter()); factoryBean.setFilters(filters); Map<String, String> chain = new LinkedHashMap<>(); chain.put("/api/login", "anon"); chain.put("/api/public/**", "anon"); chain.put("/api/**", "authc"); chain.put("/api/admin/**", "authc,perms[admin]"); factoryBean.setFilterChainDefinitionMap(chain); return factoryBean; } }

这里有个细节:chain.put("/api/**", "authc")要放在chain.put("/api/admin/**", "authc,perms[admin]")前面,因为 Shiro 的过滤器链是按顺序匹配的,第一个匹配上的规则生效。如果顺序反了,/api/admin/**的请求会先匹配到/api/**,导致权限校验被跳过。

3.4 Redis 中匿名 Session 的清理方案

即使修复了创建逻辑,之前遗留的匿名 Session 还在 Redis 里。我写了一个定时任务,每天凌晨 3 点扫描sess:前缀的 key,检查其内容是否包含认证信息。判断依据是 Session 的attributeKeys里是否有org.apache.shiro.subject.support.DefaultSubjectContext_PRINCIPALS_SESSION_KEY这个 key,如果没有,说明是匿名 Session,直接删除。

@Component public class SessionCleanTask { @Autowired private RedisTemplate<String, Object> redisTemplate; @Scheduled(cron = "0 0 3 * * ?") public void cleanAnonymousSessions() { Set<String> keys = redisTemplate.keys("sess:*"); if (keys == null || keys.isEmpty()) { return; } for (String key : keys) { Object value = redisTemplate.opsForValue().get(key); if (value instanceof byte[]) { // 反序列化后检查 try { Object session = SerializeUtils.deserialize((byte[]) value); if (session instanceof SimpleSession) { SimpleSession simpleSession = (SimpleSession) session; if (!simpleSession.getAttributeKeys().contains( DefaultSubjectContext.PRINCIPALS_SESSION_KEY)) { redisTemplate.delete(key); } } } catch (Exception e) { // 反序列化失败,直接删除 redisTemplate.delete(key); } } } } }

这个任务要注意两点:一是redisTemplate.keys()在数据量大时会阻塞 Redis,建议用scan命令替代;二是反序列化可能失败,要做好异常处理,失败的直接删除,避免脏数据堆积。

4. 常见问题与排查技巧实录

4.1 认证失败仍然创建 Session 的排查

有时候明明改了过滤器,Redis 里还是会出现匿名 Session。这种情况通常是过滤器链配置有问题,或者自定义过滤器没有生效。排查步骤是这样的:首先,在onAccessDenied方法里打日志,确认请求是否走到了这里。如果没有日志,说明过滤器没生效,检查ShiroFilterFactoryBean的setFilters是否注册了自定义过滤器,以及filterChainDefinitionMap里是否引用了正确的过滤器名称。

其次,检查是否有其他过滤器在onAccessDenied之前调用了getSession()。比如UserFilter或者LogoutFilter,这些过滤器可能会在内部创建 Session。解决办法是在这些过滤器里也覆盖相应方法,或者调整过滤器链顺序,让自定义过滤器优先执行。

还有一个隐蔽的原因是Shiro 的Subject在请求开始时就被创建了。Shiro 的Subject是通过SubjectFactory创建的,默认的DefaultWebSubjectFactory会在创建Subject时检查是否需要 Session。如果SessionManager存在,它可能会提前创建 Session。解决办法是自定义SubjectFactory,在createSubject方法里判断当前请求是否已认证,未认证则返回一个不绑定 Session 的Subject。

4.2 Redis 序列化异常与 Session 读取失败

用 Jackson 序列化 Shiro 的 Session 时,经常会遇到InvalidDefinitionException,原因是SimpleSession里的某些字段没有 getter/setter,或者类型不匹配。解决办法是自定义ObjectMapper,注册SimpleModule并添加SimpleSession的序列化器和反序列化器。或者干脆用 JDK 序列化,虽然可读性差,但兼容性最好。

另一个常见问题是Session 读取失败导致用户频繁掉线。这通常是因为 Redis 里的 Session 数据被意外修改或删除。排查时先用redis-cli查看 key 是否存在,再用TTL命令看过期时间。如果 key 存在但读取失败,可能是序列化格式不匹配,比如之前用 JDK 序列化,后来换成了 Jackson,旧数据无法反序列化。解决办法是清空 Redis 里的 Session 数据,让用户重新登录。

4.3 前后端分离下的跨域与 Token 传递问题

前后端分离时,前端通常把 Token 放在Authorization头里,但 Shiro 默认从 Cookie 或请求参数里取 Token。所以需要在自定义过滤器里覆盖createToken方法,从 Header 里读取 Token。同时,跨域请求要配置 CORS,否则浏览器会拦截响应。Spring Boot 里可以用CorsFilter或者@CrossOrigin注解,但要注意 CORS 过滤器要在 Shiro 过滤器之前执行,否则预检请求会被 Shiro 拦截。

Token 的刷新也是个问题。如果 Token 过期时间短,用户频繁掉线;如果太长,安全性又差。我一般用双 Token 机制:Access Token 有效期 2 小时,Refresh Token 有效期 7 天。Access Token 过期后用 Refresh Token 换新的,Refresh Token 过期则要求重新登录。这个逻辑可以在自定义过滤器里实现,也可以在 Controller 层单独处理。

4.4 常见问题速查表

问题现象可能原因排查方法解决方案
Redis 里出现匿名 Session过滤器未生效或调用了 getSession在 onAccessDenied 打日志覆盖 onAccessDenied,不调用 super
认证失败返回重定向而非 JSON使用了默认的 FormAuthenticationFilter查看响应状态码是否为 302自定义过滤器,直接写 JSON
Session 读取失败序列化格式不匹配用 redis-cli 查看 key 内容统一序列化方式,清空旧数据
跨域请求被拦截CORS 过滤器在 Shiro 之后查看浏览器控制台报错调整过滤器顺序,CORS 优先
Session 过期时间不生效Redis TTL 未设置或 Shiro 未清理用 TTL 命令查看设置 RedisSessionDAO 的 expire

4.5 实操心得与避坑技巧

第一个心得是不要在生产环境直接用keys命令。keys会阻塞 Redis,数据量大时可能导致服务不可用。用scan命令替代,每次扫描 100 个 key,分批处理。第二个心得是Session 的 TTL 要略大于 Shiro 的 globalSessionTimeout,因为 Shiro 的清理是惰性的,如果 TTL 和 timeout 一样,可能会出现 Shiro 还没清理,Redis 就先过期了,导致用户掉线。第三个心得是自定义过滤器的顺序很重要,anon过滤器要放在最前面,authc其次,perms最后,这样匿名接口不会被认证拦截,认证接口不会被权限拦截。

还有一个坑是Shiro 的logout操作。默认的LogoutFilter会删除 Session,但在前后端分离下,前端通常只清除本地 Token,不调用后端 logout 接口。这会导致 Redis 里的 Session 一直存在,直到过期。解决办法是在前端清除 Token 的同时,调用后端 logout 接口,或者把 Session 的 TTL 设短一点,让它自动过期。

最后分享一个调试技巧:在onAccessDenied方法里打印当前请求的 URL、Header 和 Session ID,这样能快速定位是哪个请求触发了 Session 创建。我当初就是靠这个日志发现,原来是一个健康检查接口被配置成了authc,导致每次检查都创建一个匿名 Session。把健康检查接口改成anon后,问题就消失了。

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

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

立即咨询