4. 缓存穿透解决方案(二):缓存空对象策略、TTL设置、优缺点对比
缓存空对象(Cache-Through for Null / Null Object Pattern)是应对缓存穿透最直接、最易落地的策略。其核心思想是:当数据库查询结果为空时,仍然将这个“空”结果(如 null、空集合、特定占位符)写入缓存,并设置一个较短的 TTL。这样,后续针对同一 Key 的请求将直接命中缓存并返回空值,从而避免对后端数据库的重复穿透查询。
4.1 缓存空对象策略的工作原理
该策略在标准缓存旁路(Cache-Aside)模式的基础上,增加了一个针对空结果的特殊处理分支。其完整流程如下图所示:
关键决策点: 在“结果为空?”分支中,无论数据库返回的是空集合、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(空对象淘汰次数)。这些指标能帮助评估策略的有效性和内存压力。