一、什么是缓存穿透?

缓存穿透是指查询一个根本不存在的数据,导致请求直接打到数据库上。这种情况通常发生在恶意攻击或者业务逻辑缺陷时,比如有人故意请求数据库中不存在的ID,导致缓存层形同虚设。

举个例子,假设我们有一个电商系统,用户通过商品ID查询商品信息。正常情况下,如果商品存在,我们会把商品信息缓存到Redis中,下次查询时直接从缓存获取。但如果有人故意请求不存在的商品ID(比如-1或者特别大的数字),每次请求都会绕过缓存直接查询数据库,导致数据库压力骤增。

// Java示例:存在缓存穿透风险的代码
public Product getProductById(String productId) {
    // 先查缓存
    Product product = redisTemplate.opsForValue().get("product:" + productId);
    if (product == null) {
        // 缓存没有,查数据库
        product = productDao.findById(productId);
        if (product != null) {
            // 存入缓存
            redisTemplate.opsForValue().set("product:" + productId, product, 1, TimeUnit.HOURS);
        }
    }
    return product;
}

这段代码的问题在于,如果productId不存在,每次请求都会查询数据库,缓存完全没起作用。

二、如何解决缓存穿透?

1. 缓存空对象

最简单的办法是,即使数据库查不到数据,也在缓存中存一个空值(比如NULL或特定标记),这样后续请求就不会直接打到数据库。

// Java示例:缓存空对象解决穿透
public Product getProductById(String productId) {
    // 先查缓存
    Product product = redisTemplate.opsForValue().get("product:" + productId);
    if (product == null) {
        // 缓存没有,查数据库
        product = productDao.findById(productId);
        if (product != null) {
            // 存入缓存
            redisTemplate.opsForValue().set("product:" + productId, product, 1, TimeUnit.HOURS);
        } else {
            // 存入空对象,设置较短的过期时间
            redisTemplate.opsForValue().set("product:" + productId, "NULL", 5, TimeUnit.MINUTES);
        }
    }
    // 如果是空对象标记,返回null
    return "NULL".equals(product) ? null : product;
}

优点:实现简单,能有效减少数据库压力。
缺点:如果恶意请求大量不同的不存在的key,会导致缓存中存储大量无效数据,占用内存。

2. 布隆过滤器(Bloom Filter)

布隆过滤器是一种概率型数据结构,可以高效地判断一个元素是否存在于集合中。它的特点是:

  • 可能存在误判(判断存在时可能实际不存在,但判断不存在时一定不存在)。
  • 占用空间小,适合海量数据场景。

我们可以把所有合法的商品ID预先加载到布隆过滤器中,查询前先检查商品ID是否可能存在,如果不存在就直接返回,避免查询数据库。

// Java示例:使用Guava的布隆过滤器
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

public class ProductService {
    private BloomFilter<String> bloomFilter;

    public ProductService() {
        // 预计元素数量100万,误判率1%
        bloomFilter = BloomFilter.create(Funnels.stringFunnel(), 1000000, 0.01);
        // 初始化时加载所有合法商品ID
        List<String> allProductIds = productDao.getAllProductIds();
        for (String id : allProductIds) {
            bloomFilter.put(id);
        }
    }

    public Product getProductById(String productId) {
        // 先检查布隆过滤器
        if (!bloomFilter.mightContain(productId)) {
            return null; // 肯定不存在,直接返回
        }
        // 后续逻辑和之前一样...
    }
}

优点:内存占用低,适合大规模数据。
缺点:有一定的误判率,且数据更新时需要同步更新布隆过滤器。

三、提高缓存命中率的其他技巧

1. 合理设置缓存过期时间

缓存时间太短会导致频繁回源查询数据库,太长则可能导致数据不一致。我们可以采用:

  • 基础过期时间 + 随机抖动,避免缓存雪崩。
  • 热点数据永不过期,通过后台线程异步更新。
// Java示例:设置缓存过期时间带随机抖动
public void setProductCache(Product product) {
    // 基础过期时间1小时 + 随机0-10分钟,防止同时失效
    long expireTime = 60 + (long) (Math.random() * 10);
    redisTemplate.opsForValue().set(
        "product:" + product.getId(), 
        product, 
        expireTime, 
        TimeUnit.MINUTES
    );
}

2. 多级缓存架构

除了Redis,还可以利用本地缓存(如Caffeine)作为一级缓存,Redis作为二级缓存,进一步减少Redis的压力。

// Java示例:多级缓存(Caffeine + Redis)
public Product getProductById(String productId) {
    // 先查本地缓存
    Product product = caffeineCache.getIfPresent(productId);
    if (product == null) {
        // 本地缓存没有,查Redis
        product = redisTemplate.opsForValue().get("product:" + productId);
        if (product == null) {
            // Redis没有,查数据库...
        } else {
            // 回填本地缓存
            caffeineCache.put(productId, product);
        }
    }
    return product;
}

四、应用场景与总结

适用场景

  1. 高并发查询系统:如电商商品详情、社交动态等。
  2. 防止恶意攻击:比如爬虫频繁请求不存在的资源。

技术对比

方案 优点 缺点
缓存空对象 实现简单 可能缓存大量无效数据
布隆过滤器 内存占用低 有误判,需维护过滤器

注意事项

  1. 空对象缓存的过期时间不宜过长,避免占用内存。
  2. 布隆过滤器需要定期重建,确保数据准确性。
  3. 监控缓存命中率,及时发现异常。

总结

缓存穿透是分布式系统常见问题,合理使用空对象缓存或布隆过滤器可以有效缓解。同时,结合多级缓存、动态过期策略等手段,能进一步提升系统性能。