项目标题是“SpringBoot-shiro-jwt-dubbo-redis分布式统一权限系统(完结)”,看到“完结”两个字,我第一反应是:这哥们终于把整套权限体系跑通了。做分布式权限的人都知道,这个组合踩坑的地方不在某一个技术点,而在它们之间的衔接缝隙里。我也是从单体架构一路改过来的,深深理解为什么最后会沉淀出这样一套模板。
这篇文章我打算完整拆开这套组合的每一个关键节点,从架构设计到代码落点,从踩坑记录到排查思路,全部摊开来说。如果你正准备搭一套分布式统一权限系统,或者已经在改微服务的路上,这篇应该能帮你省掉好几周的调试时间。
1. 架构思路拆解:为什么需要这套组合拳
1.1 单体时代的权限系统,搬到微服务之后哪里不对
传统单体项目里,Shiro的用法很固定:登录成功后把用户信息塞进Session,后续每次请求通过Session拿到当前用户身份,再做权限校验。这套机制在单体架构里跑得很顺,因为Session天然存在同一个应用进程里,不存在“找不到人”的问题。
但到了分布式环境,服务被拆成多个独立进程,用户请求先打到网关,再路由到订单服务、用户服务、商品服务。这时候Session就尴尬了——用户明明在支付服务登录成功了,结果请求转到售后服务时,那个服务根本不知道Session是什么东西。最常见的老办法是配置Session共享,比如通过Spring Session把Session存进Redis。我在早期项目里就这么干过,但实际体验很别扭:
- 每次请求都要带着同一个SessionID,要么用Cookie,要么重写URL,扩展性很差。
- 服务间互相调用时,Session和Cookie的传递链路特别脆弱,稍不加小心就丢了身份。
- Session这种服务端状态存储,天然和“无状态微服务”的伸缩弹性格格不入。
所以到微服务阶段,权限认证的思考方式必须换一个角度:不想在服务端保存状态,那就把身份信息做成一份“可以自己证明自己”的令牌,让要校验的一方只验证令牌、不查会话。
1.2 技术选型背后的取舍逻辑
这套方案选了四个核心组件,每个承担的角色都不一样,我把它们拆开看:
| 组件 | 在系统中的角色 | 解决的核心问题 |
|---|---|---|
| SpringBoot | 基础设施,整合框架 | 快速组装项目,自动化配置,让其他组件能快速接入 |
| Shiro | 认证授权框架 | 提供过滤器链、Subject抽象、权限检查API |
| JWT | 无状态令牌 | 把用户身份、过期时间、签名做进一个自包含密文 |
| Dubbo | RPC通讯框架 | 多服务间远程调用,统一鉴权入口 |
| Redis | 缓存与共享存储 | 权限数据缓存、令牌黑名单、分布式锁、防止并发重复刷新 |
选型的时候,其实有不少人纠结过用Shiro还是Spring Security。我自己的感受是:如果项目里已经有一堆老接口是Shiro体系写好的,强行切Spring Security改造成本极高,而且Spring Security的门槛确实比Shiro高不少。Shiro的过滤器链机制非常直观,自定义一个过滤器加进去就能完成Token解析和身份组装,调试起来也清晰。
另外一个很关键的点,是JWT和Session的取舍。JWT最大的优势就在于它是无状态的,服务端不保存任何会话信息,验证起来只需要三样东西:密钥、签名算法、载荷数据。但无状态也带来一个“副作用”——token发出去之后,服务端没办法主动让它失效。举一个场景:用户修改了密码,正常情况下应该所有旧令牌立即失效,但JWT做不到,除非你在Redis里维护一份黑名单或版本号。这也是为什么这套方案里Redis必须存在,它不只是缓存,更是弥补JWT无状态缺陷的“后门”。
1.3 一条完整的请求链路长什么样
理论上,用户从前端发起一次请求,走的完整路径是这样的:
- 前端传入用户名密码,请求登录接口。
- 认证服务校验用户信息,生成JWT令牌,并把用户基本信息同步写入Redis缓存。
- 前端拿到令牌后,后续每次请求都在请求头里带
Authorization: Bearer <token>。 - 网关或统一入口的JWT过滤器先校验令牌签名、解析用户身份、检查Redis黑名单。
- 身份合法后,Shiro的Subject被填充,带有当前用户信息。
- 请求被路由到具体业务服务时,Dubbo调用通过隐式参数传递当前用户信息。
- 目标服务利用Shiro注解(如
@RequiresPermissions)做二次权限校验。
这一条链路就是这套系统的整体骨架,后面所有细节都是围绕这条链路去填肉。我建议初学者先把这条链路画在纸上,再开始看代码,否则很容易被各种配置绕晕。
2. 认证模块落地:Shiro与JWT的整合细节
2.1 Shiro过滤器链的核心工作机制
Shiro的核心抽象是SecurityManager,它管理着Subject、Authenticator、Authorizer三大部分。在SpringBoot整合Shiro时,最关键的配置文件是ShiroConfig,里面要定义一套过滤器链。这套过滤器链本质上是Java里的Filter责任链模式,和Servlet过滤器的执行方式一样,每个URL在进入业务代码之前会依次经过自定义过滤器。
我见过很多新手配置Shiro时报错,最典型的问题就是过滤器链顺序写错。比如:
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>(); filterChainDefinitionMap.put("/login", "anon"); filterChainDefinitionMap.put("/logout", "logout"); filterChainDefinitionMap.put("/**", "jwt");这里有个细节容易被忽略:Map的遍历顺序是不可靠的,必须用LinkedHashMap保证定义顺序。Shiro匹配URL是按声明顺序从上往下来匹配的,先声明愿每个人都可以访问的路由,最后再声明需要认证的路由。如果你把/**写在最前面,后面的/login就永远匹配不到了,所有接口都会要求登录。
另外,Shiro自身自带一些过滤器,比如anon(匿名可访问)、authc(需要登录)、logout(退出),针对JWT方案需要自己写一个过滤器,把JWT的解析、校验收进Shiro的过滤链路里。我自己是继承了BasicHttpAuthenticationFilter这个类,后来发现更清爽的做法是直接继承AuthenticatingFilter,因为它在判断“有没有带token”和“token是否有效”这两个环节已经给你留好了扩展点。
2.2 自定义JWT过滤器,到底要重写哪些方法
自定义认证过滤器是整套系统的“大门”,核心是重写两个方法:
@Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { // 判断请求头是否携带token,如果没带就直接返回false,进入onAccessDenied流程 String token = getTokenFromRequest(request); if (StringUtils.isBlank(token)) { return false; } return null != JwtUtils.parseToken(token); } @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { // 主要做两件事:取出token,构造Subject,交给Shiro去登录校验 String token = getTokenFromRequest(request); try { getSubject(request, response).login(new JwtToken(token)); return true; } catch (Exception e) { // 返回401状态码,前端拿到后做跳转 return false; } }这里面有一个极其关键的细节:isAccessAllowed里如果直接返回false,请求会进入onAccessDenied去走登录逻辑。而自定义的JwtToken是什么?它本质上是一个携带token字符串的认证令牌,对应Shiro里的AuthenticationToken接口。因为Shiro默认只认UsernamePasswordToken,我们要让它认JWT,就得自己实现一个AuthenticationToken,并在Realm里写相应的校验逻辑。
还有一个坑是跨域相关的:如果你加了CORS配置,要注意OPTIONS预检请求的处理。很多项目里JWT过滤器把OPTIONS请求直接拦截了,导致前端报跨域错误。解决办法很简单,在isAccessAllowed里加一个判断,如果请求方法是OPTIONS,直接返回true。
2.3 自定义Realm:把JWT解析出来的用户身份交给Shiro管理
Realm是Shiro和业务数据之间的桥,它负责告诉Shiro“这个token对应的是谁,他有什么权限”。JWT方案里,Realm的逻辑很轻,因为用户身份已经从JWT里解析出来了,不需要再查数据库:
@Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { JwtToken jwtToken = (JwtToken) token; String userId = JwtUtils.getUserId(jwtToken.getToken()); if (userId == null) { throw new AuthenticationException("token invalid"); } // 从Redis里取用户信息(登录时已经缓存好) UserInfo userInfo = RedisUtils.get("user:" + userId); if (userInfo == null) { throw new AuthenticationException("user not found"); } // 返回给Shiro,后续可以从Subject获取当前用户 return new SimpleAuthenticationInfo(userInfo, jwtToken.getToken(), "myRealm"); } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { // 从Redis获取该用户角色和权限集合,封装成SimpleAuthorizationInfo UserInfo userInfo = (UserInfo) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); info.addRoles(userInfo.getRoles()); info.addStringPermissions(userInfo.getPermissions()); return info; }这套逻辑在单服务模式下很顺,但到分布式环境就有个问题:用户信息存Redis的key要怎么设计,才能让所有服务都读得到?我的经验是统一约定好key前缀和序列化方式,比如存用户对象用JSON字符串而不是Java原生的序列化,这样其他语言写的服务也能读。如果不统一,Dubbo服务用默认的JdkSerializer写进去的对象,别的服务读出来就是一片乱码。
2.4 无状态会话下的rememberMe该怎么做
Shiro原生的rememberMe机制是存Cookie,下次请求自动带上Cookie完成登录。但在JWT方案里,这招不好使了,因为JWT本身已经包含了身份信息,你再额外开一个Session干嘛?我在项目里处理的方式是:把“记住我”这个语义转换成“签发一个长期有效的JWT令牌”,也就是把token的有效期从30分钟升级到7天。
但长期令牌的风险也在于“泄露出去了就完蛋”,所以我在长期令牌里额外塞了一个设备信息、浏览器指纹之类的参数,登录校验时比对一下,发现异常就强制重新登录。另外一个经验是:长期令牌不要存浏览器localStorage,存到HttpOnly的Cookie里更安全一些,虽然牺牲了一点XSS防御便利性,但CSRF该防还是要防。
3. 分布式会话与权限缓存:Redis的真实角色
3.1 为什么说“Redis存token”不是最优解
网上很多博客写的方案是把JWT本身存一份到Redis,但这在我看来是没想清楚JWT的设计目的。JWT的核心价值就是“无状态”,你把它存进去,等于又开始做有状态会话,Redis一旦崩了,所有令牌立刻失效,分布式下更明显——有的服务连得上Redis,有的连不上,就会出现“这个节点说登录失败、那个节点说登录成功”的怪现象。
那Redis到底存什么?我总结了三类东西:
- 用户信息缓存:登录成功后把用户基础信息、角色集合、权限集合写一份到Redis,key设为
user:userId,设置过期时间和token保持一致。 - token黑名单:登出、改密、被封禁时把当前token的jti(JWT ID)写进黑名单,写入的过期时间正好是token剩余的有效期,黑名单自动过期,不用手动清理。
- 分布式锁:token并发刷新、多终端踢人下线这种场景,用Redis的SETNX命令做分布式锁,保证同一用户在同一时刻只有一个实例在刷新token。
我踩过一个大坑:一开始我把权限集合直接序列化成Java对象存Redis,Redis的value用JdkSerializationRedisSerializer,后来发现服务A写入的权限,服务B读出来反序列化报错,原因是两个服务引用的实体类包路径一模一样?类加载器对不上也会出问题。最后我统一改用Jackson序列化,权限集合存成JSON数组,读出来再手动转成List<String>,这才彻底解决。如果你也在做分布式,强烈建议Redis的key和value都统一用字符串序列化,别图省事用默认的JDK序列化。
3.2 权限数据为什么要缓存,而不是每次查库
在一个分布式多服务的环境里,权限校验的调用频率比想象中高得多。每个请求进来,Shiro都要调一次doGetAuthorizationInfo拉取角色和权限。如果你这段逻辑是查数据库,那高并发下数据库压力瞬间拉满,纯粹是自找麻烦。
我的方案是:用户在登录成功后,一次性把权限集合从数据库查出来,按服务维度拆分存好。比如用户访问订单服务,就从Redis的userPermissions:userId:orderService这个key里面取权限,取不到再走一遍数据库查询并回填缓存,同时设置一个合理的过期时间。这样做的好处是:第一,业务服务不直接查库;第二,不同服务间权限数据独立,某个服务的权限变更不会影响其他服务的缓存。
权限修改后的缓存同步是另一个重要问题。我这边做法是,在权限管理后台修改了某个用户的角色或权限时,主动删除Redis里对应用户的权限缓存key,或者调用一个统一的“刷新用户权限”Dubbo接口,把所有服务节点都通知一遍。这里如果偷懒不清缓存,会出现用户已经解除权限但还能继续访问接口的尴尬情况。
3.3 Redis连接池与连接超时的坑
高并发下Redis问题会放大。我最开始用Lettuce客户端,老遇到连接争抢超时,日志里全是RedisCommandTimeoutException,后来排查原因发现是没有正确配置连接池参数,默认的池子太小,并发一上来就排队等着。得改一下配置:
spring: redis: lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 max-wait: 3000ms另外一个问题是Redis主从切换时的缓存穿透现象。如果主节点挂了,从节点顶上,那一瞬间大量未命中的请求会全打到数据库。我的缓解方案是:权限缓存不只是Redis一层,再加一层本地缓存(比如Caffeine),把常访问的用户权限放本地,过期时间极短(60秒)。这算是一个用空间换稳定性的取舍。不过要注意,本地缓存和Redis之间的一致性会比较弱,如果业务上对权限实时性要求苛刻,必须权衡好这两者。
4. Dubbo服务间的身份透传与统一鉴权
4.1 服务A调服务B时,怎么把用户身份带过去
这是整套分布式权限方案里最容易被忽略的一环。网关层做了JWT认证,但内部服务之间走的是Dubbo RPC,不会经过HTTP请求头。如果不做特殊处理,服务B根本不知道“这个请求的用户是谁”,更没法做权限校验。
Dubbo提供了一个隐式参数的机制,专门干这个事。利用RpcContext的attachment,可以在发起远端调用时给请求附加信息:
RpcContext.getContext().setAttachment("userId", userId); RpcContext.getContext().setAttachment("username", username); // 然后发起调用 userService.getUserDetail(userId);在服务提供方,你再通过RpcContext.getContext().getAttachment("userId")取回这些参数。这里我强烈建议用一个统一的Filter来处理,Dubbo支持自定义Filter,通过@Activate(group = {PROVIDER, CONSUMER})注解注册,这样就不用在每个业务方法里手动塞参数、取参数了。
我踩过一个特别经典的坑:Dubbo的RpcContext不是线程安全的,异步调用情况下attachment会丢。我在项目里一开始用CompletableFuture异步调用,结果发现服务B接收的attachment经常为空,找了好久才定位到是RpcContext的线程上下文被切换了。解决方式是:如果必须用异步,则把需要在attachment传递的用户信息作为业务方法的显式参数传进去,宁可方法签名多一个参数,也不要依赖隐式传递的上下文。
4.2 服务提供方要不要重复做权限校验
做过分布式的人应该都有体会:网关校验过的权限,到服务内部就不校验了,这种行为极其危险。因为Dubbo服务可能不止通过网关调用,还可能是别的服务调用、调度任务调用、MQ消费者调用,这些入口都没经过JWT过滤器。换句话说,如果一个服务错误地把Dubbo端口暴露出去,而自身不做任何权限校验,那这个服务就裸奔了。
我的策略是“网关做粗粒度校验、服务做细粒度校验”。网关只管“这个用户是否合法、token是否有效”,不做具体权限判断。到了具体业务服务,再用Shiro的@RequiresPermissions("order:create")注解做细粒度授权,配合前面提到的Dubbo RpcContext里透传的身份信息。需要注意的是,服务B里要有一个realm能识别从RpcContext里拿到的用户信息,并完成Subject的构建。这些代码要抽成一个公共的权限starter,让每个服务都引用,避免每个服务各写一套。
4.3 统一权限starter的模块划分
做完整套系统后,我最大的建议是:不要把这些代码零散地放在各个服务里,一开始就要抽成公共模块。我自己最终分的模块是这样的:
| 模块名 | 内容 |
|---|---|
| common-core | 统一返回结构、异常定义、常量、工具类 |
| security-core | Shiro配置、JWT工具、filter、realm抽象 |
| security-starter | 自动配置,让每个服务引入后即生效 |
| platform-common | Dubbo隐式参数过滤器、RpcContext工具 |
这样设计的好处是:一个新服务上线时,只要引入security-core和security-starter,加上一个简单的配置类指定自己的Redis和密钥,就能拥有完整的JWT校验、Shiro权限注解、Dubbo身份透传能力。不需要再写那几千行各种重复配置。我在实际项目中把这套starter推到私服后,新服务从零到接入完整权限体系不到一个小时,这才是分布式系统该有的开发效率。
5. 令牌生命周期管理:续签、黑名单与并发
5.1 JWT过期了怎么办,统一续签方案
JWT的过期时间设多长,一直是争论点。设太短(比如30分钟),用户用着用着突然弹回登录页,体验很差;设太长(比如7天),令牌泄漏后的风险窗口很大。我最后采用的方案是“短期令牌 + 无感续签”。
具体逻辑:主令牌有效期设30分钟,同时给一个refreshToken,有效期7天。用户在访问过程中,每次JWT过滤器发现主令牌快过期(比如剩余有效时间小于5分钟),就在响应头里返回一个新的token,前端拦截器检测到新token就更新本地存储。这样对用户完全透明,没有任何感知。
如果你觉得refreshToken太重,也可以走“每N分钟内自动续期”的轻量方案:网关层判断如果token剩余时长小于阈值,直接重新签发一个token并在响应头返回。要注意,频繁续签会带来两个问题:一个是Redis黑名单里旧token过期时间不好对齐;另一个是同一用户多个并发请求同时触发续签,会签发一堆新token。
5.2 多端登录、踢人下线怎么实现
JWT无状态,服务端没有会话列表,想“踢人下线”就麻烦了。我实现的方式很简单:Redis里以用户为维度维护一个token版本号。用户登录后Redis存userTokenVersion:userId = v1,JWT的payload里也带一个version字段。每次鉴权时对比JWT里的version和Redis里的version,不一致就拒绝。
这样踢人下线就退化成一步操作:修改Redis里version值。比如用户在后台点了“踢出其他设备”,系统只要把Redis的version加1,老token立刻全部失效。同一用户最多允许N个设备在线时,靠version和deviceId配合维护一个设备列表就行。这套方案在部署多实例时依然有效,因为Redis是共享的,各实例拿到的版本号是一致的。
5.3 用Redis分布式锁拦住并发续签
续签操作如果并发发生,会出现同一用户签发多个新token的问题,更严重的是多个写操作同时生成token,导致后续鉴权出现“新旧token交替”的混乱。我在这块用Redis的SETNX做一个简单分布式锁:
String lockKey = "lock:refresh:token:" + userId; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); try { if (locked) { // 执行刷新逻辑 String newToken = jwtUtils.generateToken(userInfo); // 写入Redis缓存 } } finally { redisTemplate.delete(lockKey); }这里的核心点在于:同一时间只能有一个线程为同一个用户续签,其他线程必须等锁释放后,直接拿到最新的token。如果不用这个锁,并发下会产生一堆旧token并存,而且黑名单的冗余切换会让排查变得异常痛苦。
热词里有“redis分布式锁”,这里就是一个非常契合的实际应用场景。用分布式锁的目的不是花哨,而是让并发下的一致性问题控制在可控范围。
6. 常见问题与排查实录
6.1 过滤器链顺序导致接口一直401
现象:登录接口正常,其他接口全部401,Swagger也访问不了。排查半天发现是filterChainDefinitionMap声明顺序不对,/**被写在前面了。Shiro的过滤链匹配规则是“先匹配先生效”,/**拦截了所有路径,前面的规则根本没机会执行。解决办法上面提过,使用LinkedHashMap,把最具体的URL放在最前面。
6.2 Shiro注解直接失效
在SpringBoot整合Shiro时,@RequiresPermissions注解不生效是高频问题。本质原因通常是忘了开启注解支持:
@Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; }这个方法才是让Shiro注解生效的关键。另外注意,如果你的controller在Dubbo服务里,Shiro管理的容器是SpringMVC的容器,和Dubbo的服务暴露是两码事。Dubbo服务里的@RequiresPermissions注解不一定能被Shiro识别,此时你需要在服务接口里显式做权限校验,或者自定义Dubbo Filter调用Shiro的Subject.checkPermission()。
6.3 Redis集群模式下,权限缓存乱套了
如果你的Redis是集群部署(比如三主三从),有一个隐藏问题:多个key使用不同分区槽会导致批量操作失败。我遇到过用mget批量获取用户权限时直接报错CROSSSLOT,原因是用花括号拼key老拼错Redis Cluster的分区规则。解决办法是key设计时把同一用户的多个权限key落到同一个槽位上,比如key统一为{user.123}:order:permissions、{user.123}:goods:permissions,集群环境下{}中的部分会被用来计算槽,这样批量操作就不会跨slot。
6.4 一升级SpringBoot版本,整套权限体系直接崩了
热词里有人提到“springboot版本太高”问题。我遇到过最惨的一次:项目从SpringBoot 2.3升到2.7,Shiro starter没升级,结果过滤器的初始化顺序变了,导致JWT过滤器还没注册就被执行,控制台疯狂报NullPointerException。解决办法是检查依赖版本兼容性。我自己实测比较稳的组合是:
| 组件 | 版本建议 |
|---|---|
| SpringBoot | 2.6.x 或 2.7.x |
| shiro-spring-boot-starter | 1.8.0 及以上 |
| jjwt | 0.9.1 或 0.11.5 |
| dubbo-spring-boot-starter | 2.7.15 或 3.0系 |
| spring-boot-starter-data-redis | 随SpringBoot版本一致 |
6.5 Dubbo调用链路权限全部丢失的案例
下面这个case让我记忆犹新:服务A调用服务B,服务B报“用户未登录”。逐步排查,一开始怀疑是RpcContext的attachment没传对,后来发现其实是服务A那边用了ExecutorService做异步调用,attachment在子线程里根本读不到。解决方式是把userId作为业务方法的参数量显式传递,而不是依赖Dubbo的隐式传参。另外一个注意事项是:如果服务B是多副本部署,各副本必须连同一个Redis,否则从服务A传过来的userId在B的Redis里查不到用户信息,一样会失败。
我在部署的时候,专门给每个环境的Redis分配了独立库(database 0/1/2),防止开发环境和测试环境的用户缓存互相串了。这一点在多人协作时特别重要,不然你会看到莫名其妙“另一个人的用户信息出现在我的token里”这种玄学问题。
7. 关于做这套系统的几句实在话
前后花了大概三周时间把这套系统打磨到“完结”状态之后,我最大的感受是:权限系统不是一个能用“某某框架”一步到位的功能点,它是整个微服务架构里最容易出幺蛾子的一层。真正核心的价值不在那些框架本身,而在于你做出了什么样的设计决策:token生命周期怎么管理、黑白名单放哪里、服务间身份怎么传递、缓存一致性怎么保证,这些策略层面的东西才是最难抄作业的地方。
我在项目完成后,把核心的代码抽成了安全starter放进了私有仓库。后来几个新服务接入,基本上就是引入依赖、配置redis地址和密钥两件事,那一刻才真正感觉自己当初折腾那么多天总算没有白费。
最后再分享一个小技巧:如果你也刚开始做这套方案,先别急着写代码,先画一遍“请求+调用全链路图”,把网关校验、服务内校验、Dubbo透传、缓存失效这几个节点标注清楚,再对照着写,一定会顺畅很多。