一、什么是缓存穿透?

想象一下,你开了一家超市,每天都有顾客来买东西。为了提高效率,你把热销商品放在门口的货架上(这就是缓存)。但有一天,突然有100个人同时来问一种根本不存在的商品(比如"会飞的洗衣机")。由于货架上没有,每个请求都得跑到仓库翻个底朝天(查询数据库),结果当然是白忙活。这就是缓存穿透——大量请求直接绕过缓存,反复查询数据库中不存在的数据。

在分布式系统中,这个问题会被放大。比如用Redis做缓存的电商平台,如果有人恶意发起大量不存在的商品ID查询,可能导致数据库直接挂掉。

二、为什么会发生缓存穿透?

主要原因有三个:

  1. 恶意攻击:黑客故意构造不存在的关键词发起请求
  2. 业务设计缺陷:比如APP启动时自动查询用户未创建的配置项
  3. 数据自然淘汰:缓存过期瞬间遭遇高并发请求

举个Java+Redis的典型例子:

// 危险代码示例:存在缓存穿透风险
public String getProductInfo(String productId) {
    // 1. 先查Redis
    String cacheKey = "product:" + productId;
    String value = redisTemplate.opsForValue().get(cacheKey);
    if (value != null) {
        return value;
    }
    
    // 2. 查数据库(如果数据不存在,每次请求都会执行这里)
    Product product = productDao.selectById(productId);
    if (product != null) {
        redisTemplate.opsForValue().set(cacheKey, product.toJSON());
        return product.toJSON();
    }
    
    // 3. 数据不存在时返回null
    return null;
}

这段代码的问题在于:当productId不存在时,每次请求都会穿透到数据库。

三、五大解决方案实战

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

布隆过滤器就像个超级高效的"黑名单记事本",它能确定"某个元素绝对不存在"或"可能存在"。

Java+Redisson实现示例:

// 初始化布隆过滤器(需要提前预热数据)
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("product_filter");
bloomFilter.tryInit(100000L, 0.01); // 预期10万数据,误判率1%

// 查询时先检查过滤器
public String getProductInfoSafe(String productId) {
    // 1. 布隆过滤器拦截
    if (!bloomFilter.contains(productId)) {
        return "产品不存在"; // 确定不存在时直接返回
    }
    
    // 2. 正常缓存查询流程...
    // ...(与前面示例相同)
}

注意事项

  • 需要预热所有有效ID到过滤器
  • 1%的误判率意味着可能有1%的合法请求被误拦截

方案2:空值缓存

给不存在的查询结果也设置缓存,就像在超市货架上放个"此商品缺货"的牌子。

public String getProductInfoWithNullCache(String productId) {
    String cacheKey = "product:" + productId;
    String value = redisTemplate.opsForValue().get(cacheKey);
    
    // 特殊标记识别空缓存
    if ("NULL_OBJECT".equals(value)) {
        return null;
    }
    if (value != null) {
        return value;
    }
    
    Product product = productDao.selectById(productId);
    if (product != null) {
        redisTemplate.opsForValue().set(cacheKey, product.toJSON(), 1, TimeUnit.HOURS);
    } else {
        // 设置5分钟的空值缓存
        redisTemplate.opsForValue().set(cacheKey, "NULL_OBJECT", 5, TimeUnit.MINUTES);
    }
    return product != null ? product.toJSON() : null;
}

方案3:互斥锁(Mutex Key)

像卫生间门锁一样,只允许一个人去查数据库,其他人等着用结果。

public String getProductInfoWithLock(String productId) {
    String cacheKey = "product:" + productId;
    String value = redisTemplate.opsForValue().get(cacheKey);
    if (value != null) {
        return value;
    }
    
    // 尝试获取分布式锁
    String lockKey = "lock:" + productId;
    try {
        boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
        if (locked) {
            // 查数据库
            Product product = productDao.selectById(productId);
            if (product != null) {
                redisTemplate.opsForValue().set(cacheKey, product.toJSON(), 1, TimeUnit.HOURS);
            } else {
                redisTemplate.opsForValue().set(cacheKey, "NULL_OBJECT", 5, TimeUnit.MINUTES);
            }
            return product != null ? product.toJSON() : null;
        } else {
            // 等待100ms后重试
            Thread.sleep(100);
            return getProductInfoWithLock(productId);
        }
    } finally {
        redisTemplate.delete(lockKey);
    }
}

方案4:接口层校验

像保安检查身份证一样,先验证请求的合法性。

// 校验productId是否符合规则
private boolean isValidProductId(String productId) {
    // 示例规则:必须是10位数字
    return productId != null && productId.matches("\\d{10}");
}

public String getProductInfoWithValidation(String productId) {
    if (!isValidProductId(productId)) {
        throw new IllegalArgumentException("非法产品ID格式");
    }
    // ...后续正常流程
}

方案5:热点数据预加载

就像提前把双十一爆款都摆到货架上。

@Scheduled(fixedRate = 3600000) // 每小时执行一次
public void preloadHotProducts() {
    List<String> hotProductIds = productDao.selectHotProductIds();
    hotProductIds.forEach(id -> {
        Product product = productDao.selectById(id);
        redisTemplate.opsForValue().set("product:" + id, 
            product.toJSON(), 2, TimeUnit.HOURS);
    });
}

四、方案选型与注意事项

不同场景下的选择

场景 推荐方案 理由
ID规律性强 接口层校验 实现简单成本低
数据量固定 布隆过滤器 内存占用恒定
突发流量大 互斥锁+空缓存 避免雪崩效应
热点数据明显 预加载 提升用户体验

需要警惕的坑

  1. 布隆过滤器误判:需要根据业务容忍度调整参数
  2. 空缓存过期时间:建议设置较短时间(5-30分钟)
  3. 锁超时时间:太短会导致重复查询,太长会影响并发
  4. 缓存污染:恶意构造不同key可能导致缓存被无用数据占满

最佳实践组合

在实际项目中,我们通常会组合使用多种方案:

public String getProductInfoBestPractice(String productId) {
    // 1. 基础校验
    if (!isValidProductId(productId)) return null;
    
    // 2. 布隆过滤器拦截
    if (!bloomFilter.contains(productId)) return null;
    
    // 3. 正常查询流程(含空缓存和锁机制)
    return getProductInfoWithLock(productId);
}

五、总结

解决缓存穿透就像给系统穿上防弹衣,需要根据业务特点选择合适的"防护材料"。布隆过滤器适合防御大规模随机攻击,空值缓存应对临时性穿透,互斥锁解决并发风暴,而参数校验则是性价比最高的第一道防线。

记住没有银弹,最好的方案往往是多种策略的组合。建议在测试环境用JMeter模拟极端场景,观察不同方案的效果。毕竟,没经过压力测试的方案就像没试穿过防弹衣就上战场——你永远不知道它会不会关键时刻掉链子。