1. OAuth2令牌存储:为什么它是认证授权体系的命门
1.1 先搞清楚令牌的完整生命周期
做过微服务认证授权的同学都知道,OAuth2里最核心的东西不是那套授权流程,而是流程结束后拿到的那串token。很多新手第一次接触"令牌存储"这个概念时,第一反应是:token不就放Redis里设个过期时间吗?能有多复杂。
我先把这个概念拆开。OAuth2体系里有两类令牌,一类是access_token,它是访问资源的凭证,有效期很短,一般是几分钟到几小时;另一类是refresh_token,用来在access_token过期后换新token,有效期通常是几天甚至更长。access_token必须能快速校验,refresh_token必须能被安全吊销,这两件事都和存储方案强相关。
有状态令牌和无状态令牌是两条完全不同的技术路线。有状态方案把token对应的用户信息、授权范围、客户端信息存在某个存储里,校验token时去查存储;无状态方案把信息全部塞进token本身(比如JWT),校验时本地验签就行。两条路线各有适用范围,Spring Cloud Security这个生态里两条路线都支持,这也是它灵活但也容易让人懵的原因。
1.2 存储方案决定整个认证链路的可用性边界
我在实际项目中见过不少"看起来跑得挺正常,一重启就全站要重新登录"的案例,根因几乎都出在令牌存储上。存储方案不是简单的数据放哪的问题,它决定了系统的可用性边界。
拿个生活场景类比。你把token想象成电影院的票据:有状态存储就是电影院在每张票上做进场标记、出场核销,你想查这张票什么时候用的、在哪个厅用的,随便查;无状态JWT就是只看票面时间,验票员验证签名和有效期就放行,但如果有人拿复印件进来,你也很难在检票口就把人拦下。
内存存储是最典型的"重启即废"方案,多实例部署时更是一场灾难——用户可能第一次请求打到实例A,第二次请求被负载均衡转发到实例B,如果实例之间不共享存储,B根本不知道这个token是谁发出去的。所以到底是选JDBC持久化、Redis共享存储,还是JWT无状态化,本质上是在一致性、可用性、扩展性和运维成本之间做取舍。
2. 四种主流令牌存储方案横向拆解
2.1 InMemoryTokenStore:调试阶段的好帮手,生产环境的定时炸弹
Spring Cloud生态早期接触OAuth2,很多人第一个见到的就是InMemoryTokenStore。配置最简单,不需要任何外部依赖,token信息保存在当前进程的内存ConcurrentHashMap里。
它的优点非常明显:本地开发调试时零成本启动,跟踪token变化特别直观。但缺点更致命:一是内存存储无法跨进程共享,微服务多实例部署时会直接崩溃;二是应用重启后所有token消失,所有用户都要重新登录;三是内存容量有限,token量大了还可能把JVM堆撑爆。
如果你在测试环境用这个没问题,但生产环境千万要警惕。我在一个早期项目里就遇到过测试环境一直用内存存储没暴露问题,一上生产做了双实例部署,用户访问时好时坏,登录状态像抽风一样闪断,排查了大半天才定位到是token存储没有共享。所以这里有个经验:内存存储只适合单机开发调试,上线前必须替换成共享存储方案。
2.2 JdbcTokenStore:中规中矩但足够可靠的持久化方案
JdbcTokenStore是把token对应记录写入数据库,依赖oauth_access_token、oauth_refresh_token这两张核心表。它的最大优势是持久化,服务重启不影响已有token,数据库本身就是天然的共享存储,多实例部署完全没有问题。
这个方案的劣势也不难猜到:每次校验token都要执行一次数据库查询,高并发场景下数据库压力会变大。而且token过期后表中的记录不会自动清除,需要定期做任务清理,否则表会越滚越大。
如果并发量不是特别夸张(比如单服务几千QPS以内),JDBC存储其实是性价比很高的一种选择。Spring Authorization Server官方默认提供的JdbcOAuth2AuthorizationService走的就是这条路线。它不需要额外引入Redis,很多传统企业级项目选它就是为了少依赖一个中间件。表结构也相对固定,官方脚本在GitHub上都有,初始化库表后直接就能用。
2.3 RedisTokenStore:分布式环境下的主流选择
RedisTokenStore是目前大多数互联网项目最常用的令牌存储方案。它把access_token、refresh_token和对应的客户端ID、用户信息都存在Redis里,天然支持分布式共享。
优势是性能比JDBC高一大截,Redis的读写延迟通常在毫秒级,而且可以设置天然过期时间,token过期后Redis自动清除,省去了人工清理的麻烦。Redis的事务和原子操作也能帮我们解决一些并发场景下的状态一致性问题。
不过Redis方案有它自己的坑。最常见的坑是序列化问题——默认的JdkSerializationRedisSerializer序列化出来的数据可读性差,而且要求所有存储对象实现Serializable接口,稍不注意就抛序列化异常。更隐蔽的问题是token的key设计,如果多个服务共用一个Redis集群,key前缀没规划好,很容易和其他业务数据冲突。我后面会在踩坑实录里专门展开。
2.4 JwtTokenStore:把存储逻辑彻底干掉的无状态路线
JwtTokenStore严格来说不叫"存储",它压根不存token。它使用JWT作为token载体,通过RSA或HMAC签名保证令牌不可篡改,校验时只做验签和有效期检查,不需要访问数据库或Redis。
这种方式最大的优点是服务天然无状态,网关和资源服务器都能本地校验,扩展性极好,非常适合大规模分布式系统。但无状态不等于零成本,它有一个很难绕过的痛点——吊销问题。用户退出登录、管理员禁用账号、token被盗,在这些场景下你是没法让一个已经签发出去的JWT立刻失效的,只能依赖token自然过期。
生产上常用的折中方案是"JWT + Redis黑名单":正常情况本地验签,遇到登出就把token的jti(JWT唯一标识)写进Redis黑名单,过期时间设为JWT的剩余有效期。这样既保留了无状态校验的高性能,又解决了吊销难题。我自己的项目最后就是这么干的,等token存储的改造成本降下来之后,这套路相当稳。
2.5 四种方案速查对比
| 维度 | InMemoryTokenStore | JdbcTokenStore | RedisTokenStore | JwtTokenStore |
|---|---|---|---|---|
| 存储位置 | 进程内存 | 关系型数据库 | Redis | token自身 |
| 重启影响 | 全部token失效 | 无影响 | 无影响 | 无影响 |
| 分布式支持 | 不支持 | 支持 | 支持 | 天然支持 |
| 主动吊销 | 支持 | 支持 | 支持 | 较难,需配合黑名单 |
| 校验性能 | 最快 | 慢 | 快 | 最快 |
| 外部依赖 | 无 | 数据库 | Redis | 无(但需要密钥) |
| 适用场景 | 本地调试 | 中小规模传统项目 | 互联网分布式项目 | 高性能、高扩展场景 |
3. Spring Cloud Security中OAuth2令牌存储实操
3.1 老版本Spring Cloud OAuth2接入Redis令牌存储
如果你维护的项目还在用spring-security-oauth2那一套(也就是老的@EnableAuthorizationServer注解体系),接入Redis令牌存储其实就是往配置中心添加两个Bean的事。核心类是RedisTokenStore,它接收一个RedisConnectionFactory。
@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Resource private RedisConnectionFactory redisConnectionFactory; @Resource private AuthenticationManager authenticationManager; @Bean public TokenStore tokenStore() { return new RedisTokenStore(redisConnectionFactory); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.authenticationManager(authenticationManager) .tokenStore(tokenStore()); } }这里有个容易被忽略的细节:RedisTokenStore默认给token生成的Redis key前缀是access:和refresh:,如果你多个应用共用一个Redis,这些裸前缀很容易和其他key撞车。更稳的做法是在构造RedisTokenStore时指定一个唯一前缀:
@Bean public TokenStore tokenStore() { RedisTokenStore tokenStore = new RedisTokenStore(redisConnectionFactory); tokenStore.setPrefix("myapp_oauth:"); return tokenStore; }我强烈建议在项目里加上这个前缀配置。之前我接手过一个系统,登录token和业务缓存用了同一个Redis实例,业务方图省事给缓存key起名也叫access:开头,结果业务缓存覆盖了token,用户莫名奇妙的反复掉线,排查起来费了很大劲。
3.2 新版本Spring Authorization Server + JDBC存储
Spring Security 5.7之后,Spring官方把授权服务器的能力从Spring Cloud生态拆出来,演进成了独立的Spring Authorization Server项目,很多老API都被重写了。在新体系里,存储令牌的核心接口叫OAuth2AuthorizationService,官方提供了两个内置实现:InMemoryOAuth2AuthorizationService和JdbcOAuth2AuthorizationService。
JDBC存储的配置如下,需要引入JDBC依赖并准备好oauth2相关的授权记录表结构:
@Configuration public class AuthorizationServerConfig { @Bean @Primary public JdbcOAuth2AuthorizationService authorizationService( JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { JdbcOAuth2AuthorizationService service = new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository); service.setRowMapper(new CustomOAuth2AuthorizationRowMapper(registeredClientRepository)); return service; } }需要重点说明的是,Spring Authorization Server在存储层面不光要存access_token和refresh_token,还要存授权码(authorization code)。授权码是一次性的、短期的临时凭证,它同样由OAuth2AuthorizationService管理。很多人配置完token存储发现授权流程还是报错,大概率就是没意识到授权码也需要持久化或者共享存储。
表结构方面,官方给了一个通用SQL脚本,关键表有oauth2_authorization、oauth2_authorization_consent。我在实际项目中见过不少团队图省事直接拿网上野脚本建表,导致字段对不上,启动就报索引错误。这里建议大家用Spring Authorization Server官方仓库里那个schema.sql,它是经过大量生产项目验证的。
3.3 Spring Authorization Server如何扩展Redis存储
说完官方默认,有个现实问题必须面对:Spring Authorization Server官方没有内置RedisOAuth2AuthorizationService。很多团队想用Redis做存储,又不想自己造轮子,这里有两个合理的做法。
第一个做法是仿照JdbcOAuth2AuthorizationService的存储结构,用Redis的Hash类型封装一个OAuth2AuthorizationService实现。核心思路是:用授权的id作为key,把authorization对象序列化后整体写入Redis Hash。校验时可以按id、token、clientId + username等维度查数据。
@Component public class RedisOAuth2AuthorizationService implements OAuth2AuthorizationService { private static final String KEY_PREFIX = "oauth2:authorization:"; private final StringRedisTemplate redisTemplate; public RedisOAuth2AuthorizationService(StringRedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } @Override public void save(OAuth2Authorization authorization) { String key = KEY_PREFIX + authorization.getId(); redisTemplate.opsForHash().putAll(key, serializeAuthorization(authorization)); redisTemplate.expire(key, Duration.ofHours(2)); } @Override public void remove(OAuth2Authorization authorization) { redisTemplate.delete(KEY_PREFIX + authorization.getId()); } @Override public OAuth2Authorization findById(String id) { Map<Object, Object> entries = redisTemplate.opsForHash().entries(KEY_PREFIX + id); return deserializeAuthorization(entries); } }这个做法有个核心难点:OAuth2Authorization对象里包含token元数据和属性信息,反序列化时很容易因为类型转换失败报错。我建议序列化时统一使用JSON格式,并且把token元数据和属性分开存储,避免把整个对象一股脑塞进去。
第二个做法更简单,本质上绕开了自研存储的复杂度:access_token仍然用JWT无状态化,授权码和refresh_token放Redis。这种混合方案在我最近的项目里用得很顺,既享受了JWT快速校验的好处,又通过Redis保留了refresh_token的吊销能力。如果你打算从零搭一个新的认证中心,我会优先推荐这个混合方案,它把存储压力降到最低,同时又没有丧失可操作性。
3.4 客户端注册信息和授权记录的表结构
提到令牌存储,顺带讲一下关联的表。令牌不能凭空存在,令牌对应的client_id、scope、redirect_uri都是客户端注册时登记的。在Spring Authorization Server的JDBC方案里,客户端信息存放在oauth2_registered_client表,授权同意记录在oauth2_authorization_consent表。
我见过不少团队只关注token怎么存,忽略了对客户端注册信息的理解,出问题没有头绪。举个实际例子:前端应用配置的redirect_uri和白名单里登记的地址差了一个斜杠,授权时永远报redirect_uri_mismatch,查了半天最后才发现是数据库里存的URI带了结尾斜杠。这种问题不是单独的存储配置能解决的,需要你把注册客户端和授权记录当成一个整体来看待。
对于老版Spring Cloud OAuth2,JDBC存储还需要oauth_client_details表,里面存的是客户端凭证、授权类型和回调地址。迁移到Spring Authorization Server时,这些信息要转换到oauth2_registered_client表,字段类型更规范化,还增加了client_settings和token_settings的JSON字段,用来控制访问令牌有效期、是否需要复用refresh_token等等。
4. 常见问题与排查技巧实录
4.1 Redis的key撞车和序列化异常
Redis令牌存储最常见的故障就是key冲突和序列化问题,这两种问题症状极其相似,都是用户登录后一会儿正常一会儿异常,或者直接报反序列化失败。
先说key冲突。多个微服务共用一个Redis实例时,如果两个服务都用了RedisTokenStore且都没设置前缀,那么A服务签发的access:key和B服务签发的access:key会相互覆盖。后果就是用户在A服务登录后,token信息被B服务其他会话覆盖,A服务拿token去查时发现查无此token,直接判定未授权。排查方法很简单:登录前和登录后去Redis里执行KEYS access:*,看里面存的token值和你预期的业务id对不对得上。
序列化问题则更隐蔽。RedisTokenStore底层把OAuth2AccessToken对象放进Redis时,默认用的是JDK序列化,如果token里塞了自定义的UserDetail对象,而这个对象没实现Serializable接口,存的时候不报、取的时候反序列化就会抛异常。我遇到过一个项目,用户在登录时一切正常,但token一过期、refresh_token一刷新,服务直接500,日志里全是ClassNotFoundException。解决方案是把RedisTemplate的序列化器切换成Jackson或者Fastjson,同时确保塞进token的对象结构足够简单,不要放一堆互相引用的复杂模型。
4.2 并发刷新令牌时的覆盖问题
这是一个很经典但又容易被忽略的并发竞态问题。场景是这样的:access_token过期后,客户端拿着refresh_token同时发起两个并发刷新请求,负载均衡把它们打到两个服务实例上,两个请求都去拿refresh_token换新的access_token。
如果不加控制,两个请求可能先后读取同一个refresh_token记录,然后各自生成一个全新的token写入存储。前一个写入的token还没来得及被使用,后一个就把它的记录覆盖了。表现就是:刷新接口本身没报错,但刷新完紧接着调业务接口却提示token无效。
解决思路有两个方向。一是在存储层加锁,比如RedisTokenStore的场景可以用分布式锁保证同一用户同一时间只有一个刷新请求在写;二是直接用Spring Authorization Server提供的OAuth2Authorization里的状态字段做乐观锁控制,写入前比对token状态,状态异常就直接拒绝本次刷新。
我在实际项目里的处理是:刷新接口入口加了一个针对userId+clientId的分布式锁,同时把token的授权状态改成"不可重复使用"。核心点是刷新成功后旧的refresh_token必须立即作废,而不是等它自然过期。这个逻辑看起来简单,但很多人漏了作废旧token这一步,导致同一refresh_token能反复换token,泄漏出去后完全不受控。
4.3 多实例部署时登录态不一致
这个问题在从单机升级到多实例时一定会碰到。前面说的内存存储会导致登录态间歇性丢失,但如果你已经用了Redis,还是有可能会遇到登录态不一致,因为问题不一定在存储本身。
我排查过一个案例:网关层做了本地缓存,把token校验结果缓存在了网关进程内,缓存时间设置的20分钟。结果用户改密码被强制下线后,旧token在网关的本地缓存里依然有效,资源服务放行了不该放行的请求。这种问题的本质是:你把存储层迁到了Redis,但业务链路中仍然有"局部状态",而这个局部状态没有失效机制。
要彻底避免这类问题,要么统一在Redis里做缓存并设置短过期时间,要么引入消息通知机制让网关主动失效相关token。最省心的做法是网关侧不做token校验缓存,直接把请求透传到认证中心校验,代价是多一次网络调用,但对一致性要求高的场景值得。
4.4 TokenStore和密钥冲突的隐秘问题
最后分享一个非常容易忽略的问题:JWT方案的密钥和存储方案的联动。有些项目图省事,用JwtTokenStore生成JWT时,直接把签名密钥写死在配置文件里,后来在配置中心改了密钥,重启了授权服务。
结果是什么呢?所有用户全部下线,因为旧token是用旧密钥签发的,新验签逻辑用新密钥验旧签名,必然失败。如果授权服务和资源服务分开部署,资源服务也要在启动时加载同一份公钥,资源服务没有及时更新,就会一直校验失败。
这提醒我两点。第一,签名密钥一旦用起来,就不要随便改,要改就必须设计平滑迁移方案,比如密钥带版本号,解析token时同时支持新旧两把密钥,等旧token全部过期后再切走。第二,JWT方案中资源服务无法做到"零配置",它至少要知道验签公钥,所以密钥分发和更新也必须纳入运维自动化范畴。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查切入点 | 推荐解法 |
|---|---|---|---|
| 登录后频繁掉线 | 多实例未共享存储 | 各实例token存储类型是否一致 | 统一改用Redis/JDBC |
| Redis中token被覆盖 | key前缀冲突 | 执行 KEYS 查看同名key | 给TokenStore设置唯一前缀 |
| 刷新token时报反序列化错误 | 自定义对象未实现序列化 | 查异常栈中的ClassNotFoundException | 统一JSON序列化 |
| 刷新后新token立即失效 | 并发写覆盖 | 检查日志是多个并发刷新 | 加分布式锁 + 旧token作废 |
| 配置中心改密钥后全员下线 | JWT密钥不一致 | 对比新旧密钥签名结果 | 密钥带版本号平滑迁移 |
| 授权码重定向报redirect_uri_mismatch | 客户端注册URI不精确 | 比对数据库UR字段 | 规范注册客户端信息 |
5. 聊聊我自己做令牌存储改造的真实体会
讲完技术细节,最后聊聊我的选型和调优经验。如果你问我现在再启动一个新项目,令牌存储方案怎么选,我会说:小规模系统直接用JDBC存储,省一个中间件,运维压力小,token量控制在百万级以内完全没问题;中等规模以上、对性能有要求的系统,直接上JWT + Redis黑名单的混合方案,或者做Redis的OAuth2AuthorizationService自研扩展;不要迷信"JWT就是最好的",无状态在吊销场景下的短板必须用黑名单机制来补,而黑名单本质上还是存储。
还有一个建议,无论选哪种存储方案,token的有效期、刷新策略、清理策略都要在设计阶段定好。访问令牌给短一点(建议30分钟到2小时),刷新令牌给长一点(建议7到30天),但要在每次刷新时检查设备的活跃状态,确保刷新令牌不会无限期续命。Redis方案中给key设置好过期时间,JDBC方案中写一个基于定时任务的清理job,这些都属于"上线前不起眼、上线后省大事"的工作。
我在前一阵做的那个项目里,最值得的一步就是把访问令牌彻底换成JWT、只把刷新令牌和黑名单留在Redis。改造之后,网关层校验不再需要查询任何外部存储,压测时TPS比原先的有状态方案提高了接近一倍,而登出和强制下线这些需要"立即失效"的能力都保留着。踩坑多了以后你会发现,令牌存储这事没有什么银弹,把有状态和无状态的各自长处拼在一起,往往才是线上最稳的组合。