1. 从"登录"到"授权",Shiro 帮你拆解的这条链路
做 Web 开发的人都会经历这么一个阶段:最开始写接口只考虑业务逻辑,等到用户体系一接入,马上发现"登录、退出、权限控制、会话保持"这一堆横切逻辑远比想象中复杂。我曾经接过一个内部管理系统的需求,需求文档里只写了两个大字:"权限"。结果前后折腾了两周,大部分时间不是花在业务上,而是花在"怎么让登录状态安全可靠地保持住""怎么让不同角色看到不同菜单、调用不同接口"这些基础问题上。
SpringBoot 生态里提到权限方案,大家第一反应多半是 Spring Security。但我个人更偏爱 Apache Shiro,原因很直接:Shiro 的学习曲线平滑,API 设计直观,核心概念掰着手指头能数清。它把权限问题抽象成一套极其清晰的模型,而不是像 Spring Security 那样每次配个过滤器链都要翻阅一堆文档。
如果你了解 Shiro,一定知道这三个核心概念:
- Subject:当前操作的用户,或者更准确地说,当前这段代码的执行主体。
- SecurityManager:整个权限体系的调度中心,负责协调认证、授权、会话管理。
- Realm:数据访问桥接层,Shiro 不关心用户数据存在数据库还是 LDAP,它只认 Realm 返回的结果。
认证和授权的整体流程大致是这样的:用户提交账号密码后调用Subject.login(),SecurityManager 接手后去触发 Realm 的认证方法,Realm 从库里查出用户信息,Shiro 负责比对密码,认证通过后把登录状态写入会话;授权则是在每次访问受限接口时,由拦截器检查当前 Subject 是否拥有对应角色或权限,没有就抛出异常。
这套模型最大的优点是"边界清晰":认证、授权、会话管理各管各的,互不干扰。但落到工程层面,光靠 Shiro 默认的本地内存 Session 是不够的——一旦应用启动了多个实例,Session 就不同步了;权限数据每次都查数据库,压力也不小。这时候就需要引入 Redis。Redis 在这里要干三件事:存 Session、缓存授权数据、解决多实例下的会话共享。标题里的三个组件,分工就是这么来的。
1.1 认证、授权、会话管理,三者其实可以分开看
很多人把"认证"和"授权"混为一谈,觉得登进去之后权限就一起搞定了。实际上这是两个完全独立的阶段。
认证是"验明正身":你是谁?证明方式通常是账号密码、验证码、证书、令牌等。Shiro 的认证结果是生成一个 Subject 主体,并且把这个主体的登录状态记录到 Session 中。认证只做一次,通常发生在登录那一刻。
授权是"确认资格":你是谁已经知道了,但你有没有权限执行某个操作?比如普通员工不能删除订单,管理员可以;财务角色能看到薪资报表,普通开发看不到。授权通常发生在每次请求访问受保护资源时,Shiro 会基于当前 Subject 的身份去加载其角色和权限集合,然后做匹配。
会话管理则是把认证后的状态跨请求保持住。HTTP 是无状态的,如果每次请求都要重新登录一次,体验会极其糟糕。Shiro 默认基于 Servlet 容器的 Session,结合 Cookie 中的 Session ID 来识别"这个人之前登录过"。
只有把这三件事拆开理解,后面配置 Shiro 时才不会晕。比如有的项目只要登录不要求权限,那授权相关的代码完全可以不写,只配置认证即可;有的项目要求接口级权限,那 URL 过滤规则和权限注解就必不可少。
1.2 内存 Session 的痛点,逼我把 Redis 拉进来
Shiro 开箱即用的 Session 管理是基于内存的:Session 对象直接存在当前 JVM 堆里。单机开发测试没问题,一旦部署到多台服务器,就会出现一个非常经典的问题——用户在 A 服务器登录了,下一次请求被负载均衡转发到 B 服务器,B 服务器的内存里根本没有这个 Session,于是用户被强制重新登录。
解决思路无非两种:一种是做 Session 粘滞,让同一个用户的请求始终落在同一台服务器上,但这样做负载均衡的效果会被削弱,而且服务器重启即丢失;另一种就是把 Session 数据抽取出来,放到一个所有实例都能访问的公共存储里,这就是 Redis 的用武之地。
Redis 在这里解决的不只是"多实例会话共享"一个问题。权限数据的缓存同样重要:用户的角色和权限查询往往涉及多表关联,低频变化但高频读取,适合缓存。Shiro 提供 AuthorizingCache,配合 Redis 可以把权限集合缓存起来,避免一次接口调用触发多次 SQL 查询。这两点叠加,Redis 就从一个"可选项"变成了"必选项"。
2. 项目搭建,从依赖到配置类的步步拆解
这一节我们直接进入实操。我会拿一个模拟项目X来演示,这个项目X是一个典型的管理后台,包含用户管理、角色管理这两个基础模块,目标是把 Shiro 的认证和授权完整跑通。项目X的技术栈是 SpringBoot(Web 环境)、Shiro、Redis、MyBatis-Plus,数据库用 MySQL。
先说搭建环境的基本前提。JDK 建议 8 以上,SpringBoot 版本我用的 2.6.x,Shiro 版本 1.8.0,Redis 客户端使用 RedisTemplate 默认的 Lettuce。这个版本组合我实测是比较稳的,如果你的 SpringBoot 版本更高,比如 2.7 或者 3.x,可能需要稍微调整 Shiro 的依赖坐标,这个后面专门说。
2.1 依赖引入的版本匹配,最容易在这里翻车
Maven 依赖里需要加入以下内容,最核心的是 shiro-spring 和 shiro-ehcache 中的某几个模块,但注意这里不要引入 shiro-ehcache,因为我们走的是 Redis 路线。
<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring</artifactId> <version>1.8.0</version> </dependency> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-core</artifactId> <version>1.8.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.83</version> </dependency>为什么要分两个 org.apache.shiro 的依赖?单纯引入 shiro-spring 其实在 SpringBoot 集成时也够用,但很多老的 Shiro 工具类(比如org.apache.shiro.util.Assert、org.apache.shiro.codec.Base64)位于 shiro-core 里,为了保险我会一起引进来。
这里最需要注意的是版本冲突。Shiro 1.8.0 依赖的commons-beanutils版本较老,如果你的项目里有其他组件间接引用了更新的 commons-beanutils,可能出现NoSuchMethodError。我的建议是遇到这类问题先看依赖树:mvn dependency:tree,找到冲突后排除掉,保留 Shiro 需要的版本即可。
2.2 Shiro 配置类的三个核心 Bean
SpringBoot 整合 Shiro 的核心是写一个配置类,向容器中注册三个 Bean:Realm、SecurityManager、ShiroFilterFactoryBean。下面这个配置类是我常用的模板,注释里写了每个 Bean 的作用:
@Configuration public class ShiroConfig { @Bean public UserRealm userRealm() { return new UserRealm(); } @Bean public SecurityManager securityManager(UserRealm userRealm, RedisSessionDAO redisSessionDAO, RedisCacheManager redisCacheManager) { DefaultWebSecurityManager manager = new DefaultWebSecurityManager(); manager.setRealm(userRealm); manager.setSessionManager(sessionManager(redisSessionDAO)); manager.setCacheManager(redisCacheManager); return manager; } @Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean bean = new ShiroFilterFactoryBean(); bean.setSecurityManager(securityManager); // 认证失败后跳转的页面 bean.setLoginUrl("/login"); // 认证通过但授权失败跳转的页面 bean.setUnauthorizedUrl("/unauthorized"); Map<String, String> filterChain = new LinkedHashMap<>(); // anon 表示匿名可访问,authc 表示需要认证 filterChain.put("/login", "anon"); filterChain.put("/captcha", "anon"); filterChain.put("/css/**", "anon"); filterChain.put("/js/**", "anon"); // /admin 下的所有路径都需要登录后可访问 filterChain.put("/admin/**", "authc"); // /admin/delete 需要拥有 delete 权限 filterChain.put("/admin/delete", "perms[user:delete]"); // /admin/dashboard 需要 admin 角色 filterChain.put("/admin/dashboard", "roles[admin]"); filterChain.put("/**", "authc"); bean.setFilterChainDefinitionMap(filterChain); return bean; } }这段配置里有一个很关键的细节:filterChain 是有顺序的。Shiro 按照 Map 的插入顺序依次匹配 URL,一旦命中就停止继续匹配。所以/admin/delete这条规则必须写在/admin/**之前,否则请求在匹配到/admin/**的authc后就直接通过规则匹配了,后面的perms[user:delete]永远不会生效。这是 Shiro 配置中最常见的坑之一。
2.3 自定义 SessionManager 和 CacheManager
上面配置类里我引用了一个sessionManager()方法和两个 Redis 相关的 Bean,这是 Shiro 和 Redis 打通的关键。需要说明的是:Shiro 的默认 SessionManager 是 ServletContainerSessionManager,它把 Session 委托给 Servlet 容器管理。我们要做的,是换成 Shiro 自己接管 Session 的 DefaultWebSessionManager,并把 Session 持久化到 Redis。
@Bean public DefaultWebSessionManager sessionManager(RedisSessionDAO redisSessionDAO) { DefaultWebSessionManager sessionManager = new DefaultWebSessionManager(); // 自定义 sessionId 从 cookie 中获取,保证前后端分离场景下也能取到 sessionManager.setSessionIdCookieEnabled(true); sessionManager.setSessionIdUrlRewritingEnabled(false); sessionManager.setSessionDAO(redisSessionDAO); // 全局会话超时时间,单位毫秒,这里设置 30 分钟 sessionManager.setGlobalSessionTimeout(1800000); // 每隔 10 分钟检查一次过期会话 sessionManager.setSessionValidationInterval(600000); return sessionManager; }setSessionIdUrlRewritingEnabled(false)这个配置在前后端分离的项目里特别重要。如果置为 true,Shiro 会在 URL 后面拼接 sessionId,这样既难看又带来安全隐患(URL 中的 sessionId 容易被日志系统或浏览器历史记录泄露)。前后端分离时,我们通常通过请求头Authorization或 Cookie 传递 sessionId,URL 重写毫无必要。
RedisCacheManager 的配置代码如下,它负责第二层缓存——授权信息的缓存:
@Bean public RedisCacheManager redisCacheManager(RedisTemplate<String, Object> redisTemplate) { RedisCacheManager cacheManager = new RedisCacheManager(); cacheManager.setRedisTemplate(redisTemplate); // Shiro 的 cache 命名前缀,建议带业务标识避免和别的系统冲突 cacheManager.setKeyPrefix("shiro:cache:"); // 全局缓存过期时间,单位秒,这里设置 10 分钟 cacheManager.setGlobalCacheExpire(600); return cacheManager; }到这里,配置层的骨干已经就绪。下一节我们写逻辑代码。
3. 认证流程落地,从 Realm 到登录接口
配置类只是骨架,真正干活的是 Realm 和数据访问层。这一节我们把登录认证的完整链路走一遍:自定义 Realm、密码校验方式、登录接口、以及把 Session 托管到 Redis 的细节。
3.1 自定义 Realm,Shiro 和数据库之间的"翻译官"
Realm 在 Shiro 中充当数据源和 Shiro 之间的适配器。我们不直接告诉 Shiro 用户表长什么样,而是让它通过 Realm 来获取用户信息。自定义 Realm 需要继承AuthorizingRealm,并重写两个方法:doGetAuthorizationInfo(授权)和doGetAuthenticationInfo(认证)。
@Component public class UserRealm extends AuthorizingRealm { @Autowired private UserService userService; @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username = (String) principals.getPrimaryPrincipal(); User user = userService.findByUsername(username); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); // 这里建议一次性查出来,避免每次请求都触发 N 次查询 Set<String> roles = userService.findRolesByUserId(user.getId()); Set<String> perms = userService.findPermissionsByUserId(user.getId()); info.setRoles(roles); info.setStringPermissions(perms); return info; } @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { // token 中携带的就是登录时提交的用户名和密码 String username = (String) token.getPrincipal(); User user = userService.findByUsername(username); if (user == null) { throw new UnknownAccountException("账号不存在"); } if (user.getStatus() == 0) { throw new LockedAccountException("账号已被锁定"); } // 第一个参数是 principal,留给授权用;第二个参数是数据库中的密码密文;第三个参数是 realm 名称 return new SimpleAuthenticationInfo(username, user.getPassword(), getName()); } }这里有个很多人容易忽略的细节:doGetAuthenticationInfo返回的SimpleAuthenticationInfo中的principal到底是什么。我习惯用用户名作为 principal,因为授权时只需要根据用户名去查角色权限。如果你把整个 User 对象放进去,作为 principal 也可以,但授权时取出来的是内存引用,在某些序列化/反序列化场景下会多一层开销,我建议统一用用户唯一标识(用户名或用户ID)作为 principal。
另一个细节是密码校验。Shiro 拿到token.getCredentials()(用户提交的明文密码)和SimpleAuthenticationInfo中的密文密码后,会调用CredentialsMatcher做比对。默认的比对逻辑是简单字符串 equals,这显然不安全。我们下面会配置一个加盐哈希的匹配器。
3.2 密码加密存储,加盐策略不能省
用户表的密码字段如果是明文,那项目发布出去基本等于裸奔。业内通用的做法是加盐后做多次哈希计算。Shiro 自带的HashedCredentialsMatcher支持 MD5、SHA-256 等算法,配合随机盐值使用。
我项目里的做法是:注册时生成一个随机盐值(UUID 前 8 位),密码存储格式为盐值:加密后的密码,这样同一个密码不同用户存出来的密文不同。登录时 Shiro 先取出用户密文,拆出盐值,再对提交的明文用同样的方式做哈希,然后比对。
@Bean public HashedCredentialsMatcher hashedCredentialsMatcher() { HashedCredentialsMatcher matcher = new HashedCredentialsMatcher(); matcher.setHashAlgorithmName("SHA-256"); // 哈希次数,常见是 2 或 3 次,次数越多安全性越好但也越耗时 matcher.setHashIterations(2); return matcher; }然后在UserRealm的构造函数里setCredentialsMatcher(matcher)。
注册时的密码处理代码大致如下:
// 注册用户时 String salt = UUID.randomUUID().toString().replaceAll("-", "").substring(0, 8); String hashedPwd = new SimpleHash("SHA-256", rawPassword, salt, 2).toHex(); // 保存到数据库的 password 字段 user.setPassword(salt + ":" + hashedPwd);哈希迭代次数不要选得太高。2 次对登录性能影响很小,但如果选 1024 次,高并发登录时会明显感觉到延迟。安全性和性能要平衡,常规互联网项目 2 到 10 次之间比较合理。
3.3 登录接口,到底要不要自己写 Session
登录接口本身不复杂,但有几个细节值得展开。
@RestController public class LoginController { @Autowired private UserService userService; @PostMapping("/login") public Result<?> login(@RequestBody LoginRequest request) { // 构造 Shiro 的 UsernamePasswordToken UsernamePasswordToken token = new UsernamePasswordToken( request.getUsername(), request.getPassword()); Subject subject = SecurityUtils.getSubject(); try { subject.login(token); // 认证通过后,此时 Shiro 已经生成 Session String sessionId = (String) subject.getSession().getId(); return Result.ok(sessionId); } catch (UnknownAccountException e) { return Result.error("账号不存在"); } catch (IncorrectCredentialsException e) { return Result.error("密码错误"); } catch (LockedAccountException e) { return Result.error("账号被锁定"); } catch (AuthenticationException e) { return Result.error("认证失败"); } } }登录成功后 Shiro 会把 Subject 的认证状态写入 Session。如果你在 sessionManager 里配置了 RedisSessionDAO,这个 Session 会立刻同步到 Redis,而不是留在本地内存中。前端得到 sessionId 后,把它存到 Cookie 或者请求头中,后续每次请求带上它,Shiro 就会根据这个 sessionId 去 Redis 里查登录状态。
注意这里返回 sessionId 的方式。如果是纯后端渲染的 MVC 项目,sessionId 由 Cookie 自动携带,不需要暴露给前端;但如果是前后端分离,前端可能需要手动把 sessionId 放到请求头里。此时 SessionManager 的 Cookie 机制依然存在,所以即使前端不处理,浏览器 Cookie 通常也能自动带上,这在某些跨域场景下会遇到麻烦。
3.4 RedisSessionDAO 的细节配置
上面提到RedisSessionDAO,这个类的配置直接决定 Session 怎么进出 Redis。我的配置如下:
@Configuration public class RedisSessionConfig { @Bean public RedisSessionDAO redisSessionDAO(RedisTemplate<String, Object> redisTemplate) { RedisSessionDAO sessionDAO = new RedisSessionDAO(); sessionDAO.setRedisTemplate(redisTemplate); // Session 在 Redis 中的 key 前缀 sessionDAO.setKeyPrefix("shiro:session:"); // Session 超时时间,单位秒,这里要和 sessionManager 的 globalSessionTimeout 保持一致 sessionDAO.setSessionTimeout(1800); return sessionDAO; } }这里有个非常隐蔽的问题:setSessionTimeout的单位是秒,而sessionManager.setGlobalSessionTimeout的单位是毫秒。如果两边不一致,可能会出现"Redis 里 Session 已经过期了,但 Shiro 认为还没过期"或者反过来。我在实践中踩过这个坑,当时调试了一个晚上才想起来去对这两个值。建议两者配合使用时,把数值换算成一致的时间长度,写注释标记清楚。
4. 授权机制从零到一,RBAC 模型的落地
认证搞定之后,授权是下一个大活。授权要做的事情很简单:判断当前 Subject 能不能访问某个接口、执行某个操作。RBAC(角色-权限模型)是目前最主流的设计方式:用户归属于角色,角色绑定权限。Shiro 对这个模型的支持很自然。
4.1 授权信息的加载时机,权限缓存的关键
授权数据是通过doGetAuthorizationInfo返回的。但是这个方法什么时候被调用?很多初学者以为是每次请求都会触发,事实并非如此。
Shiro 在检测到当前 Subject 尚未加载授权信息时,会先检查缓存。如果缓存中没有,才会调用doGetAuthorizationInfo去数据库查询;查询完后,结果会被存入 CacheManager。也就是说,只要第一次某个用户的权限被加载过,后续的权限判断都走缓存,不会再触发数据库查询。
这正是之前配置 RedisCacheManager 的意义所在。如果项目没有配置 CacheManager,Shiro 默认不缓存授权信息,每次接口权限校验都会重新调用 Realm 的 doGetAuthorizationInfo 方法,你可能会在日志里看到大量的角色权限查询 SQL。虽然功能上没错,但性能损耗不可忽视。
4.2 注解权限和 URL 权限,怎么选
Shiro 提供两种授权方式:编程式注解和 URL 过滤规则。注解方式适合方法级别的精细控制,URL 过滤适合粗粒度的路径拦截。两者可以混用,但建议团队里统一约定风格。
注解方式需要在配置类里开启 Shiro 注解支持:
@Bean public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() { DefaultAdvisorAutoProxyCreator creator = new DefaultAdvisorAutoProxyCreator(); // 必须设置为 true,否则 Spring AOP 代理时可能无法生效 creator.setProxyTargetClass(true); return creator; } @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; }然后在 Controller 或 Service 方法上使用注解:
@RequiresPermissions("user:delete") @PostMapping("/admin/delete") public Result<?> deleteUser(@RequestParam Long userId) { userService.deleteById(userId); return Result.ok(); } @RequiresRoles("admin") @GetMapping("/admin/dashboard") public Result<?> dashboard() { return Result.ok(); }URL 过滤规则的写法在前面的 ShiroFilterFactoryBean 里已经展示过了:perms[user:delete]表示需要指定权限,roles[admin]表示需要角色。URL 过滤方式是 Shiro Filter 层拦截的,注解方式是 Spring AOP 切面拦截的,前者控制"路径能不能进",后者控制"方法能不能调"。
我个人建议是:粗粒度(如管理后台的所有/admin/**路径需要登录)用 URL 过滤;细粒度(如删除操作需要user:delete权限)用注解。这样代码里的权限逻辑跟业务方法放在一起,可读性更好。
4.3 权限字符串的命名规范
权限字符串的命名虽然没人强制,但一个好的命名能让项目维护性大幅提升。我常用的格式是资源:操作,比如:
user:list查看用户列表user:create新增用户user:update修改用户user:delete删除用户order:export导出订单
这样结构层级清晰,后面写 SQL 或做权限点时,也很容易确定某个权限资源属于哪个菜单。
5. 实战过程中的三个"深坑",每一个都耗时半天
写到这里,配置和代码都通了,系统也能跑起来了。但生产环境和本地跑通是两回事,以下几个问题是我在实际项目中真实遇到的,每个都花了不少时间排查。
5.1 Redis 序列化引发的反序列化异常
Shiro 把 Session 对象写入 Redis,必然经过序列化。默认情况下,如果 RedisTemplate 用的序列化器是 JDK 自带的JdkSerializationRedisSerializer,你会发现 Redis 里的 key 是一长串不可读的二进制,value 也是一堆绵延的序列化内容。更麻烦的是,如果 Session 对象里的某个属性没有实现 Serializable 接口,写入 Redis 时直接抛异常。
我建议统一使用GenericJackson2JsonRedisSerializer或者 Fastjson 的序列化器。但注意:GenericJackson2JsonRedisSerializer在反序列化时,可能会因为org.apache.shiro.session.Session这种接口类型信息缺失而报错。更稳妥的方案是自定义一个 RedisTemplate,指定 key 用 StringRedisSerializer,value 用 Fastjson 序列化器。
@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); FastJson2JsonRedisSerializer<Object> serializer = new FastJson2JsonRedisSerializer<>(Object.class); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; }Fastjson 序列化器在序列化 Session 时会把类型信息写进 JSON,反序列化时能正确还原对象,这是我试下来最稳定的组合。当然,如果你不想引入 Fastjson,用 Jackson 的序列化器加类型信息也可以,原理类似。
5.2 会话过期了但权限缓存还在
这个场景很经典:用户登录后访问了一个需要权限的接口,权限被缓存到了 Redis。此时管理员在后台修改了这个用户的角色,把权限收回了。按道理用户下一次请求应该被拒绝,但因为授权缓存没有失效,用户依然可以访问。
所以权限变更时,必须同步清理 Shiro 的授权缓存。可以在角色权限更新后调用:
// 清除某个用户的授权缓存 RedisCacheManager cacheManager = (RedisCacheManager) securityManager.getCacheManager(); cacheManager.getCache("authorizationCache").remove(username);更彻底的办法是在 Realm 的 doGetAuthorizationInfo 中,给权限查询加一个版本号或时间戳判断。但绝大多数项目里,手动清理缓存就足够了。我一般在用户管理服务的"修改角色"和"修改权限"方法里,统一清理对应用户的授权缓存,避免把这种逻辑散落在各个 Controller 中。
5.3 多实例部署时的 Session 互踢问题
这是分布式会话里比较隐蔽的一个问题。当项目部署了两台服务器,用户登录后 Session 存在 Redis 中。如果同一个账号在两台服务器上各登录一次,Redis 里就会有两个 Session,互不干扰。但业务上通常希望"同账号只能一个地方登录,新登录挤掉旧登录"。
Shiro 默认没有提供这个能力,需要自己实现。思路是:在 Realm 的认证方法里,登录成功后把新的 sessionId 写入 Redis,key 为该用户名;同时在 SessionManager 的创建会话时检查该用户名是否已经有活跃 sessionId,如果有,则删除旧的 Session。
这个逻辑属于业务层定制,Shiro 没有现成 API,需要自己控制。我实测下来的方案是:在登录接口校验完成后,手动把旧 sessionId 从 RedisSessionDAO 中删掉,再创建新 Session。这个方法虽然繁琐,但它保证了唯一登录的需求,而且对现有代码侵入很小。
6. 性能调优的两条经验
系统跑顺之后,接下来要考虑性能和并发问题。Shiro 加 Redis 的组合,瓶颈通常不在 Shiro 本身,而在 Redis 的读写频率和网络开销。
经验一是把授权缓存过期时间设置得合理一点。我之前的项目把授权缓存过期时间设为 10 分钟,结果用户改完角色要等 10 分钟才能生效,体验不好。后来改成 2 分钟,配合主动刷新机制,效果好了很多。权限数据是低频变化的数据,不需要频繁过期,但也不要设太长,否则权限收回滞后严重。
经验二是 Session 过期时间不要太短。有些同学把 Session 超时时间设为 30 分钟,用户操作到一半就掉线,重新登录体验很差。管理后台通常建议 2 小时,移动端可以更长一些,配合刷新机制。Shiro 的 Session 验证调度器会在后台定期清理过期 Session,如果设置的超时时间太短,用户高频操作时会频繁触发重新认证。
还有一个我特别想强调的优化点:关闭 Shiro 的 Session 在 Cookie 中的默认写入策略,如果使用前后端分离架构,不要完全依赖 Cookie,而是让前端在每次请求的 header 中携带 sessionId。这里需要继承 DefaultWebSessionManager 自定义一个获取 sessionId 的方式,核心思路就是从请求头中读取。
public class HeaderWebSessionManager extends DefaultWebSessionManager { private static final String AUTH_HEADER = "Authorization"; @Override protected String getSessionId(ServletRequest request, ServletResponse response) { HttpServletRequest httpRequest = (HttpServletRequest) request; String sessionId = httpRequest.getHeader(AUTH_HEADER); if (StringUtils.hasText(sessionId)) { return sessionId; } return super.getSessionId(request, response); } }这样就把 Shiro 的 Session 获取逻辑从"只认 Cookie"扩展成了"先从 header 取,取不到再从 Cookie 取",既兼容了前后端分离,也保留了传统场景的可用性。
最后分享我在项目里沉淀的三个习惯
这套 Shiro 加 Redis 的组合我在多个项目里反复用过,最终沉淀下来几个习惯,可能对你有参考价值。
第一个习惯是最小权限原则落实在代码层面。每一个接口尽量用权限注解去控制,不要只依赖角色判断。角色是粗粒度的,权限是细粒度的。比如"管理员能导出订单"这种需求,如果只判断角色,后面来个"运营主管也要能导出"就得到处改代码;如果一开始就定义order:export权限,再把这个权限分配给相关角色,改动成本会低很多。
第二个习惯是授权数据查询要一次性查出角色和权限。Shiro 在授权时会调用doGetAuthorizationInfo,如果这个方法内部先查角色再查权限,每次都是两次数据库交互。把这些查询合并成一条 SQL 或者一次服务调用,授权缓存未命中时的开销能减少一半。权限缓存生效后,这个开销只在第一次请求时产生。
第三个习惯是遇到登录态相关的问题,第一时间去 Redis 里看 Session 是否真实存在。到了这一步,80% 的问题都能定位:要么是 Redis key 过期了,要么是 sessionId 对不上,要么是序列化失败了。用redis-cli keys 'shiro:session:*'看一眼,再决定怎么排查。
SpringBoot、Shiro、Redis 这个组合并不复杂,真正复杂的是你在工程里对每一个细节的把控。希望这篇分享能帮你少踩几个坑。