写这篇文章的时候,我正好在帮团队把一套基于Spring Boot 3的认证中心从单机内存模式改造成支持多实例部署的分布式架构。折腾了整整一个下午,踩了不少坑,最终敲定了Spring Security 7框架下OAuth2授权码走Redis存储的方案。先简单交代一下背景:新来的同事把授权服务器部署了两台,结果用户在第一台机器上完成登录,请求却被负载均衡转到了第二台,授权码直接找不到了。这个问题的根源就在于授权码默认存在本地内存里,多实例根本不共享。如果你也在做类似改造,这篇文章就是写给你看的。
1. 为什么授权码一定要“挪窝”到Redis?
授权码(Authorization Code)这个东西本质上是OAuth2授权码模式里的一次性临时凭证,作用时间极短,一般也就60秒到10分钟。但它恰恰是整个认证链路里最容易被忽略的状态数据。
1.1 内存存储的“单机魔咒”
Spring Security自带的授权服务器实现,在没有额外配置时,OAuth2AuthorizationService默认使用InMemoryOAuth2AuthorizationService。这个实现内部就是一个ConcurrentHashMap,把授权码、访问令牌、刷新令牌全部堆在当前进程的堆内存里。
单机部署没问题,一旦上Nginx负载均衡或者Kubernetes多副本,问题立刻暴露。用户访问/oauth2/authorize端点时,请求落在实例A上,授权码保存在A的内存里。紧接着前端拿着授权码去换令牌,请求被负载均衡转发到实例B,B的内存里什么都没有,直接抛出InvalidGrantException。
我见过不少团队在这个问题上卡住,第一反应是“把负载均衡改成IP哈希,让同一个用户的请求固定落到同一台机器”。这种方法看似能解决,但治标不治本:实例重启、扩容缩容、灰度发布都会导致会话漂移,授权码照样丢失。
1.2 JDBC存储救不了分布式
Spring Security官方还提供了基于JDBC的JdbcOAuth2AuthorizationService,把授权码、令牌序列化后存进数据库表oauth2_authorization。这种方式能解决多实例共享问题,数据库是天然的共享存储。
但JDBC方案有两个让我头疼的地方:读写延迟。授权码交换令牌的链路本身要求低延迟,每次都要序列化一个巨大的Java对象再写进数据库,在登录峰值时容易成为瓶颈。需要额外维护数据库表结构。官方schema文件里那张表字段非常多,光索引就要建好几个,对于只需要授权码功能的小团队来说有点重。
Redis在这两个维度上正好击中痛点:读写快,内存操作微秒级;自然支持TTL过期,授权码本身就有时效性,交给Redis的过期机制管理,连定时清理的代码都不用写。
1.3 Redis在这条链路里的准确定位
把Redis引入OAuth2授权码链路,不是取代授权服务器,而是起到“状态暂存区”的作用。授权码在Redis里生命周期很短,过期即删,不承载任何永久性数据,所以根本不用担心Redis宕机导致用户数据丢失——最多就是认证过程中的临时状态没了,让用户重新登录一次而已。
理解了这个定位,后面做技术选型、写代码的时候就有了分寸:不能把所有认证相关状态都往Redis里塞,只存授权码和短暂令牌,其他东西该放数据库放数据库。
2. 整体方案设计与Redis数据结构规划
明确目标后,我先在纸上画了一下数据流:授权请求进来,授权服务器生成授权码,写入Redis;用户带着授权码去令牌端点换令牌,从Redis取出来校验;校验通过后删除Redis里的授权码,生成访问令牌。整个过程的关键就是那一份授权码要到Redis里“住上几十秒”。
2.1 核心接口:OAuth2AuthorizationService
Spring Security的授权服务器有一张“万能网”,所有关于授权码、令牌的存取操作都收口在OAuth2AuthorizationService接口里。这个接口定义了三个方法:
save(OAuth2Authorization authorization):保存或更新授权信息remove(OAuth2Authorization authorization):删除授权信息findById(String id):按ID查询findByToken(String token, TokenType tokenType):按令牌值查询,授权码、访问令牌、刷新令牌都走这个方法
我之前看到网上一些文章一上来就教你写RedisOAuth2AuthorizationService实现类,但很少有人讲清楚这个接口背后的调用时机。save方法在授权码生成和令牌刷新时都会调用,findByToken在拿授权码换令牌时调用。理解这些触发点,你才会知道Redis的key该怎么设计,过期时间该设多少。
2.2 Redis Key设计:不搞花活,直接拼ID
Redis的key设计我走了弯路。一开始想用授权码作为key,方便按码值查询,后来发现findByToken方法里传入的token未必是授权码,也可能是访问令牌或刷新令牌。如果只用一种规则存key,三种令牌类型会互相覆盖。
Spring的默认JDBC实现有一个值得借鉴的思路:独立存储,按类型区分。我最终采用了这样的key设计:
spring:oauth2:authorization:{id}:存储授权对象,ID是OAuth2Authorization.getId()spring:oauth2:authorization:{tokenType}:{tokenValue}:存储令牌索引,tokenType是code、access_token、refresh_token
为什么要存两份?因为findByToken拿到的是用户提供的授权码值,但授权对象的主键是id,两者对不上。Redis查询不像数据库那样能用WHERE token_value = ?来检索,必须通过索引key先映射到授权记录ID,再查出完整对象。空间换时间,这是Redis场景的标准做法。
2.3 过期时间:跟随授权码生命周期
TTL的设计直接决定这个方案稳不稳。授权码默认有效期是5分钟,我在RegisteredClient里配置的是AuthorizationCode有效期300秒。Redis的key过期时间我设置成600秒,留了一倍的缓冲,防止网络抖动导致授权码在有效期内却已经被Redis清掉。
访问令牌默认有效期是30分钟,refresh token是7天,这些也要分别设置对应的TTL。我的做法是写一个辅助方法getTokenTimeout(TokenType tokenType),根据令牌类型返回不同的过期秒数。
有一点必须提醒:Redis的TTL只是兜底,真正的业务过期校验仍由AuthorizationCodeAuthenticationProvider按照RegisteredClient里配置的时效来判断。也就是说,Redis里的数据即使没过期,业务层面判断授权码已超时,照样拒绝。TTL设长一点只是为了安全删除,不改变业务语义。
3. 核心代码落地:Redis存储OAuth2Authorization
看官方文档的人会注意到,Spring Security 7里授权服务器相关的类做了不少调整,基于Spring Boot 3项目,配置方式简洁了很多。我用的是Spring Boot 3.x + Spring Security 7(正式发布版对应的是6.x的演进版本),授权服务器的自动配置已经内置,不需要再引入spring-security-oauth2-authorization-server独立依赖。
3.1 引入依赖和Redis配置
既然已经在Spring Boot项目里,Redis的配置交给spring-boot-starter-data-redis就很简单了。加上连接池配置,避免高并发下连接数不够用:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>配置文件里我建议把连接池和过期策略一起写上:
spring: data: redis: host: 127.0.0.1 port: 6379 password: yourpassword timeout: 3000ms lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 max-wait: 3000ms这里有个细节:spring.data.redis.*是Spring Boot 3.0以后的配置前缀,老项目里常见的spring.redis.*写法在Spring Boot 3里已经废弃。许多人升级后Redis一直连不上,十有八九就是这个坑。
3.2 序列化策略:别把SimpleGrantedAuthority烤糊了
直接存OAuth2Authorization对象会遇到一个大麻烦:这个对象内部包含Authentication、Principal、GrantedAuthority等复杂类型,普通的GenericJackson2JsonRedisSerializer序列化时会报类型转换错误,或者反序列化后变成LinkedHashMap。
我建议直接使用Jackson的多态序列化方式。在RedisConfig里配置ObjectMapper时,把需要序列化的具体类型注册进去:
@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); objectMapper.activateDefaultTyping( LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY ); GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(objectMapper); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; }LaissezFaireSubTypeValidator是关键,它允许反序列化时恢复原始类型信息。不过有一点要说明:多态序列化会把类完整路径写进Redis,比如com.example.security.authentication.UsernamePasswordAuthenticationToken,这不是安全问题,因为反序列化只接受这个Jackson实例里注册过的类型。对于SimpleGrantedAuthority这种内部类,反序列化时会有些小毛刺,建议在自定义授权服务器配置里把权限对象转换成自定义的GrantedAuthority实现,避免直接用Spring内部类。
3.3 实现OAuth2AuthorizationService
这是整个方案的核心类。我看过网上很多实现,有的只实现save和findByToken,却忘了remove,导致授权码在Redis里变成僵尸数据。我的实现里把三个方法都覆盖了:
@Service public class RedisOAuth2AuthorizationService implements OAuth2AuthorizationService { private final RedisTemplate<String, Object> redisTemplate; private static final String NAMESPACE = "spring:oauth2:authorization"; public RedisOAuth2AuthorizationService(RedisTemplate<String, Object> redisTemplate) { this.redisTemplate = redisTemplate; } @Override public void save(OAuth2Authorization authorization) { String id = authorization.getId(); String key = buildKey(id); redisTemplate.opsForValue().set(key, authorization, getTimeout(authorization), TimeUnit.SECONDS); // 保存各个令牌的索引,方便按token值查询 for (OAuth2Authorization.Token<OAuth2AuthorizationCode> code : authorization.getTokens(OAuth2AuthorizationCode.class)) { if (code.getToken().isActive()) { String indexKey = buildTokenIndexKey("code", code.getToken().getTokenValue()); redisTemplate.opsForValue().set(indexKey, id, getTimeout(authorization), TimeUnit.SECONDS); } } // 访问令牌和刷新令牌索引逻辑类似,此处省略重复代码 } @Override public void remove(OAuth2Authorization authorization) { if (authorization == null) return; redisTemplate.delete(buildKey(authorization.getId())); // 同时清理所有令牌索引,这里需要在保存时维护token到id的映射 } @Override public OAuth2Authorization findById(String id) { return (OAuth2Authorization) redisTemplate.opsForValue().get(buildKey(id)); } @Override public OAuth2Authorization findByToken(String token, OAuth2Authorization.TokenType tokenType) { String tokenKey = buildTokenIndexKey(tokenType.getValue().toLowerCase(), token); Object id = redisTemplate.opsForValue().get(tokenKey); if (id == null) return null; return findById((String) id); } private String buildKey(String id) { return NAMESPACE + ":" + id; } private String buildTokenIndexKey(String type, String tokenValue) { return NAMESPACE + ":" + type + ":" + tokenValue; } private long getTimeout(OAuth2Authorization authorization) { // 根据授权对象里的令牌类型设置不同TTL,简化起见统一返回600 return 600L; } }有一个细节必须强调:findByToken方法传进来的TokenType是OAuth2Authorization.TokenType枚举,访问令牌的类型值小写是access_token,刷新令牌是refresh_token,授权码是code。拼索引key时如果大小写没处理好,会找不到预存的索引。
remove方法也要实现完整。在实际调用链中,授权码换令牌成功后,Spring会调用remove把授权码清掉。如果不清,同一个授权码理论上可以重复使用——虽然授权服务器内部校验会拦截,但Redis里残留的无用数据会越积越多,白白浪费内存。
3.4 替换默认实现:注入自己的Service
代码写完,还要让授权服务器用它。在Spring Security 7的授权服务器配置里,OAuth2AuthorizationService是一个Bean,框架自动注入默认的内存实现。我只需要在配置类里显式声明自己的RedisOAuth2AuthorizationService,并且把它放进OAuth2AuthorizationServerConfigurer:
@Configuration @EnableWebSecurity public class SecurityConfig { private final RedisOAuth2AuthorizationService authorizationService; public SecurityConfig(RedisOAuth2AuthorizationService authorizationService) { this.authorizationService = authorizationService; } @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { http.securityMatcher("/oauth2/**", "/login", "/authorize") .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) .with(new OAuth2AuthorizationServerConfigurer(), configurer -> { configurer.authorizationService(authorizationService); }) .csrf(csrf -> csrf.ignoringRequestMatchers( new AntPathRequestMatcher("/oauth2/token"), new AntPathRequestMatcher("/oauth2/revoke") )); return http.build(); } }把authorizationService(authorizationService)这段去掉,系统就默认用内存实现;加上之后,授权码就切到Redis了。切换成本极低,这也是Spring Security抽象做得好的地方。
4. 实操中遇到的坑:序列化、并发与过期清理
代码落地只是第一步,真正折磨人的是上线后遇到的问题。我把自己踩过的坑整理出来,按严重程度排个序。有些问题不踩不知道,一踩就足以让整个认证中心“半身不遂”。
4.1 授权码序列化导致的“反序列化崩溃”
这个坑花了我最多时间。授权码存入Redis后,拿回来一对比,发现authorization.getAttributes()里的AuthorizationRequest对象变成了空壳,客户端ID丢失。根本原因就是没有把AuthorizationRequest相关的类注册到Jackson的ObjectMapper里。
解决方案有两种。一种是给ObjectMapper配置activateDefaultTyping,让所有非final类型存进去时带上类信息;另一种是只对已知类型开启多态。我推荐第二种,安全性和稳定性更高:
objectMapper.registerModule(new SimpleModule().addAbstractTypeMapping( AbstractAuthenticationToken.class, UsernamePasswordAuthenticationToken.class ));如果你只是自己测试还好,一旦授权服务器对接了多个客户端应用,每个客户端的AuthorizationRequest里带的GrantedAuthority实现类可能都不一样,反序列化时直接报InvalidDefinitionException。这个问题的通用解法是:在RedisConfig里用setObjectMapper定制序列化器,把常用的安全相关的类型都显式注册一遍。
4.2 Redis连接超时拖垮授权码交换
授权码交换令牌的接口是/oauth2/token,这个接口对性能极其敏感。有一次我压测时发现,并发200的时候,Redis连接池被打满,JedisConnectionException不断抛出,认证直接失败。
排查后发现是lettuce连接池配置问题。默认的max-active是8,对于授权码这种高频短连接场景完全不够用。另外,timeout: 3000ms只设置了读取超时,但连接池获取连接时的max-wait如果设得太短,高并发下会出现大量pool exhausted异常。
建议把连接池参数设置成我上面列的那组值,同时开启lettuce的shareNativeConnection(Spring Boot默认开启),让多个操作复用同一个连接。对于极端峰值场景,还可以在Redis客户端侧做熔断降级:授权码查询失败时,直接返回认证失败,让用户重试,而不是无限阻塞。
4.3 Redis宕机后,授权服务器还能不能用?
这个问题在架构评审时被同事反复追问。我的结论是:授权服务器可以设计成“降级模式”,但默认要走向快速失败。
为什么不能默认降级?因为授权码状态一旦丢失,继续放行请求会导致严重的安全漏洞——用户明明没有完成授权,却能换到令牌。这种情况下快速失败是唯一正确的选择。
不过有一种降级思路可以考虑:在Redis不可用时,把OAuth2AuthorizationService切换到InMemoryOAuth2AuthorizationService,牺牲分布式一致性,换取单机可用性。前提是你得接受一个事实:多实例之间授权码不共享,负载均衡策略需要临时调整为IP哈希。这个方案我只建议在非核心环境(比如预发环境)用,生产环境别玩这个。
4.4 授权码过期在Redis里还存在,怎么清理?
前面提到,Redis的TTL和业务过期时间是两套机制。业务判断授权码过期后,会返回InvalidGrantException,但Redis里的数据要等TTL到了才会真正删除。这期间数据占着内存。
我补充了一个兜底清理策略:在RedisOAuth2AuthorizationService里加一个定时任务,每5分钟扫描一次授权码索引,检查AuthorizationCode.getExpiresAt(),如果已过期但仍在Redis中,主动删除:
@Scheduled(cron = "0 */5 * * * ?") public void cleanExpiredAuthorizations() { // 扫描所有以 spring:oauth2:authorization:code: 开头的key // 逐一取出ID,比对过期时间,过期则执行删除 }不过这里要特别小心:直接使用keys命令在生产环境是灾难。Redis的keys在大数据量下会阻塞单线程,必须用scan命令迭代。我写了个ScanOptions版本的批量清理工具,实测在生产环境运行稳定,没有阻塞Redis。
5. 关键配置与安全加固建议
方案运行稳定后,我又从安全角度过了几遍设计。很多团队只盯着功能实现,忘了授权码存储这个环节本身也是有安全要求的。
5.1 Redis本身要足够安全
授权码虽然不是持久凭证,但它是换取令牌的“钥匙”。如果Redis被未授权访问,攻击者可以读取授权码,再配合拦截到的请求就能冒领访问令牌。
因此,线上环境的Redis至少要满足这几点:启用密码认证,禁用config命令,绑定内网IP,不暴露公网端口。用云厂商的Redis服务时,要开启虚拟私有网络隔离。Redis的protected-mode必须设置为yes。
另外推荐开启Redis的AOF持久化。有人会觉得授权码丢了就丢了,没必要开AOF,但考虑一个场景:授权码刚写入Redis,服务端还没来得及完成响应,Redis进程崩溃。如果开启了AOF且刷盘策略合理,重启后至少不丢最近一秒的数据,对用户体验影响很小。
5.2 密钥和客户端凭据不要进Redis
我做第一个版本的时候图省事,把RegisteredClient里的clientSecret也一起放进了Redis缓存。后来安全审查发现这个问题:客户端密钥属于静态凭证,不应该出现在缓存系统里。
正确的做法是:Redis里只放OAuth2Authorization对象,即跟授权会话有关的动态数据。RegisteredClient相关的配置继续放在数据库或者内存配置里,让RegisteredClientRepository去管理。两者职责分离,安全边界清晰。
5.3 审计日志与监控指标
上线稳定之后,我加了两样东西:审计日志和Redis监控指标。审计日志记录每次授权码生成、消费、删除的关键节点,包括客户端ID、用户主体、来源IP和授权码指纹(MD5哈希,不能存原文)。Redis监控指标包括:授权码存量、命中率、过期清理数量、Redis读写耗时P99。
这些数据接入Prometheus后,我能很直观地看到授权码链路的健康度。有一次发现授权码存量曲线异常上涨,排查后发现是一个客户端在循环刷新授权码,没有及时消费,是客户端代码逻辑问题。如果没有监控指标,这种问题可能要等用户投诉了才会暴露。
6. 常见问题速查表
把整个改造过程中积攒的问题整理成一个速查表,方便大家对照排查。简单列一下主要的几个典型问题:
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
授权码交换令牌时返回invalid_grant | 多实例部署但授权码仍存内存 | 确认是否注入了RedisOAuth2AuthorizationService,查看Redis key是否存在 |
| Redis key存在但反序列化报错 | Jackson没有注册安全相关的类型 | 配置ObjectMapper的多态序列化,显式注册抽象类实现 |
| 上线后授权码存储速度明显变慢 | Redis连接池配置不合理或网络延迟高 | 调大max-active,开启lettuce连接复用,检查同机房部署 |
| 压测时授权码大量丢失 | Redis主从切换导致写入丢失 | 开启AOF持久化,保证写入多数节点,或使用云Redis的高可用版本 |
| 授权码成功消费后Redis里还有残留 | remove方法没实现完整 | 在save和remove里同时维护索引key和主key的清理逻辑 |
还有一个大家问得比较多的:为什么我不直接用RedissonClient做分布式锁?授权码存储本身是读写Redis,不需要分布式锁。真正需要加锁的场景是“同一个授权码同时被两个请求消费”,在Redis层面可以用set(key, value, nx)做幂等控制,但这超出本文范围。授权码消费本身足够快,并发冲突概率极低,建议先把基础方案跑通,再加锁优化。
从内存存储切到Redis存储,核心改动就是实现一个OAuth2AuthorizationService并替换默认Bean,但这个看似简单的替换牵扯到序列化、TTL、索引、异常兜底等一系列细节。我在实施过程中最大的体会是:Spring Security 7的授权服务器框架已经把边界划得很清晰,你自己要操心的不是框架内部怎么做,而是数据持久层的选型适配。Redis作为授权码的临时存储介质,既要利用它的高速读写,也要警惕它“重开即空”的特点,该设置的过期时间、监控、安全加固,一项都不能少。如果后续你还要把访问令牌的JWT签名密钥也纳入统一管理,这套Redis存储方案完全能复用,把索引key再扩展一层即可。