☰
SpringBoot整合Shiro+JWT+Dubbo+Redis构建分布式统一权限系统实战
2026/10/13 13:54:09 网站建设 项目流程

这套基于 SpringBoot + Shiro + JWT + Dubbo + Redis 搭建的分布式统一权限系统,从立项到落地、从单机演示到多节点部署,到这儿总算可以写一个完结篇了。刚开始接这个需求时,我一度觉得不过是把几个成熟框架叠在一起,Shiro 做权限、JWT 做凭证、Redis 做缓存、Dubbo 做远程调用,各管一段就行。真正动手之后才发现,组件之间要是不在设计上提前对齐,启动后不是过滤器冲突,就是权限缓存读不出来,连锁反应能让人排查两天。

这篇不打算复述官方文档,只讲我在实际项目里的整合思路和绕过的坑。内容包含完整认证链路、RBAC 权限落地、Dubbo 调用链上的身份传递、多节点部署时的配置策略,大部分方案都是生产环境验证过的,适合正在做微服务权限改造、或者准备把单机权限系统升级成分布式方案的开发同学参考。

1. 完结篇先讲清楚:这套系统为什么长这样

1.1 从各系统独立登录,到“一套登录到处通行”的目标

我接手的项目背景其实很常见:公司内部有订单服务、用户服务、运营后台、数据报表好几个系统,每个系统各自维护一套用户表,登录状态互不相认。用户在一个系统登录后,跳到另一个系统又得重新输入账号密码;运营人员需要重复开通权限,管理员想统一管控也找不到一个入口。表面上是体验问题,底层是权限体系碎片化。

既然是“统一权限系统”,核心目标就必须明确:所有业务系统共用同一个用户身份库、同一套角色权限定义、同一份在线状态记录。业务系统只需要知道自己收到了谁的操作指令、这个人有没有权限执行该操作,不该各存一份用户密码和会话状态。这个目标定了之后,技术选型基本就跟着确定下来了——需要一个能跨系统传递的身份凭证,需要一份共享缓存来承担认证结果和授权结果,需要一组服务接口让各业务系统按统一方式校验身份和权限。

1.2 SpringBoot、Shiro、JWT、Dubbo、Redis 各自解决什么问题

很多第一次接触这套组合的人容易混乱,因为 Shiro 本身自带完整的认证授权体系,JWT 也带着认证能力,两者看起来有重叠。实际上它们在这个架构里的分工完全可以不冲突:SpringBoot 负责把所有模块串起来,提供 Web 层接口和服务端基础配置;Shiro 承担的是框架级的认证与授权管理,包括 Realm、过滤器链、权限注解、URL 级别的路径控制;JWT 负责把登录成功后的用户身份令牌化,用无状态的方式在多个系统之间传递;Redis 负责存放令牌的凭证指纹、用户权限快照、在线状态和登录历史;Dubbo 则让各个业务系统之间的远程调用在同一个分布式体系下进行,同时让我们在 RPC 调用链上统一携带身份信息。

我常用一个生活化的类比:JWT 是一张带签名的通行证,Shiro 是门口执行放行规则的保安,Redis 是保安手里的花名册和权限台账,Dubbo 是不同楼层之间递单据的传输带。通行证可以不联网验真,但花名册让你能随时作废某张通行证、能快速看这个人还能去哪儿。这个搭配既发挥了 JWT 的无状态优点,又避免了 JWT 无法主动注销的问题,算是我能想到的比较务实的一种组合。

2. 认证链路逐层拆解:JWT 令牌怎么从登录接口开始流转

2.1 登录入口:业务层完成密码校验,Shiro 只管令牌合法

这里要明确一个设计取舍:用户提交账号密码之后,密码校验放在业务 Service 层还是 Shiro Realm 里面都可以,但放在业务层更直观。我在项目里走的是“业务层校验密码 + Shiro 验证令牌”的路径。用户请求/api/auth/login时,SpringBoot 的 Controller 接收账号密码,调用 UserService 查用户表,比对密码哈希,密码正确后生成一个 JWT 令牌返回给前端;密码错误直接抛出业务异常,根本不会进入 Shiro 的过滤器链。

