4. 缓存策略设计原则:缓存穿透、缓存击穿、缓存雪崩的定义及在令牌场景下的应对方案

在 OAuth2.0 令牌缓存系统中,缓存失效或异常访问会直接导致认证服务压力陡增,甚至引发级联故障。本章深入剖析三种经典缓存异常——缓存穿透、缓存击穿、缓存雪崩,并给出针对令牌场景的专项应对方案。

4.1 三种缓存异常的定义与对比

异常类型 定义 令牌场景典型表现 影响范围
缓存穿透 查询一个一定不存在的数据,缓存和数据库都查不到,每次请求都穿透到后端存储。 攻击者伪造大量不存在的 access_token 或 refresh_token,绕过缓存直接查询数据库或认证服务器。 单次请求
缓存击穿 一个热点 key 在缓存失效的瞬间,大量并发请求同时涌入后端数据库重建缓存。 某个高频使用的 access_token(如网关公共令牌)在过期瞬间,数千个请求同时去认证中心刷新。 单个热点 key
缓存雪崩 大量缓存 key 在同一时间过期失效,导致所有请求全部落到后端数据库。 令牌缓存统一设置了相同的 TTL(如 3600 秒),整点时刻所有令牌同时失效,认证服务瞬间被击穿。 全局/大面积
核心认知:三种异常的本质区别在于“数据是否存在”和“失效范围”。穿透针对不存在的数据,击穿针对单个热点,雪崩针对批量 key 同时失效。

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 秒)。后续相同请求直接命中空缓存,避免重复穿透。

注意:空对象 TTL 不宜过长,否则会导致合法令牌被误判为不存在。建议空对象 TTL 为正常令牌 TTL 的 1/10 且不超过 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,而是为缓存数据设置一个逻辑过期时间。当读取到逻辑过期的数据时,立即返回旧数据,同时异步发起刷新任务:

  • 优点:请求永远不等待,响应速度稳定。
  • 缺点:短时间内返回的是旧令牌,需业务容忍短暂的不一致。
令牌场景推荐:对于 access_token 这种短生命周期(通常 1~2 小时)且允许分钟级延迟的数据,逻辑过期 + 异步刷新是性价比最高的方案。

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 令牌缓存异常应对全景图

下图展示了三种缓存异常在令牌系统中的位置及对应的防御策略:

令牌缓存异常应对全景图 客户端请求 布隆过滤器(穿透防御) 不存在则直接返回 401 L1 本地缓存(雪崩防御) TTL = Redis TTL × 1/2 + 随机偏移 L2 Redis 缓存 TTL 随机化 + 空对象缓存 互斥锁 / 逻辑过期(击穿防御) 只允许一个线程回源重建缓存 认证中心(回源) 防御策略说明 • 穿透:布隆过滤器 + 空对象 • 击穿:互斥锁 + 逻辑过期 • 雪崩:TTL 随机化 + 多级缓存 令牌场景特点 • 短 TTL(1~2 小时) • 热点 key 集中 • 允许短暂不一致

4.6 方案选型建议

异常类型 首选方案 备选方案 令牌场景适用性
缓存穿透 布隆过滤器 缓存空对象 + 短 TTL 高(推荐两者结合使用)
缓存击穿 逻辑过期 + 异步刷新 互斥锁 高(逻辑过期更适合令牌场景)
缓存雪崩 TTL 随机化 + 多级缓存 缓存预热 + 懒加载 高(必须实施)
最佳实践:在 OAuth2.0 令牌缓存系统中,建议同时部署布隆过滤器(穿透)、逻辑过期 + 异步刷新(击穿)、TTL 随机化 + 本地缓存(雪崩)三层防御,形成纵深防护体系。