4. 缓存穿透解决方案(二):缓存空对象策略、TTL设置、优缺点对比

缓存空对象(Cache-Through for Null / Null Object Pattern)是应对缓存穿透最直接、最易落地的策略。其核心思想是:当数据库查询结果为空时,仍然将这个“空”结果(如 null、空集合、特定占位符)写入缓存,并设置一个较短的 TTL。这样,后续针对同一 Key 的请求将直接命中缓存并返回空值,从而避免对后端数据库的重复穿透查询。

4.1 缓存空对象策略的工作原理

该策略在标准缓存旁路(Cache-Aside)模式的基础上,增加了一个针对空结果的特殊处理分支。其完整流程如下图所示:

缓存空对象策略工作流程 客户端请求 Key 查询缓存 缓存命中(含空值) 缓存未命中 查询数据库 结果为空? 缓存空对象 + TTL 缓存真实数据 返回空值 返回数据给客户端
关键决策点: 在“结果为空?”分支中,无论数据库返回的是空集合、null 还是业务意义上的“无数据”,都应统一视为“空对象”进行缓存,而不是直接返回 null 给客户端。

4.2 TTL(生存时间)设置策略

空对象缓存的 TTL 设置是决定该策略成败的核心参数。设置过短会导致频繁穿透,设置过长则可能引发数据不一致。以下是推荐的 TTL 设置原则:

场景 推荐 TTL 范围 说明
高频访问的冷门 Key 30s - 60s 短时间内大量请求同一不存在 Key,短 TTL 可快速释放缓存空间,避免空对象堆积。
低频访问的随机 Key 60s - 120s 攻击者使用随机 Key 穿透时,中等 TTL 可有效拦截重复请求,同时避免长期占用内存。
业务数据更新不频繁 5min - 10min 如果底层数据极少变化(如配置表),可适当延长 TTL 以减少数据库查询压力。
数据一致性要求极高 10s - 30s 当空对象对应的真实数据可能被写入时,短 TTL 能更快地让缓存失效,让新数据有机会被加载。
注意: 不要对所有空对象使用统一的固定 TTL。建议根据 Key 的命名空间、业务类型或访问频率进行差异化配置。例如,对用户维度的空对象设置 60s,对商品维度的空对象设置 300s。

4.3 优缺点对比

缓存空对象策略并非银弹,它与布隆过滤器等其他方案相比,有其独特的适用场景和局限性。以下从多个维度进行详细对比:

对比维度 缓存空对象策略 布隆过滤器策略
实现复杂度 低。只需在现有缓存逻辑中增加一个空值判断和写入分支,代码侵入性小。 中高。需要引入布隆过滤器数据结构,并维护其与真实数据集的同步。
内存占用 较高。每个空对象都会占用一个缓存条目,当攻击 Key 数量巨大时,可能导致内存暴涨。 极低。使用位数组存储,占用空间与数据量呈线性关系,且远小于实际数据存储。
对数据库的保护效果 好。能拦截对同一空 Key 的重复穿透,但对首次穿透无法避免。 极好。在请求到达数据库之前即可拦截绝大多数不存在 Key 的查询。
数据一致性 存在短暂不一致窗口。在 TTL 内,真实数据被写入后,缓存仍返回空值。 无不一致问题。布隆过滤器只判断“可能存在”,不缓存具体值,数据写入后立即生效。
应对随机 Key 攻击 较弱。攻击者每次使用不同 Key,空对象缓存无法命中,仍会穿透到数据库。 强。布隆过滤器能拦截几乎所有随机 Key,除非发生哈希碰撞误判。
运维成本 低。无需额外组件,依赖现有缓存系统即可。 中。需要维护布隆过滤器的初始化、更新和定期重建。
适用场景 Key 空间有限、重复查询多、对一致性要求不苛刻的场景。 Key 空间极大、攻击 Key 随机性强、对内存敏感的场景。

4.4 代码实现示例

以下是一个使用 Java 和 Redis 实现缓存空对象策略的典型代码片段,展示了核心逻辑:

public Object getProduct(String productId) {
    // 1. 尝试从缓存获取
    Object cacheValue = redisTemplate.opsForValue().get("product:" + productId);
    
    // 2. 缓存命中
    if (cacheValue != null) {
        // 判断是否是缓存的空对象
        if (NULL_PLACEHOLDER.equals(cacheValue)) {
            return null; // 返回空,避免业务层处理占位符
        }
        return cacheValue;
    }
    
    // 3. 缓存未命中,查询数据库
    Object dbValue = productDao.selectById(productId);
    
    // 4. 根据数据库结果决定缓存内容
    if (dbValue == null) {
        // 缓存空对象,设置较短 TTL(如 60 秒)
        redisTemplate.opsForValue().set(
            "product:" + productId, 
            NULL_PLACEHOLDER, 
            60, 
            TimeUnit.SECONDS
        );
        return null;
    } else {
        // 缓存真实数据,设置正常 TTL(如 1 小时)
        redisTemplate.opsForValue().set(
            "product:" + productId, 
            dbValue, 
            3600, 
            TimeUnit.SECONDS
        );
        return dbValue;
    }
}
最佳实践: 使用一个特殊的占位符对象(如 NULL_PLACEHOLDER)来区分“缓存未命中”和“缓存了空值”。避免直接缓存 null,因为大多数缓存框架无法区分“Key 不存在”和“Key 对应的值为 null”。

4.5 内存保护与淘汰策略

当大量空对象被缓存时,内存压力会显著增加。为了应对这种情况,需要结合缓存系统的内存淘汰策略进行综合治理:

  • 设置最大内存上限: 为 Redis 等缓存组件配置 maxmemory 参数,防止无限制增长。
  • 选择合适的淘汰策略: 推荐使用 allkeys-lru(最近最少使用)或 volatile-lru(仅对设置了 TTL 的 Key 进行 LRU 淘汰)。空对象由于 TTL 短、访问频率低,会优先被淘汰。
  • 空对象 Key 命名规范: 在 Key 中加入特定前缀(如 null:),便于监控和批量清理。
  • 异步批量清理: 定期扫描并删除过期或长时间未访问的空对象缓存,释放内存。
监控指标建议: 在 OpenTelemetry 中,建议为缓存空对象策略添加以下自定义指标:cache.null_object.count(当前空对象数量)、cache.null_object.hit_rate(空对象命中率)、cache.null_object.eviction_count(空对象淘汰次数)。这些指标能帮助评估策略的有效性和内存压力。