Shiro 真正参与的是对后续每次请求的令牌校验。前端在请求头里带上Authorization: Bearer xxx,Shiro 网关过滤器会摘取这个请求头,解析 JWT,拿着解析结果里的用户标识去信任来源查询。Shiro 的 Realm 在这里就不需要做“密码比对”,只需要做“凭证确认”和“权限装载”。

// 简化后的 JWT Realm: @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) { String jwtToken = (String) token.getPrincipal(); Long userId = jwtUtil.getUserIdFromToken(jwtToken); if (userId == null) { throw new AuthenticationException("无效令牌"); } // 查一次缓存,确认该用户对应的凭证指纹还在 String cacheToken = redisService.get("login:token:" + userId); if (cacheToken == null || !cacheToken.equals(jwtToken)) { throw new AuthenticationException("登录状态已失效"); } return new SimpleAuthenticationInfo(jwtToken, jwtToken, "jwtRealm"); }

这里有个容易被忽视的点:如果完全无状态,JWT 一旦签发就无法在服务器端撤回,用户修改密码、账号被禁用、管理员强制下线,在旧令牌没过期前仍然有效。我最终给每个用户存了一个“当前有效令牌”的 Redis key,登录时写入,注销时删除,修改密码时重置。JWT 本身只用来防篡改和验身份,Redis 里的凭证指纹才决定这个令牌现在还能不能用。这样既保住了无状态传输的好处,又拿到了服务端控制力。

2.2 JWT 签发、携带与解析,Redis 里到底存了哪些信息

JWT 的结构大家应该都清楚:Header 放签名算法和令牌类型,Payload 放用户标识、过期时间、签发时间,Signature 用密钥对前两部分签名。我建议 Payload 里的信息尽量精简,一般只放userId、loginType、jti(唯一标识)、iat、exp。别把用户昵称、手机号、角色列表全塞进去,令牌会变长,而且这些信息本身就是可以变化的,塞进 Payload 反而容易造成旧权限残留。

Redis 侧我设计了几个不同前缀的 key,各自生命周期不同。

Redis Key 示例存的内容过期时间
login:token:{userId}当前有效 JWT 串跟随登录过期时间
login:info:{userId}用户基础信息 JSON与登录态一致
perm:user:{userId}当前用户权限码集合5 到 10 分钟短缓存
perm:role:{roleId}角色对应权限码集合10 到 30 分钟
login:kick:{userId}退出标记,避免并发写覆盖1 小时

令牌过期时间我用了两层:JWT 自身exp设置为 12 小时,Redis 里凭证指纹的 TTL 也设置成 12 小时。为什么 Redis 还要再设一遍 TTL?因为如果只在 Redis 判断“存在即有效”,漏配 TTL 会导致 Redis 里堆一堆永远不清理的 key,时间长了内存上涨,而且某些情况下服务端已经把用户踢下线,缓存却还在,造成“人走了门还开着”的风险。两条期限共同约束,任何一条到期都视为登录失效。

2.3 注销登录、账号禁用与多端互踢:怎么处理 JWT 的“后遗症”

由于我做了“当前有效令牌”这个 key,注销实现就非常简单了:调用redisService.delete("login:token:" + userId),再顺手清理login:info和perm:user。前端拿到退出接口的响应后,本地删除保存的 JWT 即可。真正有意思的是多端登录操作:允许同一账号在 Web 后台、手机 App、管理端同时在线,还是强制单端在线?我做过的一个管理端权限项目选择的是“同类型端互踢、不同类型端共存”,也就是 Web 端登录会把旧 Web 端挤下线,但不会影响 App 端。

