缓存穿透:从原理到实战的完整解决方案

缓存穿透这个问题,我最早是在一个电商大促项目中遇到的。当时系统突然扛不住了,数据库连接池被打满,排查了半天才发现是缓存穿透在作祟。说实话,那会儿我对这个问题的理解还很浅,后来踩了不少坑才真正搞明白。

什么是缓存穿透

简单来说,缓存穿透就是请求的数据在缓存和数据库中都不存在。每次请求都直接打到数据库上,缓存形同虚设。

你想想看,正常的流程是这样的:

  • 请求来了,先查缓存
  • 缓存有数据,直接返回
  • 缓存没有,查数据库
  • 数据库有数据,回写缓存

但穿透的情况呢?缓存没有,数据库也没有。每次请求都直接穿透缓存,打到数据库。如果这种请求量一大,数据库就扛不住了。

核心特征:缓存穿透的本质是「查不到」的数据被高频访问,导致缓存层完全失效。

穿透的原因分析

我在项目中总结了几种常见场景:

  1. 恶意攻击:有人故意请求不存在的商品ID,比如请求ID为-1、99999999这样的数据
  2. 业务误操作:前端传入了错误参数,比如用户ID为空字符串
  3. 数据被删除:原本存在的数据被删除了,但缓存还没来得及更新
  4. 爬虫扫描:爬虫遍历不存在的URL参数

我记得有一次,线上突然出现大量请求ID为0的数据。排查后发现是某个新来的同事在测试环境写了个死循环,把测试数据发到了线上。嗯,这种问题其实挺常见的。

空值缓存方案

这是最直接的解决方案。说白了,就是查不到数据时,也缓存一个空值。

public String getProduct(String productId) {
    // 1. 先查缓存
    String cacheValue = redis.get("product:" + productId);
    if (cacheValue != null) {
        // 缓存命中,直接返回
        return cacheValue;
    }
    
    // 2. 缓存未命中,查数据库
    String dbValue = queryFromDB(productId);
    
    if (dbValue == null) {
        // 3. 数据库也没有,缓存空值
        // 设置较短的过期时间,比如30秒
        redis.set("product:" + productId, "NULL", 30);
        return null;
    }
    
    // 4. 数据库有数据,正常缓存
    redis.set("product:" + productId, dbValue, 3600);
    return dbValue;
}

我的经验:空值缓存的过期时间不要设太长。我一般设30-60秒,太长了会导致正常数据恢复后用户还查不到。另外,空值最好用特殊标记,比如"NULL"或"EMPTY",别用空字符串,容易和真实数据混淆。

布隆过滤器原理与实现

空值缓存虽然简单,但有个问题:如果恶意攻击者每次都用不同的ID来请求,缓存里会塞满空值,内存扛不住。这时候就需要布隆过滤器了。

布隆过滤器的原理,我用大白话解释一下:

  • 它有一个很长的二进制数组,初始全是0
  • 添加数据时,用多个哈希函数计算位置,把对应位设为1
  • 查询数据时,同样计算哈希位置,检查这些位是否都是1
  • 如果都是1,数据可能存在(有误判率)
  • 如果有任何一个位是0,数据一定不存在

说白了,布隆过滤器就是用空间换时间,用一定的误判率换来了极高的查询效率。

关键特性:布隆过滤器说「有」不一定有,但说「没有」一定没有。这个特性正好适合缓存穿透场景——我们只需要过滤掉那些「一定不存在」的请求。

布隆过滤器在Redis中的实战

Redis 4.0之后支持了布隆过滤器插件。我一般用Redisson客户端来操作,代码很简洁:

// 初始化布隆过滤器
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("productBloom");
// 初始化,设置预计元素数量和误判率
// 预计100万条数据,误判率1%
bloomFilter.tryInit(1000000L, 0.01);

// 商品上线时,把ID加入布隆过滤器
public void onProductOnline(String productId) {
    bloomFilter.add(productId);
}

// 查询时,先用布隆过滤器判断
public String getProduct(String productId) {
    // 1. 布隆过滤器判断是否存在
    if (!bloomFilter.contains(productId)) {
        // 一定不存在,直接返回
        return null;
    }
    
    // 2. 查缓存
    String cacheValue = redis.get("product:" + productId);
    if (cacheValue != null) {
        return cacheValue;
    }
    
    // 3. 查数据库
    String dbValue = queryFromDB(productId);
    if (dbValue != null) {
        redis.set("product:" + productId, dbValue, 3600);
    }
    return dbValue;
}

避坑指南:我曾经犯过一个错误——布隆过滤器初始化后忘了更新。商品下架了,但布隆过滤器里还有它的ID,导致请求还是能穿透到缓存层。后来我加了个定时任务,每天凌晨重建布隆过滤器。另外,布隆过滤器不支持删除操作,如果业务上有删除需求,得用Counting Bloom Filter或者定期重建。

缓存穿透的监控与告警

光有解决方案还不够,你得知道什么时候出了问题。我一般会监控这几个指标:

指标 说明 告警阈值
缓存命中率 正常情况应该在90%以上 低于80%告警
空值缓存数量 空值缓存占总缓存的比例 超过10%告警
数据库QPS 数据库每秒查询次数 超过正常值2倍告警
布隆过滤器误判率 实际误判率是否接近预设值 超过预设值2倍告警

我习惯用Prometheus + Grafana来做监控。在代码里埋点,把关键指标暴露出来:

// 缓存穿透监控埋点
public String getProductWithMonitor(String productId) {
    // 记录总请求数
    metrics.counter("cache.request.total").inc();
    
    // 布隆过滤器拦截
    if (!bloomFilter.contains(productId)) {
        metrics.counter("cache.bloom.reject").inc();
        return null;
    }
    
    // 缓存查询
    String cacheValue = redis.get("product:" + productId);
    if (cacheValue != null) {
        if ("NULL".equals(cacheValue)) {
            metrics.counter("cache.hit.null").inc();
        } else {
            metrics.counter("cache.hit.data").inc();
        }
        return cacheValue;
    }
    
    // 数据库查询
    metrics.counter("cache.db.query").inc();
    String dbValue = queryFromDB(productId);
    
    if (dbValue == null) {
        metrics.counter("cache.penetration").inc();
        redis.set("product:" + productId, "NULL", 30);
    }
    
    return dbValue;
}

我的建议:告警别设得太敏感。我刚开始做的时候,把缓存命中率低于95%就告警,结果一天收到几百条告警,运维同事差点把我拉黑。后来改成低于80%才告警,配合空值缓存数量和数据库QPS一起看,效果就好多了。

总结一下,缓存穿透的解决方案其实就三板斧:空值缓存兜底、布隆过滤器拦截、监控告警兜底。三者配合使用,基本能挡住99%的穿透问题。剩下的1%,嗯,那就得看你的业务场景了,比如限流、降级这些手段也得备着。