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

3.1 定义与危害

缓存穿透是指客户端请求的数据在缓存中不存在,同时在数据库中也不存在。每次请求都直接穿透缓存层,打到后端数据库,导致缓存完全失效,数据库压力骤增。

核心危害:缓存穿透的本质是“缓存层被绕过”,使得本应被缓存保护的数据库直接暴露在高并发请求下,极易引发数据库连接耗尽、响应超时甚至宕机。
危害维度 具体表现 影响等级
数据库压力 大量无效查询直接访问数据库,QPS 飙升 严重
系统响应 请求因数据库负载过高而超时,用户体验下降 中等
资源浪费 缓存层空转,网络带宽和连接资源被无效请求占用 中等
连锁故障 数据库宕机后,依赖该数据的上游服务级联失败 严重

3.2 产生原因

缓存穿透的产生通常源于以下三种场景:

  1. 恶意攻击或爬虫:攻击者故意构造大量不存在的 Key(如负 ID、超长字符串)发起请求,绕过缓存直接攻击数据库。
  2. 业务逻辑漏洞:查询参数未做合法性校验,导致非法参数(如 id=-1、id=null)进入查询流程。
  3. 数据误删除或未初始化:缓存和数据库中的合法数据被删除后,后续请求无法命中缓存,也无法从数据库获取数据。
典型案例:某电商平台在促销期间,攻击者使用不存在的商品 ID(如 product_id=99999999)循环请求,导致数据库每秒收到数万次无效查询,最终数据库连接池耗尽,正常用户无法访问。

3.3 解决方案

针对缓存穿透,业界有三种主流解决方案,按推荐优先级排列如下:

3.3.1 方案一:参数校验(第一道防线)

在请求入口层对参数进行合法性校验,直接拦截明显不合法的请求。

// 参数校验示例(Java)
public Product getProduct(String productId) {
    // 1. 基础校验:非空、格式
    if (productId == null || productId.trim().isEmpty()) {
        throw new IllegalArgumentException("商品ID不能为空");
    }
    // 2. 业务校验:ID 范围、格式规则
    if (!productId.matches("^[A-Za-z0-9]{8,32}$")) {
        throw new IllegalArgumentException("商品ID格式不合法");
    }
    // 3. 黑名单校验(可选)
    if (isInBlackList(productId)) {
        return null; // 直接返回空,不查询缓存和数据库
    }
    // ... 后续缓存查询逻辑
}
最佳实践:参数校验应放在网关层或 API 入口处,使用白名单模式(只允许合法格式通过),而非黑名单模式。校验规则应包含:非空检查、类型检查、长度限制、格式正则、枚举值校验。

3.3.2 方案二:布隆过滤器(Bloom Filter)

布隆过滤器是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它能够以极小的内存开销,快速过滤掉绝大多数不存在的 Key。

核心原理:使用多个哈希函数将元素映射到一个位数组中的多个位置,并置为 1。查询时,若所有对应位均为 1,则元素“可能存在”;若有任意一位为 0,则元素“一定不存在”。
布隆过滤器工作原理图 布隆过滤器工作原理 请求 Key Hash 1 Hash 2 Hash 3 Hash k 位数组(Bit Array) 0 1 0 1 0 1 0 1 1 0 1 0 1 0 1 0 判断规则:所有哈希映射位均为 1 → 可能存在(有误判率);任意一位为 0 → 一定不存在 误判率可通过调整位数组大小和哈希函数数量控制,通常低于 1%

布隆过滤器在 Redis 中的使用方式:

# Redis 4.0+ 通过 module 加载布隆过滤器
# 1. 加载模块
redis-server --loadmodule /path/to/redisbloom.so

# 2. 初始化过滤器(预计插入 10000 个元素,误判率 1%)
BF.RESERVE product_filter 0.01 10000

# 3. 添加元素(数据预热时执行)
BF.ADD product_filter product_12345
BF.ADD product_filter product_67890

# 4. 查询元素(请求入口处执行)
BF.EXISTS product_filter product_99999  # 返回 0,直接拦截
布隆过滤器 vs 传统 Set:布隆过滤器占用内存极低(通常仅为 Set 的 1/10 ~ 1/100),但存在误判率(会把不存在误判为存在)。对于缓存穿透场景,误判只会导致少量无效请求打到数据库,不会造成系统崩溃,属于可接受的权衡。

3.3.3 方案三:空值缓存(Cache Null Object)

当查询数据库发现数据不存在时,仍然将该 Key 写入缓存,但 Value 设置为一个特殊空值(如 null、""、"NULL"),并设置较短的过期时间(如 30~60 秒)。

// 空值缓存实现示例(Java + Redis)
public Product getProduct(String productId) {
    // 1. 查询缓存
    String cacheValue = redis.get("product:" + productId);
    if (cacheValue != null) {
        // 2. 判断是否为空值标记
        if ("NULL".equals(cacheValue)) {
            return null; // 缓存中标记为空,直接返回
        }
        return JSON.parseObject(cacheValue, Product.class);
    }
    
    // 3. 缓存未命中,查询数据库
    Product product = productDao.selectById(productId);
    
    // 4. 根据数据库结果处理
    if (product == null) {
        // 数据库也不存在:写入空值缓存,TTL 30秒
        redis.setex("product:" + productId, 30, "NULL");
        return null;
    } else {
        // 数据库存在:写入正常缓存,TTL 3600秒
        redis.setex("product:" + productId, 3600, JSON.toJSONString(product));
        return product;
    }
}
注意事项:
  • 空值缓存的 TTL 必须远小于正常缓存,避免长期占用内存。
  • 需要区分“空值标记”和“正常数据”,避免业务逻辑混淆。
  • 如果数据可能被恢复(如管理员后台添加了该商品),需要同步删除空值缓存。

3.4 方案对比与选型

方案 内存开销 实现复杂度 误判风险 适用场景
参数校验 无 低 无 所有系统必须实现的基础防线
布隆过滤器 极低 中 有(可控) 高并发、Key 空间大、可预热的场景
空值缓存 中等 低 无 Key 空间有限、数据变化不频繁的场景
推荐组合策略:参数校验(必选)+ 布隆过滤器(高并发首选)+ 空值缓存(兜底方案)。三层防护叠加,可将缓存穿透风险降至最低。对于极端高并发系统,建议在网关层增加限流和降级措施,作为第四道防线。
缓存穿透三层防护架构 缓存穿透三层防护架构 第一层:参数校验 拦截非法格式/范围 第二层:布隆过滤器 拦截不存在 Key(99%+) 第三层:空值缓存 兜底标记空 Key 后端数据库 最终保护目标 只有通过全部三层防护的请求才会到达数据库,极大降低无效查询压力 建议配合限流(Rate Limiting)和熔断(Circuit Breaker)作为第四层防护