这种互踢逻辑最终落实到两个字段:loginType和clientId。登录时校验同一用户同一登录类型的旧客户端 ID,如果存在且不是当前发起登录的客户端 ID,就把旧凭证指纹删除。被踢掉的那一端下一次请求会发现自己拿着一个已经失效的 JWT,再引导用户重新登录。这个操作听起来简单,但有一个边界情况要处理干净:同一客户端 ID 的重复登录,比如前端刷新页面、路径跳转导致重复调用登录接口,不能把自己这个会话给踢了。我的处理方式是比对旧的客户端 ID 和当前客户端 ID,相同才放行,避免登录接口一刷新就被全员下线。

3. 权限模型和动态授权:Shiro 的 RBAC 在 RPC 场景下怎么不失控

3.1 用户、角色、权限、资源四张表和缓存的关系

统一权限系统最底层的支撑是权限数据模型,我在项目中直接用了标准 RBAC 模型,但准确说已经延伸到 RBAC1,角色之间没有继承,但权限粒度拆到了操作级别。数据库里的核心表是:用户表、角色表、用户角色关联表、角色权限关联表,以及菜单资源表。菜单资源表里每一条记录对应一个真实的菜单或按钮,同时对应一个权限标识,比如order:create、order:export、user:resetPwd。

Shiro 在做授权时最常用的判断就是“用户是否拥有某个权限码”。我让 Realm 的doGetAuthorizationInfo方法先去 Redis 读perm:user:{userId},缓存没有再从数据库把角色关联的权限码合并后写回 Redis。为什么过期时间设置得那么短,只有 5 到 10 分钟?因为权限数据本身就属于“变更频率低但必须及时生效”的数据。管理员给用户加了一个角色,如果缓存是 30 分钟,用户最长要等 30 分钟才能使用新权限,这个等待对运营后台这类系统来说太长了。设置 5 分钟,既避免频繁打数据库,又保证权限变更感知延迟在可接受范围内。

3.2 URL 路径级别的拦截定义,哪些接口必须走 Shiro

Shiro 的过滤器链配置里,anon是匿名可访问,jwt是只要登录就能访问,更细的按权限码控制要走注解或自定义过滤器。我在实际项目里是按这个规则分配的:登录接口、验证码接口、健康检查接口、Dubbo 对外暴露的少量探测接口全部anon;其余所有请求一率走jwt过滤器,只准带有效令牌的人进入。

进入后如果需要做按钮级或菜单级权限判断,就在方法上打@RequiresPermissions("order:export")之类的注解。这里有个团队协作上的经验:路径拦截规则放得太散会很难维护。我项目里把公共路径和业务路径的过滤规则集中在同一个ShiroConfig配置类中,并且用常量统一管理通配符。接口新增时,开发人员大概率不会记得更新这个配置类,所以我还做了一个断言式校验:每个需要验证权限的接口必须显式出现在过滤规则中,没有被匹配到的接口默认拒绝。把“默认拒绝”反过来写,会让线上接口遗漏权限检查的概率下降不少。

3.3 权限变更后的缓存失效:线上用户不能一直带着旧权限

这是分布式权限系统里最容易被人忽视的坑。数据库里角色权限改了,Redis 里perm:user:{userId}和perm:role:{roleId}还在旧值,已登录用户在当前缓存生命周期内会继续使用旧权限。短缓存能缓解问题,但不能彻底解决问题。我在项目里做了主动失效机制:管理员在权限管理界面保存角色权限后,后台服务立即删除与该角色关联的所有perm:role:{roleId},同时把当前在线且拥有这个角色用户的perm:user:{userId}也批量删除。这样下一次用户请求时,Realm 发现缓存 miss,就会重新加载最新权限。

主动失效也有代价,如果用户很多,批量删除 key 的数量会很大。我当时的备份方案是把角色和用户的关联关系放在 Redis Set 里,管理员操作后先用smembers把这个角色下的用户取出来,再逐个执行delete,Redis 单线程特性决定了这种批量删除短时间内会有一定阻塞风险,所以我把这批删除操作丢到了异步任务池,并加了解耦。实际上管理员配置权限的操作频率很低,牺牲一点点实时性完全可接受,我更看重的是主链路登录和请求鉴权的稳定。

