咱们先把一个最熟悉的问题摆出来:一个系统一旦用户量大起来,多搞几个授权服务器实例是逃不掉的。可一旦实例变多,令牌怎么发、发完以后另一个实例认不认,就成了让人头疼的事。今天咱们就绕开那些晦涩的架构理论,用大白话把 OAuth 2.0 令牌发放和分布式缓存一致性这摊子事聊透,顺便看看数据库和 Redis 在集群环境下到底怎么选、怎么用。
一、先搞清楚问题出在哪?
1.1 高可用意味着什么
所谓高可用,简单说就是别把命运押在一台机器上。一个授权服务器挂了,整个系统就不能登录、不能换令牌,那肯定不行。于是咱们上负载均衡,后面挂着好几个授权服务器实例,比如实例 A、实例 B、实例 C。用户请求被随机打到任意一个实例上,任何一个实例暂时挂了,其他实例能接着干活。
听起来很舒服,但令牌这块就出问题了。令牌不是一张纸,而是一堆有生命的临时身份凭证。它有过期时间,有绑定的用户信息,有对应的刷新令牌,还可能随时被吊销。这些信息如果只存在某个实例的内存里,其他实例根本看不到。
1.2 令牌发放不是简简单单发一串随机数
咱们常常把“发令牌”想得太轻松,其实一个真正的令牌发放动作至少要包含以下几件事:
- 生成一个唯一且无法被猜到的 token 字符串;
- 确定 token 的过期时间;
- 记录这个 token 属于哪个客户端(client_id);
- 记录是哪个用户拿到了这个 token;
- 存下这个 token 的 scope(权限范围);
- 如果支持刷新令牌,还要把 refresh_token 跟 access_token 关联起来;
- 如果旧令牌要被替代,还要把旧的标记失效。
这些数据一旦散落在多个实例的本地内存里,立刻就会变成一团乱麻。用户拿着 A 颁发的令牌,请求却命中了 B,B 查遍自己的本地内存也没找到这个令牌,只能干巴巴地返回 401。用户一脸懵,开发人员半夜被抓起来查日志,最后发现只是“证书不通用”而已。
1.3 一致性问题怎么冒出来的
一致性问题说白了就是:同一个令牌,在一个地方存在,在另一个地方不存在;或者一个令牌已经被吊销了,但另一个副本还认为它是有效的。
在单机时代,所有状态都在一个进程里,读写天然一致。到了集群环境,各实例除了共享网络和服务,还得共享“记忆”。这种共享记忆如果做不好,就会出现“同一个世界,不同的梦”的诡异现象。
二、缓存里到底要放哪些东西?
2.1 授权码
授权码(authorization code)是 OAuth 2.0 授权码流程里非常短命的凭证,通常几分钟就过期。它用于换取 access_token,必须只能换取一次。在集群环境里,如果授权码只存在本地内存,用户第一步在实例 A 拿到授权码,第二步换令牌时请求被负载均衡到实例 B,B 压根不知道这个授权码,整个流程就断了。所以授权码必须放进所有实例都能共享的存储里。
2.2 access_token
access_token 是真正用来访问资源的凭证。它的有效期一般 1 小时左右。验证 token 时,授权服务器或者资源服务器需要知道这个 token 是否存在、是否过期、属于哪个用户、有什么权限。这部分是缓存一致性最核心的阵地。
2.3 refresh_token
refresh_token 的生命周期长得多,可能几天甚至几个月。它用来在 access_token 过期后换取新 token。它的状态变更很敏感,比如用户退出登录后,refresh_token 必须立即失效。如果集群中一部分实例还没拿到“失效通知”,用户依然能刷新成功,那就出大事了。
2.4 客户端信息和用户会话
这些信息相对静态,变化不频繁,但一旦改动了,比如某个客户端突然被禁用,各实例能不能马上知道呢?同样存在缓存同步的问题,只是没有那么急性。
2.5 哪类一致性最敏感
我个人看法:授权码和 refresh_token 的状态一致性最敏感。授权码是一次性的,用一次就必须删掉;refresh_token 是长期凭证,一旦被撤销就必须全局失效。相比之下,access_token 的短时间不一致窗口还能接受,因为它的有效期短,稍微宽容一点,不会造成特别严重的后果。
三、用数据库存令牌行不行?
3.1 把数据库当成唯一的“共用记忆”
最简单的思路就是:别用本地内存,也别用缓存,直接用数据库。所有授权服务器实例都连同一个数据库,所有的令牌、授权码、refresh_token 都放数据库里。这样天然就一致了,因为大家都从同一块石头里读数据,写数据时数据库自身的事务和约束能帮我们挡住很多混乱。
听起来很笨,但对很多中小型系统来说,这可能是最稳的方案。只要并发量没到夸张的程度,数据库完全可以扛住令牌的存取压力。
3.2 建表示例
咱们用 MySQL 8 来演示。这里先说明一下技术栈:Java 17 + Spring Boot 3 + MySQL 8 + MyBatis-Plus + Spring Data Redis(后面 Redis 章节也会用到)。
-- 技术栈:MySQL 8
CREATE TABLE `oauth_access_token` (
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '物理主键',
`token` VARCHAR(128) NOT NULL COMMENT 'access_token的哈希值/明文',
`client_id` VARCHAR(64) NOT NULL COMMENT '客户端ID',
`user_id` VARCHAR(64) NOT NULL COMMENT '用户ID',
`scope` VARCHAR(512) COMMENT '权限范围',
`expires_at` DATETIME NOT NULL COMMENT '过期时间',
`issued_at` DATETIME NOT NULL COMMENT '签发时间',
`refresh_token` VARCHAR(128) COMMENT '关联的refresh_token',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1有效 0失效',
UNIQUE KEY `uk_token` (`token`),
KEY `idx_user` (`user_id`)
) ENGINE = InnoDB COMMENT ='访问令牌表';
注意 token 列加了唯一约束,这能保证任何时刻同一个 token 字符串在数据库里只会存在一行。多个实例同时插入相同的 token 字符串是不可能的,因为 UUID 生成碰撞概率极低,而且唯一约束会兜底。
3.3 发放令牌代码示例
下面是用 Spring Boot 写的一个简单的令牌发放服务,里面没有偷懒,每一步都加了注释。
// 技术栈:Java 17 + Spring Boot 3 + MyBatis-Plus + MySQL
package com.example.authservice;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* 数据库模式下的令牌发放实现。
* 这种实现的核心理念是:所有状态都落库,用数据库事务做主同步。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DbTokenService {
private final AccessTokenMapper accessTokenMapper;
/**
* 发放 access_token 和 refresh_token。
* 这里使用 Spring 的事务注解,保证所有表操作要么全成功,要么全失败。
*/
@Transactional(rollbackFor = Exception.class)
public TokenPair issueToken(String clientId, String userId, String scope) {
log.info("开始给用户 [{}] 发放令牌,clientId=[{}]", userId, clientId);
// 1. 生成两个随机令牌,UUID 只是演示,生产环境请用 SecureRandom 生成足够长的字符串
String accessToken = UUID.randomUUID().toString().replace("-", "");
String refreshToken = UUID.randomUUID().toString().replace("-", "");
// 2. 拼装实体对象
AccessTokenEntity entity = new AccessTokenEntity();
entity.setToken(accessToken);
entity.setClientId(clientId);
entity.setUserId(userId);
entity.setScope(scope);
entity.setIssuedAt(LocalDateTime.now());
entity.setExpiresAt(LocalDateTime.now().plusHours(1)); // 默认1小时过期
entity.setRefreshToken(refreshToken);
entity.setStatus(1); // 1 表示有效
// 3. 如果这个用户/client 之前已有有效令牌,先把旧的置为失效
int invalidated = accessTokenMapper.invalidateOldTokens(clientId, userId);
log.info("当前已作废旧令牌数量: {}", invalidated);
// 4. 写入新令牌
accessTokenMapper.insert(entity);
log.info("新令牌已写入数据库,id={}", entity.getId());
// 5. 返回给上层
return new TokenPair(accessToken, refreshToken);
}
}
上面的 invalidateOldTokens 其实就是执行一条 UPDATE 语句,它的好处是原子地把旧令牌全部置为失效,然后插入新令牌。整个操作在一个事务里,不会出现“新令牌发了,旧令牌还活着”这种状态。
3.4 数据库方案的优点
- 一致性最强:数据库通过事务和锁保证了所有实例读到的状态都是一样的。
- 运维简单:不用额外学一套缓存集群,MySQL 本身就是基本功。
- 排查方便:有人拿着令牌来问“这令牌到底有没有效?”,直接查库,一查一个准。
- 报表方便:想统计活跃 token 量、用户登录分布,一条 SQL 就出来了。
3.5 数据库方案的缺点
- 性能上限低:每一次令牌校验都是查库,高并发下数据库压力巨大。
- 连接数瓶颈:集群实例多了以后,数据库连接池会撑不住。
- 延迟相对高:网络来回一次还得查一张大表,比内存响应慢不少。
- 数据库根本不是专门干这事的:令牌有天然的过期删除需求,但数据库不擅长自动淘汰旧数据,得靠定时任务清理,又麻烦又容易拖垮生产环境。
四、用 Redis 做缓存,该怎么处理一致性?
4.1 Redis 在集群模式下面临的问题
Redis 比数据库轻快得多,于是很多人直接把令牌全扔进 Redis,这没问题。可 Redis 一旦上了集群(主从或者 Cluster 模式),就会引入复制延迟。想象一下:主节点写入了一个令牌,从节点还没同步完,某个请求正好读到从节点,发现令牌不存在。这就是典型的“主从一致性”问题。
解决思路有两个方向:要么让所有读都走主节点,牺牲一部分 Redis 集群的意义;要么接受短暂的不一致,并用重试或降级处理。对令牌这种敏感数据,我强烈建议至少在访问令牌的读取上,短暂不一致可以忍,但授权码和 refresh_token 的状态不能忍。
4.2 令牌写入的原子性
在 Redis 里,如果只是简单 SET 一个 key,很容易遇到“同时更新”的并发问题。比如两个实例同时给同一个用户刷新令牌,都去删旧令牌、写新令牌,最后可能旧令牌没删干净,或者新令牌被覆盖。为了避免这种问题,咱们可以使用 Lua 脚本把“检查 + 删除 + 写入”这几个步骤打包成一个原子操作。
4.3 Lua 脚本示例
下面示例使用的技术栈是:Java 17 + Spring Boot 3 + Spring Data Redis + Redisson(Lua 脚本通过 DefaultRedisScript 执行)。
// 技术栈:Java 17 + Spring Boot 3 + Redisson + Lua
package com.example.authservice;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
/**
* Redis 模式下的令牌发放实现。
* 利用 Lua 脚本确保多个操作是原子的,避免两个实例互相踩踏。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RedisTokenService {
private final StringRedisTemplate redisTemplate;
// 这个 Lua 脚本接收三个参数:
// KEYS[1] -> 旧 token 对应key(如果没有可传空字符串)
// ARGV[1] -> 新 token 值
// ARGV[2] -> 新 token 过期秒数
// ARGV[3] -> 旧 token 对应的 refresh_token 关联key
private static final String ISSUE_TOKEN_LUA =
"if #KEYS[1] > 0 and ARGV[3] ~= '' then " + // 如果传入旧key
" redis.call('DEL', KEYS[1]); " + // 先删旧token
"end; " +
"local newKey = 'oauth:token:' .. ARGV[1]; " + // 拼接新token的redis key
"redis.call('SETEX', newKey, ARGV[2], 'valid'); " + // 写入新token并设置过期时间
"return 1;";
/**
* 使用 Lua 脚本原子地完成“删旧写新”。
*/
public String issueToken(String oldToken) {
String newToken = UUID.randomUUID().toString().replace("-", "");
String oldKey = "oauth:token:" + oldToken;
long ttlSeconds = 3600L; // 默认1小时
log.info("准备原子替换令牌:旧token={}, 新token={}", oldToken.isEmpty() ? "无" : oldToken, newToken);
DefaultRedisScript<Long> script = new DefaultRedisScript<>(ISSUE_TOKEN_LUA, Long.class);
// 注意:Lua 里 KEYS 传入的是 Redis 的 key 列表
List<String> keys = Collections.singletonList(oldKey);
Long result = redisTemplate.execute(script, keys, oldKey, newToken, String.valueOf(ttlSeconds), oldKey);
log.info("Lua 脚本执行完成,返回结果: {}", result);
return newToken;
}
}
这个示例虽然简化了很多,但核心思想很明白:千万别在业务代码里“先查一次 Redis,再删一次,再写一次”,因为这三步被其他请求插进来以后,状态就稀里糊涂了。把整段逻辑塞进 Lua 脚本,让 Redis 单线程来执行,才能保证中间没有人捣乱。
4.4 查询与校验的注意事项
校验令牌的时候,要记得读取近实时数据。最稳妥的办法是直接读 Redis 主节点。如果架构里读写分离了,那么建议设置一个非常短的过期时间,或者在校验失败时再读一次主节点做二次确认。举个例子,用户拿 access_token 来验证,从节点上没查到,我们不能立刻就说“无效”,可以先到主节点查一下,如果主节点有,那说明就是复制延迟,应该放行。
// 技术栈:Java 17 + Spring Boot 3 + StringRedisTemplate
public boolean validateToken(String token, boolean allowSlaveRead) {
String key = "oauth:token:" + token;
if (allowSlaveRead) {
// 从节点优先,加快读取速度
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
return true;
}
}
// 主节点兜底,规避从节点同步延迟问题
Object masterValue = redisTemplate.opsForValue().get(key);
return masterValue != null;
}
这段代码写得直白,实际生产环境还需要把从节点和主节点的连接区分开,但思想就是这样:为了快可以读从库,为了不错杀无辜必须读主库。
4.5 Redis 主从切换带来的脏读
Redis 主从切换的时候,有一个经典的麻烦:主节点还没把某条写操作同步给从节点,突然主节点挂了,哨兵把从节点提升成新主节点。这时候原本刚写入的令牌就丢了,或者旧版本的数据变成“官方数据”。对令牌系统来说,这最致命的影响是:一个已经被吊销的令牌,在切换后可能又被当作有效令牌。
怎么应对?一个是尽量缩短同步延迟,开启 wait 机制或者使用 WAIT 命令等待持久化,但这会损失性能。另一个是给每条令牌数据加上“版本号”或者“签发时间戳”,哪怕从旧数据里读到了一个令牌,发现它的签发时间已经超过允许的时钟偏差,就自动判定无效,这样能兜底。
4.6 Redis 方案优缺点
优点非常明显:速度快,支持自动过期,淘汰策略符合令牌场景,读写能力远高于数据库。缺点也明显:需要额外维护一套 Redis 集群,主从切换的一致性问题不能完全消除,Lua 脚本虽然原子,但分布式环境的脑裂问题依然存在,需要设计补偿。
五、选型建议与应用场景
5.1 什么时候用数据库
如果你所在的团队还在创业初期,用户量几千、几万,授权服务器的并发不高,直接用数据库最省心。数据库方案特别适合需要强一致的场景:比如企业内部授权系统,财务、合规相关的令牌,宁慢勿错。
5.2 什么时候用 Redis
当授权服务器已经横向扩展,每秒令牌校验请求达到几千甚至几万,数据库的查询开始报慢查询,连接数报警,这时候 Redis 就成为了刚需。Redis 适合大多数互联网业务场景,因为在保证性能的前提下,短暂不一致窗口通常可以被业务重试覆盖。
5.3 混合方案:先持久化再缓存
还有一种常见的玩法是数据库和 Redis 一起用。先写数据库,成功之后再写 Redis。读取时优先从 Redis 查,查不到去数据库查,然后回填 Redis。如果 Redis 写失败了,不要影响主流程,只要数据库有数据,顶多是慢一点。这其实就是“变更以数据库为准,缓存只用来加速”。
混合方案还要注意删除 Redis 中旧令牌的时机。尽量别在业务代码里先删缓存再写数据库,而是先写数据库,再删缓存。因为如果先删缓存后写数据库,写库失败会导致缓存里没数据,数据库里也很可能没有,用户彻底没法验证了。先写数据库再删缓存,最多因为删除失败造成缓存里多存一个旧令牌,我们还能靠过期时间补救。
六、实战中的坑和注意事项
6.1 时钟漂移
令牌的签发时间和过期时间依赖服务器的本地时钟。如果集群里两台机器时间差了半分钟,一台机器签发的 token 在另一台机器眼里可能已经过期。所以 NTP 时间同步是基本要求。我见过线上问题,就是因为一台服务器时间走快了,所有令牌一出生就“过期”,折腾了一整夜。
6.2 过期时间设置不合理
access_token 过期时间太短,用户体验差;太长,安全风险大。要结合业务定义,比如 OAuth 2.1 推荐 access_token 不超过 1 小时,refresh_token 可以长一些,但必须可以撤销。
6.3 删除令牌失败
在数据库方案里,把旧令牌置为失效的 UPDATE 语句可能因为锁冲突失败。在 Redis 方案里,DEL 命令也可能因为网络问题执行失败。为了减少影响,在发放新 token 时,不一定要求旧 token 立刻消失。只要校验时能区分“token 属于哪个会话”,并且支持用客户端状态拒绝旧 token,也能接受。更优雅的做法是维护一个 token_version,每次发放新令牌都把旧会话的版本号作废。
6.4 缓存穿透、雪崩
如果恶意攻击者拿着随机的 token 访问授权服务器,Redis 里查不到,这些请求就会全部打到数据库验证,直接打爆数据库。解决办法包括:对不存在的 token 也设置一个短时间的空缓存;或者使用布隆过滤器快速判断 token 是否存在;同时给数据库访问设置限流和熔断。
6.5 分布式锁到底要不要
有些场景确实需要分布式锁,比如同一个用户同时发起两次刷新令牌,两个不同的实例同时处理,可能会生成两个新的 refresh_token,最后只有一个是有效的。用数据库的唯一约束可以兜底,用 Redis 的 SETNX 可以做分布式锁。但锁不是银弹,加锁会牺牲性能和可用性,所以我们优先考虑用 Redis 的 Lua 脚本或者数据库事务来把关键操作原子化,实在需要锁的时候再用。
七、总结
授权服务器的高可用设计,本质上就是“把令牌状态从单机内存搬到共享存储”的过程。数据库方案稳如老狗,适合对一致性极其敏感、并发量不高的场景。Redis 方案快如闪电,但需要认真处理主从同步、原子写入和删除失败这些边边角角。没有绝对完美的方案,只有贴合团队现状和业务场景的选择。最怕的是既想要数据库的强一致,又想要 Redis 的高性能,却把两者的缺点全踩了。
写代码的时候,多想一步:并发场景下,一个请求正在发令牌,另一个请求正在刷新令牌,第三个请求正在校验令牌,它们之间有没有可能互相干扰?如果我们的每一步操作都能像数据库事务那样边界清楚,或者像 Lua 脚本那样原子执行,集群环境下的诡异问题就少了一大半。希望这篇文章能让你在看一个令牌的“生老病死”时,不再觉得云里雾里。
评论
围绕“授权服务器高可用设计中OAuth 2.0令牌发放与分布式缓存一致性问题的深入分析及集群环境下基于数据库或Redis的解决方案”参与讨论