54、缓存穿透模式:布隆过滤器、空值缓存与热点数据保护

缓存穿透,说白了就是「查啥啥没有,回回都落库」。

我刚开始做高并发系统时,就吃过这个亏。一个简单的商品查询接口,Redis里没数据,结果所有请求全怼到数据库上,直接把MySQL打挂了。后来一查日志,有人恶意刷了一个不存在的商品ID,每秒几万次请求……嗯,从那以后,我再也不敢忽视缓存穿透了。

什么是缓存穿透?

正常流程是这样的:请求来了 → 查缓存 → 有数据直接返回 → 没数据查数据库 → 回写缓存。

但缓存穿透呢?请求查一个缓存里没有、数据库里也不存在的数据。每次请求都绕过缓存,直接打到数据库。如果并发高,数据库瞬间就扛不住了。

核心问题:缓存层形同虚设,数据库成为唯一防线。

三种主流解决方案

1. 布隆过滤器(Bloom Filter)

布隆过滤器是个很巧妙的数据结构。它用多个哈希函数,把数据映射到一个位数组上。判断一个key「可能存在」还是「绝对不存在」。

注意,它有一定的误判率——可能把不存在的key误判为存在,但绝不会把存在的key误判为不存在。

我在项目中用过Guava的BloomFilter,也自己实现过基于Redis的分布式版本。说实话,布隆过滤器最适合的场景是:数据量极大,但允许少量误判

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

public class BloomFilterDemo {
    // 预计插入100万条数据,误判率0.01%
    private static BloomFilter<String> bloomFilter =
        BloomFilter.create(Funnels.stringFunnel(), 1_000_000, 0.0001);

    public static void main(String[] args) {
        // 初始化:加载所有合法商品ID
        for (String id : getAllProductIds()) {
            bloomFilter.put(id);
        }

        // 查询时先过布隆过滤器
        String requestId = "product_999999";
        if (!bloomFilter.mightContain(requestId)) {
            System.out.println("该ID肯定不存在,直接返回");
            return;
        }
        // 再走缓存-数据库流程
    }
}
我的经验:布隆过滤器初始化时,一定要预估好数据量和误判率。我曾经设了一个0.1%的误判率,结果位数组太大,内存吃掉了2GB……后来改成0.01%,内存降到了300MB,效果完全够用。

2. 空值缓存

这个思路更直接:数据库查不到数据时,把「空结果」也缓存起来,设置一个较短的过期时间(比如30秒)。

这样,同样的key再次请求时,直接从缓存返回空,不会打到数据库。

// Java示例:空值缓存
public Object queryProduct(String productId) {
    // 1. 查缓存
    Object cache = redis.get(productId);
    if (cache != null) {
        return cache;
    }

    // 2. 查数据库
    Object dbResult = database.query(productId);
    if (dbResult == null) {
        // 空值也缓存,过期时间30秒
        redis.setex(productId, 30, "NULL");
        return null;
    }

    // 3. 正常数据缓存5分钟
    redis.setex(productId, 300, dbResult);
    return dbResult;
}
注意:空值缓存不能解决「恶意攻击」问题。攻击者可以每次换不同的key来刷,空值缓存会越积越多,撑爆Redis内存。所以,空值缓存一定要配合过期时间和内存淘汰策略。

3. 热点数据保护

有些数据访问频率极高,比如秒杀商品、热搜词。这些热点数据一旦缓存失效,大量请求同时穿透,就是所谓的「缓存击穿」。

我常用的手段是:互斥锁 + 二级缓存

// Java示例:热点数据保护(互斥锁)
public Object getHotProduct(String productId) {
    // 1. 查一级缓存(Redis)
    Object cache = redis.get(productId);
    if (cache != null) {
        return cache;
    }

    // 2. 加锁,只让一个线程去查数据库
    String lockKey = "lock:" + productId;
    if (redis.setnx(lockKey, "1", 5)) { // 5秒超时
        try {
            Object dbResult = database.query(productId);
            redis.setex(productId, 300, dbResult);
            return dbResult;
        } finally {
            redis.del(lockKey);
        }
    } else {
        // 3. 其他线程等待,或者查二级缓存(本地缓存)
        Thread.sleep(50);
        return redis.get(productId); // 重试
    }
}
避坑指南:我曾经在锁的过期时间上栽过跟头。如果数据库查询超过5秒,锁自动释放,其他线程又会去查库。后来我改用Redisson的看门狗机制,自动续期,才彻底解决。

高并发下的缓存优化

上面三种方案,单独用都有局限。我一般会组合使用:

  • 第一层:布隆过滤器——拦截99%的不存在key
  • 第二层:空值缓存——兜住漏网之鱼
  • 第三层:热点数据保护——防止缓存击穿

你想想看,三层防护下来,数据库的压力能降多少?我实测过,从每秒几万次查询降到几十次,效果非常明显。

核心逻辑图

缓存穿透防护流程 用户请求 布隆过滤器 判断key是否存在 不存在 直接返回空 可能存在 Redis缓存 缓存命中→返回 未命中 数据库查询 有数据→回写缓存 无数据 空值缓存30秒 热点数据:互斥锁保护 三层防护:布隆过滤器 → 空值缓存 → 热点数据保护

总结一下

缓存穿透不是高深的技术难题,但处理不好,后果很严重。我个人习惯是:

  • 先上布隆过滤器,挡住大部分无效请求
  • 再配空值缓存,兜住漏网之鱼
  • 热点数据加互斥锁,防止击穿

这三板斧下去,数据库基本就稳了。当然,具体用哪种,还得看你的业务场景。数据量小、key有限,空值缓存就够了;数据量大、key无限,布隆过滤器是首选。

一句话记住:缓存穿透不可怕,三层防护搞定它。
公众号:蓝海资料掘金营,微信 deep3321