4. Dubbo RPC 调用链上的身份传递:一次服务调用如何带上当前用户

4.1 RpcContext 传递用户 ID 和凭证,而不是传递密码和全部角色

在微服务架构里,用户最先请求的往往是网关或单一业务入口,后续业务逻辑会通过 Dubbo 调用下游服务。下游服务要感知“当前操作者是谁”,最简单的方式是把用户信息放进 Dubbo 的 RPC 上下文。我在服务提供方和服务消费方都依赖了同一套基础 SDK,消费方在发起调用前把从 JWT 里解析出来的userId、userName塞进RpcContext的 attachments;提供方在真正进入业务方法前,从RpcContext取出来,放入自定义的LoginUserHolder线程变量,方便后续方法直接读取。

// 消费方发起 Dubbo 调用前: RpcContext.getContext().setAttachment("loginUserId", String.valueOf(userId)); RpcContext.getContext().setAttachment("loginUserName", userName); rpcService.doSomeBusiness(request);

这里我特意强调“不传角色权限列表”。为什么?因为角色权限很可能在调用链的某个环节内已经发生变化,传过去的旧列表会造成下游误判;而且把角色列表塞进 RPC 参数会让网络传输包体膨胀。下游需要做权限判断时,直接以userId为 key 去 Redis 获取最新权限快照,或者调用统一权限服务做实时校验。宁可多一次 Redis 读取,也不要依赖一份可能过期几十秒的权限副本。

4.2 Provider 端鉴权要做,但不能每个方法都查一遍权限库

RPC 服务收到上游传来的用户身份之后,要不要再做一次权限校验?我的结论是要,但要看场景。对于内部管理系统的核心接口,比如删除订单、修改用户状态、下发敏感配置,服务提供方不能完全信任上游已经做过鉴权,因为这条链路也可能是其他业务系统触发的,甚至可能是内部定时任务触发的。我在服务提供方的入口做了一个公共拦截,从上下文取用户 ID,再按接口注解的权限码去 Redis 做一次校验。

但这个校验如果做得太频繁,Dubbo 接口的吞吐会受影响。我在项目里做了一级本地近似缓存:权限校验结果放在 Caffeine 里,过期时间是 10 秒。也就是说同一个用户调用同一个权限码,10 秒内的命中不会打到 Redis。这十个字的权限校验从原来的每次几十毫秒降到纳秒级,基本不影响服务性能。等到后续要做实时权限回收时,可以通过版本号强制刷新本地缓存,但在我的项目里这个 10 秒延迟完全可接受。

4.3 Dubbo 超时重试带来的身份传播和幂等问题

Dubbo 的重试机制在写业务代码时很容易成为隐形地雷。我的一个订单服务在调用库存服务扣减库存时,因为库存服务偶尔 GC 停顿导致响应超时,Dubbo 默认的重试策略会把同一个请求再发一次,结果就是同一张订单被扣了两次库存,这个惨痛教训让我对所有写接口全部显式关闭了重试,或者让下游接口实现幂等。

权限系统本身同样受影响。如果登录后需要调用 Dubbo 接口记录登录日志,日志服务超时了,Dubbo 重试会再次携带同一份用户 token 调用,这在多数场景下没有副作用,但如果是“踢人下线”操作,重复调用就可能出现把当前用户自己踢掉的怪异现象。我对这类接口同样设置retries="0",把真正的状态变更封装成唯一单据号,下游按单据号去重,而不是依赖调用方的重试来补偿。

5. 踩坑实录:分布式环境里的意外远比教科书多

5.1 Shiro 过滤器和 Spring Boot 过滤器注册顺序:最隐蔽的坑

这是整个项目里让我调试最久的问题之一。启动后登录接口可以匿名访问,但带有 JWT 的请求全部跑到 401,一开始我以为是 Realm 问题,后来发现是过滤器链执行顺序出了问题。Spring Boot 使用内嵌容器时也会注册自身的过滤器,而 Shiro 的ShiroFilterFactoryBean返回的过滤器实例必须通过FilterRegistrationBean设置一个最高的执行顺序,否则它可能被容器当成普通过滤器放在靠后的位置,前面已经有人把请求消费掉了,JWT 过滤器根本没机会运行。

