Skip to content

缓存穿透:布隆过滤器 + 缓存空值

用户访问一个不存在的商品详情页。 请求直接打到数据库,数据库被压垮。

这就是缓存穿透

什么是缓存穿透?

缓存穿透是指:查询一个不存在的数据,由于缓存和数据库都没有,请求直接打到数据库

正常流程:
┌─────────┐    ┌─────────┐    ┌─────────┐
│  请求   │───▶│  缓存   │───▶│  数据库  │
│         │    │  命中   │    │  (不查)  │
└─────────┘    └─────────┘    └─────────┘

缓存穿透:
┌─────────┐    ┌─────────┐    ┌─────────┐
│  请求   │───▶│  缓存   │───▶│  数据库  │
│         │    │ (未命中) │    │  (查询)  │
└─────────┘    └─────────┘    └─────────┘

        每次都查数据库!

穿透的危害

危害说明
数据库压力大量无效请求打到数据库
性能下降数据库响应慢,影响正常业务
雪崩风险数据库崩溃导致整个系统不可用
资源浪费CPU、内存被消耗

为什么会发生穿透?

场景一:恶意攻击

攻击者使用大量不存在的 ID 请求:

请求:product:-1, product:-2, product:-9999999
原因:试探系统、压垮服务

场景二:业务逻辑

商品下架后,URL 仍然可访问
用户点击 → 查询数据库 → 返回空
缓存没有 → 继续查数据库

场景三:数据误删

误删了数据库中的商品
但缓存已过期
用户查询 → 缓存未命中 → 查询数据库 → 返回空

解决方案

方案一:缓存空值

原理:将空结果也缓存起来,下次查询直接命中。

java
/**
 * 缓存空值方案
 * 
 * 优点:实现简单,效果明显
 * 缺点:浪费缓存空间(空值也占空间)
 */
public String getProduct(String productId) {
    // 1. 查询缓存
    String cacheKey = "product:" + productId;
    String cacheValue = redis.get(cacheKey);
    
    if (cacheValue != null) {
        // 2. 命中缓存
        return cacheValue;
    }
    
    // 3. 查询数据库
    Product product = db.findProduct(productId);
    
    if (product == null) {
        // 4. 数据库也没有,缓存空值
        // 注意:空值要有过期时间,否则正常数据恢复后仍然查不到
        redis.setex(cacheKey, 5 * 60, "NULL");  // 空值缓存 5 分钟
        return null;
    }
    
    // 5. 缓存正常数据
    redis.setex(cacheKey, 30 * 60, JSON.toJSONString(product));
    return JSON.toJSONString(product);
}

方案二:布隆过滤器

原理:用布隆过滤器判断数据是否存在,只对存在的数据查询缓存和数据库。

java
/**
 * 布隆过滤器原理:
 * 
 * 1. 初始化:将所有存在的 key 插入布隆过滤器
 * 2. 查询时:先检查布隆过滤器
 *    - 不存在 → 直接返回,不查数据库
 *    - 可能存在 → 查缓存,再查数据库
 * 
 * 特点:
 * - 空间效率高
 * - 可能有误判(false positive)
 * - 不可能有漏判(false negative)
 */
public class BloomFilterDemo {
    
    // Guava 的布隆过滤器
    private BloomFilter<String> bloomFilter;
    
    /**
     * 初始化布隆过滤器(系统启动时)
     */
    public void init() {
        // 从数据库加载所有存在的商品 ID
        List<String> allProductIds = db.getAllProductIds();
        
        bloomFilter = BloomFilter.create(
            Funnels.stringFunnel(StandardCharsets.UTF_8),
            1000000,  // 预计数据量
            0.01      // 误判率 1%
        );
        
        for (String productId : allProductIds) {
            bloomFilter.put(productId);
        }
    }
    
    /**
     * 查询商品
     */
    public Product getProduct(String productId) {
        // 1. 检查布隆过滤器
        if (!bloomFilter.mightContain(productId)) {
            // 2. 布隆过滤器判断不存在,直接返回
            return null;
        }
        
        // 3. 布隆过滤器说可能存在,查缓存
        String cacheKey = "product:" + productId;
        String cacheValue = redis.get(cacheKey);
        
        if (cacheValue != null) {
            return JSON.parseObject(cacheValue, Product.class);
        }
        
        // 4. 查数据库
        Product product = db.findProduct(productId);
        
        if (product != null) {
            redis.setex(cacheKey, 30 * 60, JSON.toJSONString(product));
        }
        
        return product;
    }
}

方案三:布隆过滤器 + 缓存空值(最佳实践)

两种方案结合,取长补短:

java
public class Cache穿透防护 {
    
    private BloomFilter<String> bloomFilter;
    private Jedis jedis;
    
    /**
     * 查询商品(双重防护)
     */
    public Product getProduct(String productId) {
        String cacheKey = "product:" + productId;
        
        // 第一层:布隆过滤器过滤明显不存在的数据
        if (!bloomFilter.mightContain(productId)) {
            // 布隆过滤器说一定不存在
            return null;
        }
        
        // 第二层:查缓存(包含空值)
        String cacheValue = jedis.get(cacheKey);
        
        if (cacheValue != null) {
            if ("NULL".equals(cacheValue)) {
                // 空值缓存,说明之前查过且数据库也没有
                return null;
            }
            return JSON.parseObject(cacheValue, Product.class);
        }
        
        // 第三层:查数据库
        Product product = db.findProduct(productId);
        
        if (product != null) {
            // 缓存正常数据
            jedis.setex(cacheKey, 30 * 60, JSON.toJSONString(product));
        } else {
            // 缓存空值,但时间短一些(避免长期占用)
            jedis.setex(cacheKey, 2 * 60, "NULL");
        }
        
        return product;
    }
    
