一、什么是缓存穿透?

想象你开了一家超市,有个顾客每天都来问"有没有会飞的鱼罐头",而你的超市根本没卖过这种商品。每次你都要翻遍整个仓库确认,结果白白浪费体力。缓存穿透就像这样:恶意请求不断查询根本不存在的数据,导致系统每次都要去数据库空跑一趟,最终拖垮整个系统。

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

常见于三种场景:

  1. 黑客故意用随机ID发起海量请求
  2. 业务代码误删了缓存但没及时更新
  3. 冷启动时缓存尚未预热

比如这个Java示例(技术栈:SpringBoot + Redis):

// 有问题的查询方法
public Product getProduct(Long id) {
    // 1.先查Redis
    Product product = redisTemplate.opsForValue().get("product:" + id);
    if (product == null) {
        // 2.Redis没有就查数据库(危险操作!)
        product = database.query("SELECT * FROM products WHERE id = " + id);
        // 3.结果存入Redis
        redisTemplate.opsForValue().set("product:" + id, product);
    }
    return product;
}

当恶意请求传入不存在的id(如-1或999999)时,每次都会穿透到数据库。

三、五大解决方案实战

3.1 布隆过滤器(Bloom Filter)

就像超市门口的货物清单,能快速告诉你"这东西我们肯定没有"。

Java实现示例:

// 初始化布隆过滤器(使用Guava库)
BloomFilter<Long> filter = BloomFilter.create(
    Funnels.longFunnel(), 
    1000000,  // 预期数据量
    0.01      // 误判率
);

// 预热数据:把已有商品ID加入过滤器
List<Long> existIds = productDao.getAllIds();
existIds.forEach(filter::put);

// 查询改造
public Product getProduct(Long id) {
    // 先过布隆过滤器
    if (!filter.mightContain(id)) {
        return null; // 肯定不存在
    }
    // ...后续正常流程
}

注意:可能存在1%误判(把存在的判为不存在),但绝不会放行不存在的请求。

3.2 缓存空对象

给不存在的商品也建个"空档案",避免重复查询:

public Product getProduct(Long id) {
    Product product = redisTemplate.opsForValue().get("product:" + id);
    if (product != null) {
        // 特殊标记的空对象
        if (product.getId() == -1) return null; 
        return product;
    }
    
    product = database.query("SELECT * FROM products WHERE id = " + id);
    if (product == null) {
        // 缓存空对象,设置较短过期时间
        Product empty = new Product();
        empty.setId(-1L); // 特殊标记
        redisTemplate.opsForValue().set(
            "product:" + id, 
            empty,
            5, TimeUnit.MINUTES  // 5分钟过期
        );
        return null;
    }
    // ...正常缓存
}

3.3 互斥锁保护数据库

像超市的"正在补货"牌子,防止多人同时查库存:

public Product getProduct(Long id) {
    String lockKey = "lock:product:" + id;
    try {
        // 尝试获取分布式锁(Redis实现)
        boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
        
        if (!locked) {
            Thread.sleep(50);
            return getProduct(id); // 重试
        }
        
        // ...正常查询流程
    } finally {
        redisTemplate.delete(lockKey); // 释放锁
    }
}

3.4 接口层防护

在入口处设置规则:

  • 对id范围校验(如只允许>0的整数)
  • 频率限制(1秒内相同IP最多10次请求)
@RestController
public class ProductController {
    // 使用Spring的@RequestLimit注解
    @RequestLimit(count=10, time=1000) 
    @GetMapping("/product/{id}")
    public Product get(@PathVariable Long id) {
        if (id == null || id <= 0) {
            throw new IllegalArgumentException("非法ID");
        }
        return productService.getProduct(id);
    }
}

3.5 热点数据预加载

提前把可能被频繁查询的数据加载到缓存,比如:

@PostConstruct  // 服务启动时执行
public void preheatCache() {
    List<Long> hotProductIds = Arrays.asList(1001L, 1002L, 1003L);
    hotProductIds.forEach(id -> {
        Product p = database.queryById(id);
        redisTemplate.opsForValue().set("product:" + id, p);
    });
}

四、方案对比与选型建议

方案 优点 缺点 适用场景
布隆过滤器 内存占用极小 有误判率 海量数据且允许误判
缓存空对象 实现简单 可能缓存大量无效数据 数据差异性不大
互斥锁 保证数据库绝对安全 降低并发性能 超高并发场景
接口层防护 从源头拦截 规则可能被绕过 所有系统都应该做
数据预加载 完全避免穿透 需要准确预测热点 已知的热点查询

终极建议

  1. 必做:接口层基础校验 + 缓存空对象
  2. 高并发系统:加布隆过滤器
  3. 秒杀类场景:额外增加互斥锁

五、避坑指南

  1. 空对象缓存时间建议5-30分钟,避免占用太多内存
  2. 布隆过滤器的容量要预留20%以上空间,否则误判率飙升
  3. 分布式锁一定要设置超时时间,防止死锁
  4. 监控Redis的缓存命中率,低于90%就需要优化

记住:没有银弹!根据你的业务特点组合使用这些方案才是王道。