4. 缓存穿透:定义、产生原因与解决方案

4.1 定义与问题本质

缓存穿透是指查询一个根本不存在的数据时,缓存层无法命中(因为不存在),请求直接穿透到后端数据库。由于数据库中也不存在该记录,每次查询都会落到数据库上,导致数据库压力骤增,甚至引发雪崩。

核心特征:缓存层与数据库层均无目标数据,每次请求都穿透缓存直达数据库。

4.2 产生原因

原因类别 具体场景 典型示例
恶意攻击 攻击者构造大量不存在的 Key 发起请求 遍历不存在的用户 ID(如 -1、99999999)
业务逻辑缺陷 前端未做参数校验,直接传入非法参数 空字符串、特殊字符、超长 ID
数据误删/未同步 数据库记录已被删除,但缓存未及时清理 商品下架后,客户端仍请求旧 ID
缓存预热遗漏 新上线功能未将热点数据提前加载到缓存 新活动页面未预加载商品详情

4.3 解决方案一:布隆过滤器(Bloom Filter)

布隆过滤器是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它由一个位数组和多个哈希函数组成。

4.3.1 工作原理

  1. 初始化:创建一个长度为 m 的位数组,所有位初始化为 0。
  2. 添加元素:使用 k 个哈希函数对元素计算哈希值,将位数组中对应位置置为 1。
  3. 查询元素:同样使用 k 个哈希函数计算位置,如果所有位置均为 1,则元素可能存在;如果任意一位为 0,则元素一定不存在。
注意:布隆过滤器存在误判率(假阳性),即可能将不存在的元素误判为存在。但不会漏判(假阴性率为 0)。误判率可通过调整位数组长度和哈希函数数量来控制。
布隆过滤器工作流程 请求 Key Hash 1 Hash 2 Hash k 位数组(m 位) 1 0 1 1 0 1 0 0 ... 共 m 位 ✅ 可能存在(放行) ❌ 一定不存在(拦截) 关键特性 一定不存在 → 直接拒绝,保护数据库 | 可能存在 → 放行到缓存/数据库查询

4.3.2 布隆过滤器 vs 传统缓存

对比维度 布隆过滤器 传统缓存(如 Redis String)
空间占用 极低(位数组,每个元素仅占 1 bit) 较高(存储完整 Key-Value)
查询速度 O(k),k 为哈希函数个数 O(1) 哈希表查找
误判率 有(假阳性) 无
删除支持 不支持(标准版) 支持
适用场景 防止缓存穿透、爬虫去重 常规缓存读写

4.3.3 Redis 中的布隆过滤器

Redis 4.0+ 通过 RedisBloom 模块支持布隆过滤器。常用命令:

# 创建一个布隆过滤器,预期容量 10000,误判率 0.01
BF.RESERVE user_filter 0.01 10000

# 添加元素
BF.ADD user_filter user:1001

# 批量添加
BF.MADD user_filter user:1002 user:1003 user:1004

# 检查元素是否存在
BF.EXISTS user_filter user:1001    # (integer) 1
BF.EXISTS user_filter user:9999    # (integer) 0

# 批量检查
BF.MEXISTS user_filter user:1001 user:9999

4.4 解决方案二:空值缓存(Cache-Through for Null)

当查询数据库发现数据不存在时,仍然将空值(如 null、空字符串、特殊标记)写入缓存,并设置一个较短的过期时间(通常 30~60 秒)。后续相同请求直接返回空值,避免重复查询数据库。

4.4.1 实现流程

  1. 查询缓存:根据 Key 查询 Redis。
  2. 缓存命中:如果缓存中存在(即使是空值标记),直接返回结果。
  3. 缓存未命中:查询数据库。
  4. 数据库有数据:写入缓存(正常过期时间),返回结果。
  5. 数据库无数据:写入空值标记到缓存(短过期时间,如 30s),返回空。
空值缓存流程 客户端请求 Redis 缓存 数据库 ① 查询 Key ② 未命中 查询结果 有数据? 写入正常值 ③ 有数据 写入空值标记 ④ 无数据 ⑤ 返回结果 空值缓存过期时间建议 30~60 秒,防止恶意 Key 长期占用缓存空间

4.4.2 代码示例

public String getProduct(String productId) {
    // 1. 查询缓存
    String cacheValue = redis.get("product:" + productId);
    if (cacheValue != null) {
        // 2. 命中缓存(包括空值标记)
        if ("NULL_MARK".equals(cacheValue)) {
            return null; // 直接返回空
        }
        return cacheValue;
    }

    // 3. 缓存未命中,查询数据库
    Product product = productDao.selectById(productId);
    if (product != null) {
        // 4. 有数据,写入缓存(过期时间 1 小时)
        redis.setex("product:" + productId, 3600, product.toJson());
        return product.toJson();
    } else {
        // 5. 无数据,写入空值标记(过期时间 30 秒)
        redis.setex("product:" + productId, 30, "NULL_MARK");
        return null;
    }
}

4.5 两种方案对比与选型

对比维度 布隆过滤器 空值缓存
空间开销 极低(位数组) 中等(存储完整 Key)
实现复杂度 较高(需引入模块/算法) 低(简单 if-else)
误判风险 有(假阳性) 无
恶意攻击防护 优秀(直接拦截非法 Key) 良好(但会占用缓存空间)
数据一致性 需考虑布隆过滤器更新 空值过期后自动恢复
推荐场景 高并发、Key 空间大、恶意攻击频繁 中小规模、快速实现、Key 数量可控
最佳实践:大型系统通常组合使用两种方案——布隆过滤器作为第一道防线拦截大部分非法 Key,空值缓存作为兜底策略处理少量漏网之鱼。