    /**
     * 商品新增时,添加到布隆过滤器
     */
    public void addProduct(Product product) {
        bloomFilter.put(product.getId());
    }
    
    /**
     * 商品删除时,从布隆过滤器移除
     * 注意:布隆过滤器不支持删除单个元素
     * 解决:重建布隆过滤器 或 使用带删除功能的变体(如 Cuckoo Filter)
     */
    public void removeProduct(String productId) {
        // 方案 1:重建整个布隆过滤器(数据量小时可用)
        rebuildBloomFilter();
    }
}

方案对比

方案优点缺点适用场景
缓存空值实现简单,效果明显浪费空间,数据不一致风险数据量较小
布隆过滤器空间效率高有误判,不支持删除数据量很大
组合方案双重防护,最完善实现稍复杂生产环境推荐

布隆过滤器的实现

RedisBloom 模块

Redis 4.0+ 可以使用 RedisBloom 模块:

bash
# 安装 RedisBloom
docker run -d --name redis-bloom redis/redis-stack:latest

# 添加元素
BF.ADD products -1
BF.ADD products -2

# 检查元素
BF.EXISTS products -1    # 1(可能存在)
BF.EXISTS products 999  # 0(一定不存在)

# 批量添加
BF.MADD products -3 -4 -5

# 批量检查
BF.MEXISTS products -1 -3 -999

Java 客户端

java
import com.redislabs.redisgraph.RedisGraph;
import com.redislabs.modules.rebloom.JedisFilter;

public class RedisBloomDemo {
    
    private Jedis jedis;
    private static final String FILTER_NAME = "products";
    
    /**
     * 初始化布隆过滤器
     */
    public void init() {
        // 添加已存在的商品 ID
        List<String> existingIds = getExistingProductIds();
        
        jedis.bf().add(FILTER_NAME, existingIds.toArray(new String[0]));
    }
    
    /**
     * 检查是否存在
     */
    public boolean mightExist(String productId) {
        return jedis.bf().exists(FILTER_NAME, productId);
    }
    
    /**
     * 批量检查
     */
    public List<String> filterNotExists(List<String> ids) {
        // 过滤掉布隆过滤器认为存在的
        return ids.stream()
            .filter(id -> !mightExist(id))
            .collect(Collectors.toList());
    }
}

Guava 实现

java
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

public class GuavaBloomFilterDemo {
    
    private BloomFilter<String> bloomFilter;
    
    /**
     * 初始化
     */
    public void init(int expectedInsertions) {
        bloomFilter = BloomFilter.create(
            Funnels.stringFunnel(StandardCharsets.UTF_8),
            expectedInsertions,  // 预计元素数量
            0.01                // 误判率
        );
    }
    
    /**
     * 添加元素
     */
    public void put(String id) {
        bloomFilter.put(id);
    }
    
    /**
     * 检查元素
     */
    public boolean mightContain(String id) {
        return bloomFilter.mightContain(id);
    }
}

布隆过滤器的参数选择

参数说明计算公式
n预计元素数量根据业务预估
p误判率业务可接受的值
m所需 bit 数-n * ln(p) / (ln(2)^2)
k哈希函数数量(m / n) * ln(2)
java
/**
 * 布隆过滤器参数计算
 */
public class BloomFilterCalculator {
    
    /**
     * 计算所需内存
     */
    public static void main(String[] args) {
        int n = 10000000;  // 1000 万元素
        double p = 0.01;    // 1% 误判率
        
        // 所需 bit 数
        double m = -n * Math.log(p) / (Math.log(2) * Math.log(2));
        
        // 所需内存(字节)
        double memoryMB = m / 8 / 1024 / 1024;
        
        System.out.println("预计元素数量: " + n);
        System.out.println("误判率: " + (p * 100) + "%");
        System.out.println("所需 bit 数: " + (long)m);
        System.out.println("所需内存: " + String.format("%.2f MB", memoryMB));
        
        // 不同误判率的内存对比
        System.out.println("\n不同误判率的内存对比:");
        for (double fp : new double[]{0.01, 0.001, 0.0001}) {
            double m2 = -n * Math.log(fp) / (Math.log(2) * Math.log(2));
            double mem = m2 / 8 / 1024 / 1024;
            System.out.printf("  误判率 %.2f%%: %.2f MB%n", fp * 100, mem);
        }
    }
}

输出:

预计元素数量: 10000000
误判率: 1.0%
所需 bit 数: 95850588
所需内存: 11.44 MB

不同误判率的内存对比:
  误判率 1.00%: 11.44 MB
  误判率 0.10%: 17.28 MB
  误判率 0.01%: 23.12 MB

总结

缓存穿透是常见的安全问题:

  • 危害:数据库压力增大,可能导致系统崩溃
  • 原因:查询不存在的数据
  • 方案:缓存空值、布隆过滤器、组合使用
  • 选型:数据量大用布隆过滤器,数据量小用缓存空值

留给你的问题

布隆过滤器有一个特性:只能添加,不能删除

如果你的业务需要删除已下架的商品,布隆过滤器如何处理这种情况?有什么替代方案?

基于 VitePress 构建