4. 缓存策略设计原则:缓存穿透、缓存击穿、缓存雪崩的定义及在令牌场景下的应对方案
在 OAuth2.0 令牌缓存系统中,缓存失效或异常访问会直接导致认证服务压力陡增,甚至引发级联故障。本章深入剖析三种经典缓存异常——缓存穿透、缓存击穿、缓存雪崩,并给出针对令牌场景的专项应对方案。
4.1 三种缓存异常的定义与对比
| 异常类型 | 定义 | 令牌场景典型表现 | 影响范围 |
|---|---|---|---|
| 缓存穿透 | 查询一个一定不存在的数据,缓存和数据库都查不到,每次请求都穿透到后端存储。 | 攻击者伪造大量不存在的 access_token 或 refresh_token,绕过缓存直接查询数据库或认证服务器。 | 单次请求 |
| 缓存击穿 | 一个热点 key 在缓存失效的瞬间,大量并发请求同时涌入后端数据库重建缓存。 | 某个高频使用的 access_token(如网关公共令牌)在过期瞬间,数千个请求同时去认证中心刷新。 | 单个热点 key |
| 缓存雪崩 | 大量缓存 key 在同一时间过期失效,导致所有请求全部落到后端数据库。 | 令牌缓存统一设置了相同的 TTL(如 3600 秒),整点时刻所有令牌同时失效,认证服务瞬间被击穿。 | 全局/大面积 |
4.2 缓存穿透:令牌场景的应对方案
令牌缓存穿透通常由恶意扫描或客户端错误引起。攻击者构造随机字符串作为 access_token 请求资源,缓存未命中后直接查询认证中心,导致认证中心负载飙升。
4.2.1 布隆过滤器(Bloom Filter)前置拦截
在缓存层之前部署布隆过滤器,存储所有已签发令牌的哈希指纹。请求到达时先检查布隆过滤器:
- 不存在 → 直接返回 401,不查询缓存和数据库。
- 可能存在 → 放行到缓存层继续查询。
// 伪代码:布隆过滤器拦截穿透请求
public TokenResult getToken(String accessToken) {
// 第一步:布隆过滤器快速判断
if (!bloomFilter.mightContain(accessToken)) {
return TokenResult.notFound("令牌不存在");
}
// 第二步:查询缓存
TokenResult result = cache.get(accessToken);
if (result != null) {
return result;
}
// 第三步:回源到认证中心
result = authServer.verifyToken(accessToken);
if (result.isValid()) {
cache.set(accessToken, result, ttl);
}
return result;
}
4.2.2 缓存空对象 + 短 TTL
对于布隆过滤器误判或未覆盖的场景,当查询到不存在的令牌时,在缓存中写入一个空对象(如 null 或特殊标记),并设置极短的 TTL(如 30~60 秒)。后续相同请求直接命中空缓存,避免重复穿透。
4.3 缓存击穿:令牌场景的应对方案
令牌缓存击穿的核心问题是热点 key 重建竞争。例如,网关用于验证所有请求的“公共访问令牌”在过期瞬间,大量并发请求同时触发令牌刷新。
4.3.1 互斥锁(Mutex Key)
当缓存失效时,只允许一个线程去重建缓存,其他线程等待或降级。在分布式环境下,使用 Redis 的 SETNX 或 Redisson 分布式锁实现:
// 伪代码:互斥锁防止缓存击穿
public TokenResult getTokenWithLock(String key) {
TokenResult result = cache.get(key);
if (result != null) {
return result;
}
// 尝试获取分布式锁,锁的 key 为 "lock:" + 令牌 key
String lockKey = "lock:" + key;
boolean locked = redisLock.tryLock(lockKey, 500, 2000); // 等待 500ms,持有 2s
if (locked) {
try {
// 双重检查:获取锁后再次查询缓存
result = cache.get(key);
if (result != null) {
return result;
}
// 回源认证中心重建缓存
result = authServer.verifyToken(key);
cache.set(key, result, ttl);
return result;
} finally {
redisLock.unlock(lockKey);
}
} else {
// 未获取到锁:等待或返回降级数据
Thread.sleep(100);
return cache.get(key); // 重试一次
}
}
4.3.2 逻辑过期 + 异步刷新
不设置物理 TTL,而是为缓存数据设置一个逻辑过期时间。当读取到逻辑过期的数据时,立即返回旧数据,同时异步发起刷新任务:
- 优点:请求永远不等待,响应速度稳定。
- 缺点:短时间内返回的是旧令牌,需业务容忍短暂的不一致。
4.4 缓存雪崩:令牌场景的应对方案
缓存雪崩的根源是大量 key 同时过期。在令牌系统中,如果所有令牌都使用相同的 TTL(如 3600 秒),整点时刻将出现雪崩效应。
4.4.1 TTL 随机化
为每个令牌的缓存 TTL 增加一个随机偏移量,使过期时间均匀分布:
// 基础 TTL 3600 秒,随机偏移 ±300 秒
int baseTtl = 3600;
int randomOffset = ThreadLocalRandom.current().nextInt(-300, 301);
int finalTtl = baseTtl + randomOffset;
cache.set(tokenKey, tokenValue, finalTtl);
4.4.2 多级缓存架构
引入本地缓存(Caffeine/Guava)作为 L1 缓存,Redis 作为 L2 缓存。即使 Redis 中的大量令牌同时过期,本地缓存仍能承载部分流量:
- L1 本地缓存:TTL 设置为 Redis TTL 的 1/2,且随机化。
- L2 Redis 缓存:TTL 随机化 + 布隆过滤器前置。
- 回源保护:认证中心接口配置限流和熔断。
4.4.3 缓存预热 + 懒加载
在令牌签发时,主动将令牌写入缓存(预热),而不是等待第一次查询时再加载。同时,对于已过期的缓存,采用懒加载 + 互斥锁的方式逐步重建,避免瞬间回源。
4.5 令牌缓存异常应对全景图
下图展示了三种缓存异常在令牌系统中的位置及对应的防御策略:
4.6 方案选型建议
| 异常类型 | 首选方案 | 备选方案 | 令牌场景适用性 |
|---|---|---|---|
| 缓存穿透 | 布隆过滤器 | 缓存空对象 + 短 TTL | 高(推荐两者结合使用) |
| 缓存击穿 | 逻辑过期 + 异步刷新 | 互斥锁 | 高(逻辑过期更适合令牌场景) |
| 缓存雪崩 | TTL 随机化 + 多级缓存 | 缓存预热 + 懒加载 | 高(必须实施) |