我的处理方式是显式声明FilterRegistrationBean,把 Shiro 过滤器对象包进去,setOrder 设置得非常靠前,同时setEnabled(true)。这个配置看起来一句话,但不知道的人很容易忽略它。真实场景下,任何自定义 Filter 和 Spring Boot 内嵌容器结合时都要留意执行顺序,尤其当你的过滤器依赖HttpServletRequest的 body 流或者请求头时,顺序错了,后面的框架连请求头都看不到。

5.2 异常响应统一为 JSON:401、403、超时不能返回一堆 HTML

前后端分离之后,所有异常响应都应该是一段可解析的 JSON,但 Shiro 默认的未登录跳转逻辑很可能返回 302 重定向或者 HTML 页面。前端收到 302 后还以为自己登录成功了,结果跳到一个丑陋的错误页,这个体验在内部系统里勉强能忍,在外网系统里就是事故。

我在自定义 JWT 过滤器里重写了onAccessDenied,直接通过response.setContentType("application/json;charset=UTF-8")写回一个标准错误结构。类似地,全局@ControllerAdvice要能捕获认证异常、鉴权异常、参数异常和业务异常,状态码要分层:未登录返回 401、无权限返回 403、参数错误返回 400、业务处理失败返回 500。另外 Dubbo 调用超时的异常必须单独归类,返回给前端的提示不能是“系统异常”,而应该是“服务处理超时,请稍后重试”。统一响应结构看着是小事,却是前后端联调时最消磨人耐心的环节。

5.3 Redis 序列化方式不一致,导致缓存里的权限字段读不出来

这个问题极具代表性。项目里有人用 JDK 默认序列化,有人用 JSON 序列化,还有人直接用 String 序列化保存权限码集合,结果从 Redis 读出来的对象类型对不上,@RequiresPermissions注解无法识别已加载的权限集合。我最终把权限快照统一存成 JSON 字符串,perm:user:{userId}的值直接就是["order:create","order:export"]这样的数组字符串,读取时用 JSON 序列化工具转成List<String>。

这类问题最磨人,因为代码编译没问题,运行起来也不报错,但权限就是“看不到”。我建议所有权限类缓存都设置成为 String 类型,不要用原生对象序列化,这样不仅可读性好,还能方便排查问题,比如redis-cli get perm:user:123一眼就能看到这个用户当前有哪些权限码。退一步来说,即使有中间件迁移或者缓存清理脚本,字符串也比二进制对象稳定得多。

5.4 JWT 密钥管理和时间戳:不能把生产密钥写在代码里

JWT 的签名密钥一旦泄露,任何人就能自己签发合法令牌,这个风险非常致命。我把生产环境的 JWT 密钥放在配置中心,通过环境变量注入,而不是写死在application.properties,并且要求至少 256 位长度。同时在私钥轮换时,设计了多密钥平滑切换方案:JWT 解析时先用当前密钥,解析失败再用上一代密钥重试,这样旧令牌在轮换窗口期内不会大量失效。

时钟问题也提一下。多节点部署时,如果服务器之间系统时间相差过大,JWT 的iat和exp判断可能出现偏差。Dubbo 服务节点和网关节点都必须启用 NTP 时间同步,否则会出现 5 分钟前登录的令牌在 6 分钟后就报过期这种灵异现象。我之前在测试环境遇到过,查了半天发现测试机时钟被重置成了旧时间,哭笑不得。

6. 多节点部署与性能调优:把这套系统推到真实流量下的几个配置

6.1 Redis 连接池、Dubbo 线程池和过滤器中的 Redis 依赖

