资讯动态

Spring Security 7 OAuth2授权码Redis分布式存储方案与实战坑点

发布时间:2026/10/1 5:00:09 来源:尧图企业网站定制
把授权码放在内存里这事第一次在开发环境跑通时没什么感觉一旦生产上了多实例问题就接踵而来用户在 A 实例完成了授权跳转回调落到 B 实例结果 B 实例发现授权码不存在直接给你一个invalid_grant。Spring Security 7 时代OAuth2 授权码模式的存储方案已经从“能跑就行”上升到“分布式环境下必须共享”的程度。这篇文章就围绕 Spring Security 7 的 OAuth2 授权码分布式存储聊清楚为什么要用 Redis、怎么设计、怎么落地以及我在实际项目中踩过的那些坑。这篇文章适合谁看正在从单机演示项目走向生产环境的应用开发、对 Spring Authorization Server 有兴趣但又不想被官方文档绕晕的同学还有那些已经被“授权码丢失”问题折磨过、想找到一套可参考解决方案的同行。我会把从接口抽象到 Redis 数据结构、从序列化策略到多实例并发的完整过程都写出来代码能直接抄方案能直接用。1. 先搞清楚一件事授权码存储在整个 OAuth2 流程中的真实位置1.1 授权码模式的一次完整握手里授权码到底经历了什么在标准 OAuth2 授权码模式下整个流程可以拆成两段第一段是浏览器和授权服务器之间的交互第二段是客户端应用和授权服务器之间的交互。第一段以用户点击授权按钮开始授权服务器校验用户身份、校验客户端参数、确认授权范围之后生成一个随机字符串——这个字符串就是授权码然后通过浏览器重定向返回给客户端通常是附加在回调 URL 后。第二段紧接着发生客户端拿到这个授权码之后不再通过浏览器而是直接向后端接口发起 token 换取请求一般是 POST/oauth2/token把授权码、client_id、client_secret 一起交给授权服务器。授权服务器要做的事情不是简单验证授权码字符串对不对而是需要把这个授权码关联的完整上下文找出来——是哪个用户授权的、授权的 scope 是哪些、当时带了什么 redirect_uri、有没有绑定 PKCE 的 code_challenge、这个授权码是什么时候签发的、过期没有。这些上下文数据就是授权码的“存储内容”。你可以把它看作一个临时会话授权码本身只是这个会话的 key。用户点击授权那一刻服务器把整个会话塞进存储层客户端拿授权码回来换 token 时服务器根据授权码反查到会话数据校验通过后用其中的信息生成 access_token 和 refresh_token。1.2 内存存储和 JDBC 存储的边界在哪里Spring Authorization Server 默认提供的两种存储实现很多同学只知道名字叫 InMemory 和 Jdbc但对各自的适用场景和边界未必有清晰认识。InMemoryOAuth2AuthorizationService用 ConcurrentHashMap 把所有授权数据放在进程内存中。单机开发调试确实方便零配置直接跑但有两个硬伤第一服务重启全部授权码瞬间蒸发用户在上一步刚完成授权下一步请求 token 时就会被系统拒绝第二多实例部署时每个实例的内存是独立的授权码记录在 A 实例请求却可能被负载均衡转发到 B 实例B 实例翻开自己的内存找不到对应数据只能返回错误。这两个硬伤决定了内存存储只能活在开发环境。JdbcOAuth2AuthorizationService把数据落到数据库解决了重启丢失和多实例共享的问题。但它的成本在于需要建五张表authorization、authorization_consent 等需要配置数据源和事务每个 token 请求都会敲一次数据库。在高并发换取 token 的场景下数据库连接池反而可能成为瓶颈。对于大多数互联网应用把授权码这类临时性高、并发量中等的会话数据放到数据库有点大材小用而且清理过期数据还得另行处理。Redis 在这两者之间提供了很好的平衡存储介质独立于应用进程天然支持多实例共享读写亚毫秒级性能上完全满足 token 端点的请求压力Key 自带 TTL 过期机制授权码这种“几分钟内有效、用完即焚”的数据正是它最擅长的场景。2. Spring Security 7 里授权码存储的抽象核心OAuth2AuthorizationService2.1 这个接口管的不只是授权码很多刚接触 Spring Authorization Server 的同学会有一个误解OAuth2AuthorizationService这个类名里带 Authorization就以为它只负责授权码。实际上它管理的是整个 OAuth2 授权会话也就是OAuth2Authorization授权码只是这张“会话表”里的一个字段。一个OAuth2Authorization对象里装了什么我拆开看id授权记录的全局唯一标识principalName发起授权的用户名authorizedScopes用户已授权的 scope 集合token授权码OAuth2AuthorizationCode封装accessToken后续生成的访问令牌refreshToken刷新令牌attributes额外的上下文属性比如 PKCE 的 code_challenge、state 参数等所以这个接口真正做的事情是对整个授权过程的“状态”做持久化。接口定义非常简洁一共五个方法public interface OAuth2AuthorizationService { void save(OAuth2Authorization authorization); void remove(OAuth2Authorization authorization); OAuth2Authorization findById(String id); OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType); }findByToken中的tokenType用来区分传入的字符串到底是授权码、访问令牌还是刷新令牌。在授权码换 token 这个环节授权服务器就是靠findByToken(authorizationCode, OAuth2TokenType.AUTHORIZATION_CODE)把用户授权时保存的上下文捞回来的。2.2 为什么要自己实现而不是改官方的 Jdbc 实现如果只想要“分布式存储”这个结果数据库方案已经能满足了。但 Redis 方案的优势在于更快的读写性能、更自然的临时数据管理、更少的运维依赖。而前面说了Jdbc 实现在高并发下会不断占用数据库连接而且在数据清理上要依赖手工定时任务。还有一个更深层的原因OAuth2AuthorizationService 是一个扩展点非常明确的接口实现它不代表要重写整个授权服务器的逻辑。授权码的生成、token 的下发、PKCE 校验这些流程全在框架内部完成它们只是把“存储这个动作”委托给了这个接口。这意味着自定义一个 Redis 实现是完全受支持的扩展方式不会破坏框架原有行为。基于这一点我的方案就很清晰了自己写一个RedisOAuth2AuthorizationService实现接口的五个方法把数据存到 Redis然后通过 Bean 覆盖默认实现。这样授权服务器内部逻辑一行都不用改。3. Redis 存储方案怎么设计才可靠数据结构、Key 规划、序列化策略3.1 Key 设计主数据与索引的分离Redis 是 KV 存储不能像关系数据库那样按条件查询。findByToken需要按 token 字符串反查所以只存一条 id 映射是不行的。我的设计是将数据分成两类主数据 Key 和索引 Key。主数据 Key 保存完整的OAuth2Authorization序列化结果索引 Key 保存 token 字符串到主数据 Key 的映射。用一张表来说明Key 模式ValueTTLoauth2:authorization:{id}序列化的 OAuth2Authorization 对象取 token 中最大的有效期oauth2:authorization:code:{code}authorization id授权码有效期默认 5 分钟oauth2:authorization:access_token:{token}authorization idaccess token 有效期oauth2:authorization:refresh_token:{token}authorization idrefresh token 有效期为什么索引 Key 和主数据 Key 的 TTL 要分开设置因为授权码的生存周期和 access_token、refresh_token 完全不一样。授权码是短命数据5 分钟后就没用了但同一个授权记录对应的 refresh_token 可能还能维持更长的时间。如果把整个授权记录在授权码过期时就删掉后续客户端拿 refresh_token 来刷新时就会扑空。主数据 Key 的 TTL 我建议取 access_token 和 refresh_token 两者中较长的那个给 refresh token 留足存活时间。索引 Key 各自跟着自己的 token 有效期走Redis 过期机制会自动清理。3.2 存储结构的最优解整体序列化还是拆字段这里有一个很多第一次写 Redis 存储方案的同学容易纠结的问题是仿照 Jdbc 实现把 OAuth2Authorization 的各个字段拆开用 Redis Hash 存还是整体序列化成一个 value我的结论是整体序列化。原因有几点第一OAuth2Authorization内部很多嵌套对象如OAuth2AuthorizationCode、OAuth2AccessToken、认证信息Authentication等拆字段存的话存进去容易读出来拼装极其痛苦那些嵌套对象还要再拆一层第二框架拿到OAuth2Authorization后是按整个对象用的拆字段意味着每次读取要组装 N 次 Redis 操作网络开销显著第三整体序列化后一次简单的 get 就能拿到完整对象快得很。3.3 序列化方案对比JDK 原生序列化 vs JSON序列化是 Redis 方案里最容易踩坑的地方。官方 Jdbc 实现用的是一套基于 Java Serialization 的Serializer机制所以我的 Redis 实现直接采用 JDK 原生序列化value 存byte[]Redis 里看到的是一个二进制对象。我推荐 JDK 原生序列化原因很简单OAuth2Authorization及其内部对象都实现了Serializable接口序列化端到端不需要额外配置拿来就能用。JSON 序列化虽然可视化效果好但 OAuth2Authorization 内部包含多态的Authentication实现类解析时要用到大量自定义 deserializer工作量成倍增加。顺便说一个取舍JDK 序列化产物里面带 class metadata体积偏大但在授权码这种低频小数据场景下完全不是问题。如果后续想追求更极致的性能和可读性可以考虑 Kryo在项目初期先用 JDK 序列化把链路跑通是最务实的路线。如果不喜欢二进制乱码又想可调试还有一种折中做一层 Base64 编码把二进制转成字符串存进去Redis Desktop Manager 里看着是一段可复制的字符串但会额外增加 33% 体积。我在正式环境没做这层编码保持原样。3.4 核心实现代码来看完整的实现类。这个类可以直接复制到项目里使用package com.example.oauth2.redis; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; import org.springframework.beans.factory.InitializingBean; import org.springframework.dao.DataRetrievalFailureException; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.SerializationException; import org.springframework.security.jackson2.CoreJackson2Module; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AuthorizationCode; import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; import org.springframework.security.oauth2.core.endpoint.PkceParameterNames; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationServerJackson2Module; import org.springframework.security.web.jackson2.WebServletJackson2Module; import org.springframework.util.Assert; import org.springframework.util.StringUtils;等一下上面这个开头有误导性我在实际项目中并不推荐把 JDK 序列化用 Jackson 混着写那样会把自己搞晕。下面是我真正在用的、干净利落的基于 JDK 序列化的实现版本。import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.security.oauth2.core.OAuth2AuthorizationCode; import org.springframework.security.oauth2.core.OAuth2RefreshToken; import org.springframework.security.oauth2.server.authorization.OAuth2Authorization; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; import org.springframework.security.oauth2.server.authorization.OAuth2TokenType; import javax.annotation.PreDestroy; import java.time.Duration; import java.time.Instant; import java.util.concurrent.TimeUnit; public class RedisOAuth2AuthorizationService implements OAuth2AuthorizationService { private static final String PREFIX oauth2:authorization:; private static final String CODE_PREFIX PREFIX code:; private static final String ACCESS_TOKEN_PREFIX PREFIX access_token:; private static final String REFRESH_TOKEN_PREFIX PREFIX refresh_token:; private final RedisTemplateString, Object redisTemplate; public RedisOAuth2AuthorizationService(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; // 保证主数据 key 使用字符串序列化器 this.redisTemplate.setKeySerializer(new StringRedisSerializer()); this.redisTemplate.setValueSerializer(new JdkSerializationRedisSerializer()); } Override public void save(OAuth2Authorization authorization) { // 先清理可能存在的旧索引避免脏数据残留 remove(authorization); String id authorization.getId(); OAuth2AuthorizationCode authorizationCode authorization.getToken(OAuth2AuthorizationCode.class); OAuth2AccessToken accessToken authorization.getToken(OAuth2AccessToken.class); OAuth2RefreshToken refreshToken authorization.getToken(OAuth2RefreshToken.class); // 计算主数据 TTL取所有 token 中最大的过期时间 Instant maxExpiresAt null; if (authorizationCode ! null authorizationCode.getExpiresAt() ! null) { maxExpiresAt maxExpiresAt null ? authorizationCode.getExpiresAt() : maxExpiresAt.isBefore(authorizationCode.getExpiresAt()) ? authorizationCode.getExpiresAt() : maxExpiresAt; } if (accessToken ! null accessToken.getExpiresAt() ! null) { maxExpiresAt maxExpiresAt null ? accessToken.getExpiresAt() : maxExpiresAt.isBefore(accessToken.getExpiresAt()) ? accessToken.getExpiresAt() : maxExpiresAt; } if (refreshToken ! null refreshToken.getExpiresAt() ! null) { maxExpiresAt maxExpiresAt null ? refreshToken.getExpiresAt() : maxExpiresAt.isBefore(refreshToken.getExpiresAt()) ? refreshToken.getExpiresAt() : maxExpiresAt; } Duration ttl Duration.between(Instant.now(), maxExpiresAt); // 主数据 key redisTemplate.opsForValue().set(PREFIX id, authorization, ttl.toSeconds(), TimeUnit.SECONDS); // 授权码索引 key授权码有效期一般只有五分钟 if (authorizationCode ! null) { Duration codeTtl Duration.between(Instant.now(), authorizationCode.getExpiresAt()); redisTemplate.opsForValue().set(CODE_PREFIX authorizationCode.getTokenValue(), id, codeTtl.toSeconds(), TimeUnit.SECONDS); } // access token 索引 key if (accessToken ! null) { Duration accessTtl Duration.between(Instant.now(), accessToken.getExpiresAt()); redisTemplate.opsForValue().set(ACCESS_TOKEN_PREFIX accessToken.getTokenValue(), id, accessTtl.toSeconds(), TimeUnit.SECONDS); } // refresh token 索引 key if (refreshToken ! null) { Duration refreshTtl Duration.between(Instant.now(), refreshToken.getExpiresAt()); redisTemplate.opsForValue().set(REFRESH_TOKEN_PREFIX refreshToken.getTokenValue(), id, refreshTtl.toSeconds(), TimeUnit.SECONDS); } } Override public void remove(OAuth2Authorization authorization) { if (authorization null) { return; } String id authorization.getId(); OAuth2AuthorizationCode authorizationCode authorization.getToken(OAuth2AuthorizationCode.class); OAuth2AccessToken accessToken authorization.getToken(OAuth2AccessToken.class); OAuth2RefreshToken refreshToken authorization.getToken(OAuth2RefreshToken.class); redisTemplate.delete(PREFIX id); if (authorizationCode ! null) { redisTemplate.delete(CODE_PREFIX authorizationCode.getTokenValue()); } if (accessToken ! null) { redisTemplate.delete(ACCESS_TOKEN_PREFIX accessToken.getTokenValue()); } if (refreshToken ! null) { redisTemplate.delete(REFRESH_TOKEN_PREFIX refreshToken.getTokenValue()); } } Override public OAuth2Authorization findById(String id) { Assert.hasText(id, id cannot be empty); return (OAuth2Authorization) redisTemplate.opsForValue().get(PREFIX id); } Override public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) { Assert.hasText(token, token cannot be empty); String key; if (OAuth2TokenType.ACCESS_TOKEN.equals(tokenType)) { key ACCESS_TOKEN_PREFIX token; } else if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) { key REFRESH_TOKEN_PREFIX token; } else if (OAuth2TokenType.AUTHORIZATION_CODE.equals(tokenType)) { key CODE_PREFIX token; } else { throw new IllegalArgumentException(Unsupported token type: tokenType); } String id (String) redisTemplate.opsForValue().get(key); return id null ? null : findById(id); } PreDestroy public void destroy() { // 也可以在这里做关闭连接等清理动作目前留空 } }JdkSerializationRedisSerializer 默认使用 Java 原生的ObjectOutputStream所以要求所有需要序列化的对象实现Serializable。Spring Authorization Server 的 OAuth2Authorization、OAuth2AuthorizationCode、OAuth2AccessToken 这些类都已经实现了 Serializable直接使用没有任何障碍。3.5 自定义序列化器方案解决 JSON 可读性问题如果团队有 Redis key 可读性要求不想在 Redis 里看到二进制乱码可以考虑用 JSON 序列化替代 JDK 序列化。这里我给一个能跑通的方法但请理解它的代价。JSON 序列化的核心难点是 OAuth2Authorization 内部包含多态类型反序列化时必须保留类型信息。思路是使用 Jackson 的默认类型信息机制加上 Spring Security 自带的模块import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; import org.springframework.security.jackson2.CoreJackson2Module; import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationServerJackson2Module; import org.springframework.security.web.jackson2.WebServletJackson2Module; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; ObjectMapper objectMapper new ObjectMapper(); objectMapper.registerModule(new CoreJackson2Module()); objectMapper.registerModule(new WebServletJackson2Module()); objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module()); objectMapper.activateDefaultTyping( LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, com.fasterxml.jackson.annotation.JsonTypeInfo.As.PROPERTY ); objectMapper.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS); GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(objectMapper);用这种方式Redis value 是 JSON 字符串可以读。但代价是必须保证所有被序列化的类型都在 Jackson 的可见范围内一旦出现自定义的 Authentication 实现类就需要手动注册 MixIn 或者自定义序列化器。这里我一定要提醒一句JSON 方案在单元测试里跑通很简单但真正投入生产后你会遇到“自定义 UserDetails 类型解析不出来”“新版本升级后类型全名变了”这类问题。我的建议是如果没有硬性要求先走 JDK 序列化把方案跑稳再考虑优化。4. 把 Redis 存储接入 Spring Authorization Server 的完整步骤4.1 RedisTemplate 的配置Spring Boot 项目里RedisTemplate的声明建议独立出来避免和业务缓存混用。生产环境里 Redis 往往承担多种职责——缓存治理、分布式锁、消息队列等oauth2 存储单独使用一个 template、甚至单独使用一个 database能有效避免互相干扰import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; Configuration(proxyBeanMethods false) public class RedisOAuth2Configuration { Bean public RedisTemplateString, Object oauth2RedisTemplate( RedisConnectionFactory redisConnectionFactory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(redisConnectionFactory); template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new JdkSerializationRedisSerializer()); template.setHashValueSerializer(new JdkSerializationRedisSerializer()); template.afterPropertiesSet(); return template; } }如果公司规范不允许使用 JDK 序列化可以在这一步换成 GenericJackson2JsonRedisSerializer需要做的额外配置就是前面提到的 ObjectMapper 定制。4.2 覆盖默认的 OAuth2AuthorizationServiceSpring Authorization Server 的自动配置逻辑是如果容器中有用户自定义的OAuth2AuthorizationServiceBean就优先使用它否则按条件装配InMemoryOAuth2AuthorizationService。所以接入方式非常简单只要把自己实现的 Bean 声明出来即可import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; Configuration(proxyBeanMethods false) public class OAuth2AuthorizationServerConfig { Bean public OAuth2AuthorizationService oauth2AuthorizationService( RedisTemplateString, Object oauth2RedisTemplate) { return new RedisOAuth2AuthorizationService(oauth2RedisTemplate); } }这里有一个细节Spring Boot 的自动装配是按类型找 Bean 的如果你的项目里已经依赖了 Spring Authorization Server 的 starter它会自动装配默认实现。此时自定义 Bean 的作用是通过条件注解替换默认实现——ConditionalOnMissingBean是 Spring Boot 自动装配类上最常见的条件只要存在自定义 Bean默认的就不会被创建。如果你用的不是 Spring Boot 的自动装配而是手动注册配置类只需要保证以上两个配置类都被扫描到即可。4.3 验证链路重启服务和双实例场景接入完成后我建议按下面的顺序做验证。第一启动单实例走一遍完整授权码流程确认授权和换 token 都能正常完成。第二获取授权码之后、换取 token 之前重启应用实例再执行换 token 请求——如果 Redis 存储生效重启不会影响授权码的有效性请求会正常返回 access_token。这一步尤其能验证存储独立性。第三启动两个实例通过 Nginx 或 Spring Cloud LoadBalancer 将请求分发到不同实例先让授权请求落到实例 A再让换 token 请求落到实例 B——如果两边都能从 Redis 读到同一个授权记录说明分布式共享场景已打通。关于 Nginx 的配置这里不展开核心原理就是让两个实例共享同一个 Redis负载均衡策略不会影响授权码的可用性。5. 我在生产环境真正踩过的几个坑5.1 序列化反序列化不兼容jar 包升级后 token 全废了这是我在真实项目里遇到的最头疼的问题。上线一段时间后Spring Authorization Server 从版本升了一级重启后发现用户拿有效的 refresh_token 来刷新时反序列化直接报InvalidClassException原因是类版本号变了。JDK 序列化对类的结构变化极其敏感。serialVersionUID如果没显式声明JVM 会根据类的字段和方法哈希值自动生成类结构一变UID 就变。解决思路有两层第一层所有自定义的、会被放入 OAuth2Authorization attributes 的实体类务必显式声明private static final long serialVersionUID 1L;至少保证小版本升级时不会因为 UID 变化翻车。这个经验适用于所有放入序列化链路的对象。第二层上线发布期间如果有蓝绿发布或滚动发布新旧两个版本会共存一段时间。新版本的序列化格式和老版本可能不一致导致升级过程中部分 token 读取失败。比较稳妥的做法是发布窗口内确保允许用户重新走一次授权流程因为 token 失效后重新授权对用户的影响通常低于延长发布窗口的代价。5.2 授权码的“一次性”语义在并发下被击穿OAuth2 规范要求授权码只能使用一次第二次使用同一授权码必须拒绝。但 Redis 的方案天然存在一个竞态窗口两个并发请求同时携带同一个授权码来换 token都先执行findByToken在同一时刻都查到了授权记录都判断为“有效”然后都继续往下走最终两个请求都拿到了 token。这就把授权码变成了一次性之外还出现了双花。官方 JDBC 实现之所以没有这个问题是因为它有数据库事务和行锁兜底。Redis 实现要做等价保证必须自己处理原子性。我的解法是放弃“先查后删”方案改成直接用 Lua 脚本原子地完成“查询 删除 返回”local id redis.call(GET, KEYS[1]) if id then redis.call(DEL, KEYS[1]) local authKey oauth2:authorization: .. id redis.call(DEL, authKey) return id else return false end把这段 Lua 脚本封装到 RedisOAuth2AuthorizationService 里授权码换 token 时调用它由 Redis 自身保证并发安全。这样授权码在被两个并发请求同时使用时只有一个请求能通过脚本拿到 id另一个返回空直接被判定为无效授权码。需要说明的是为了兼容性这个脚本在框架的默认流程中依然可以在findByToken中正常工作——你只需要在findByToken里对 AUTHORIZATION_CODE 类型走 Lua 逻辑其他类型保持不变。5.3 Redis 故障导致的连锁反应没有保护Redis 一旦宕机授权码存储不可用所有授权码换取 token 的请求都会失败。这个问题比数据库不可用更容易被忽视因为大家默认 Redis 是缓存层挂了顶多多查一次数据库。但 OAuth2 授权码是不可重放的Redis 挂了客户端会拿到一串 500 或超时错误。我的建议是给 Redis 存储逻辑加上降级开关如果 Redis 不可用可以将授权码存储降级到本地内存或数据库但降级逻辑要明确告知用户——分布式场景下降级只能保证单体自身的流程闭环多实例下仍可能失败。比较务实的目标是短时间内告警尽快恢复 Redis避免用户感知长时间故障。最理想的状态是在架构层面让 Redis 高可用例如采用主从加哨兵或集群模式这取决于团队的运维架构。5.4 授权码索引的明文存储安全隐患新的安全规范其实建议对 token 做哈希后再存入 Redis避免 Redis 数据泄露时授权码和令牌被直接拿到。JDK 序列化方案中主数据里的授权码、access token 都是以明文形式存在于 Redis value 中的如果 Redis 被脱库风险很大。改进思路是给索引 Key 做一层哈希处理例如使用 SHA-256private String hashToken(String token) { try { MessageDigest digest MessageDigest.getInstance(SHA-256); byte[] bytes digest.digest(token.getBytes(StandardCharsets.UTF_8)); return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(SHA-256 not available, e); } }主数据 Key 因为不能哈希反序列化后找不到 id 就麻烦了可以保留原样或者把 id 本身设计为 UUID这个 UUID 泄露的风险相对较低。索引 Key 用哈希值后再结合 Redis 的 ACL 权限控制和网络安全隔离能把数据泄露风险降到更低。5.5 授权记录里放了不可序列化的对象OAuth2Authorization 的 attributes 是可以自定义放东西的。有些同学在授权流程里往 attributes 塞了一个自定义对象结果在 Redis 反序列化的时候直接报NotSerializableException。解决方法是所有需要放入 attributes 的对象都要实现 Serializable 接口。如果塞进去的是第三方库的不可序列化对象建议包装成自己可序列化的 DTO 再存入。6. 进阶优化授权码 Redis 存储还能怎么扩展6.1 基于 Redis 过期事件做主动清理前面设计的 TTL 方案已经可以自动过期大部分数据但主数据 Key 的 TTL 是按最长 token 有效期设置的授权码本身可能早就过期了主数据还在 Redis 里躺着。如果业务上要求严格的数据生命周期管理可以开启 Redis 的 Keyspace Notifications监听oauth2:authorization:*的过期事件过期时顺势把相关联的其他 Key 一起清理。这里有个前提Redis 需要配置notify-keyspace-events Ex才能推送过期事件。开启之后应用里用RedisMessageListenerContainer订阅事件频道收到过期 key 时解析 id删除对应的索引。这个机制我实际测试过可靠性还可以但要注意过期事件不是严格实时的Redis 的过期清理是惰性且周期性的。对于授权码这种几分钟内就要严格失效的场景侧重点应放在业务校验上而不是依赖事件清理。6.2 hash 索引与 Redis 集群的兼容性如果你的 Redis 是 Cluster 模式所有 key 会根据哈希槽分散到不同节点。我设计的 Key 结构中主数据 Key 和索引 Key 的前缀不同按 CRC16 计算哈希槽时就可能落到不同的节点导致同一个授权记录的数据和索引分散在两个节点。对于授权码这种单次操作不多的场景影响不大因为我的逻辑是先查索引再查主数据两次 get 不是事务性的。但如果需要保证原子性就必须让相关 Key 落在同一个哈希槽。做法是在 Key 设计时使用哈希标签让相关 key 带上同一个标签oauth2:{userSessionId}:authorization:{id}不过加入标签后 key 的语义会变复杂。我的经验是授权码操作本身是低频且非强事务性的Lua 脚本在集群跨 slot 时无法直接执行所以如果你用了集群模式一定要先确认你的 Redis 集群是否支持hash tag以及业务是否能接受这种设计。如果微服务规模不大主从模式加哨兵通常更省心。6.3 把存储方案抽象成可插拔的接口不同环境不同需求开发环境用 InMemory测试环境用 Redis生产环境用 Redis 加审计。可以通过一个自定义配置注解或环境变量控制装配哪个实现。以下直接给配置示例oauth2: authorization-store: redis # 可选in-memory、redis然后在配置类里根据这个配置注入不同的 OAuth2AuthorizationService 实现即可。这个抽象层能省下不少切换成本尤其是当你需要让企业客户在本地一键启动时。7. 写在最后实际使用中的体会做完了这套 Redis 存储方案再回头看 Spring Security 7 的 OAuth2 授权码存储其实核心结论就一句话存储介质变了接口语义没变复杂度全部集中在数据模型和序列化上。Spring Authorization Server 把存储层抽象成 OAuth2AuthorizationService已经为分布式场景铺好了路我们要做的只是选对工具、避开序列化和并发这两个最典型的坑。我个人的体会是方案写完不是终点线上跑一段时间才能真正检验它。比如我一开始用的是纯 JDK 序列化后来发现团队运维查数据时看到二进制就懵后面才逐步调整。如果你也想做类似改造不建议一步到位上最复杂的方案先把 JdkSerializationRedisSerializer 跑通验证授权码流程完整再考虑可读性、安全性、集群兼容这些进阶问题。最后分享一个实用小技巧在 save 方法里加一个对 authorization.getId() 的日志输出配合 Redis 的 MONITOR 命令调试授权码生命周期会非常直观。这个习惯帮我省下了不知道多少排查时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