权限系统本身对 Redis 的依赖度很高,登录校验、权限缓存、RPC 上下文样样都要用。并发上来后,Redis 连接池的连接数会成为一个隐形瓶颈。我建议 Jedis 或 Lettuce 连接池的maxTotal按业务预估 QPS 和单个 Redis 操作耗时来估算。比如单次登录校验需要读一次login:token、一次login:info、一次perm:user,三次操作,如果每次 2 毫秒,一个请求占用连接约 6 毫秒,QPS 1000 就需要约 6 个连接,但为了应对峰值和长尾延迟,实际至少要预到 30 到 50 个连接,否则高峰期会在获取连接时阻塞。

我还会在有 Redis 依赖的过滤器链上做一个降级设计:如果 Redis 短时间不可用,JWT 过滤器可以先执行本地密钥验签和过期时间校验,允许请求带着未过期的令牌直接通过,但把每次业务操作中的权限校验降级为只放行基础权限。这样做的代价是牺牲了一部分权限实时性,但比整个系统直接不可用强得多。分布式系统里永远要问自己:外部依赖挂了,核心链路能不能降级。

6.2 多节点下的登录态一致性:算单点登录还是多点登录

这套系统部署了多个节点后,登录态是否保持一致,关键在于持久化。JWT 本身是自包含的,任何节点拿到合法令牌都能验签通过,但凭证指纹放在 Redis,并且这个 Redis 必须是多个应用节点共享的一套,不能每个节点独享一份。项目里我用的是 Redis Sentinel + 读写分离,所有权限节点连同一份 Redis 数据层,登录后无论请求打到哪台机器,都能读到同一份凭证状态。

这里有个容易踩的坑:如果把 Redis 也分成多个独立副本,再想实现“单点登录”就非常难,因为各个副本之间的数据一致性会成为新的问题。统一权限系统尽量让 Redis 层保持对外单一逻辑视图,应用节点才真正能做到无状态。无状态应用节点是分布式系统最容易水平扩展的理想形态。

6.3 配置分组和环境隔离:测试、预发、生产必须用各自的 Redis 前缀

最后一条配置经验特别基础但特别重要:不同环境之间不仅 Redis 地址要隔离,Redis key 前缀也应该隔离。测试环境的数据和生产环境的数据一旦混在一个 Redis 集群里,key 冲突是迟早的事。我习惯在配置中心维护redis.keyPrefix这个参数,测试环境是test:,预发是stage:,线上是prod:。这样即使有人手滑把预发系统切到了生产 Redis,也不会立刻出现互相覆盖登录凭证的惨状。

Dubbo 服务分组也一样。同一套 Provider 代码在不同环境注册到不同的 Dubbo Registry,消费方按环境解析不同的注册中心地址。我在本地联调时会设置dubbo.register=false,只走直连模式,避免本地启动的服务把测试环境的注册中心搞乱。这些都是细节,但这样的细节决定了一个系统在跨环境复制时的稳定程度。

7. 收尾前再说几句实在心得

如果只让我总结一句话,那就是“授权是设计出来的,不是写代码写出来的”。这套组合里真正花时间的不是把五个框架集成在一起,而是提前想清楚哪些数据放 Redis、哪些信息走 RPC 链路、哪些权限判断可以靠缓存降级。我在实际项目中,最后一天加的往往不是新功能,而是一堆判断条件:“这里 Redis 挂了怎么回退”“那个修改权限的功能怎么通知在线用户失效”“同一客户端重复登录会不会互踢”,全是边界条件。

后续如果再让我设计一次,我会考虑把权限模型抽成更独立的权限中心服务,用统一的 SDK 接入所有业务系统,而不是把 Shiro 依赖散落在各个服务里。认证和授权逻辑集中得越彻底,业务系统的接入成本越低。这大概也是所有统一权限系统演进的终局方向。对我个人来说,比起纯粹的业务 CRUD,把这种跨系统的通用能力梳理清楚,收获要大得多。如果你也在搭类似的权限体系,建议从小范围试水开始,先把登录认证跑直,再加上权限校验,最后才考虑 RPC 链路和多节点部署,一步步来,会比一次性堆大而全的方案靠谱得多。

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

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

立即咨询