2. 缓存穿透:定义与成因、空值缓存与布隆过滤器原理、布隆过滤器代码实现示例

2.1 缓存穿透的定义与成因

缓存穿透是指客户端请求的数据在缓存中不存在,在数据库中也不存在,导致每次请求都直接穿透缓存层,打到后端数据库。由于缓存层无法命中,数据库也返回空结果,这类请求会持续绕过缓存,造成数据库压力陡增,甚至引发雪崩。

核心特征:缓存未命中 + 数据库无记录 = 每次请求都直击数据库。

常见成因:

  • 恶意攻击:攻击者构造大量不存在的 Key(如负 ID、超长字符串、随机组合),绕过缓存直接查询数据库。
  • 业务逻辑缺陷:用户查询非法参数或已删除的资源,后端未做前置校验。
  • 缓存与数据库数据不一致:数据已被删除但缓存未及时失效,导致请求穿透后数据库返回空。
  • 大量并发请求:热点 Key 失效瞬间,大量请求同时穿透,数据库瞬间承压。
对比维度 缓存穿透 缓存击穿 缓存雪崩
目标 Key 不存在的 Key 单个热点 Key 失效 大量 Key 同时失效
数据库影响 每次请求都穿透 瞬间高并发穿透 大面积请求穿透
典型场景 恶意攻击、非法参数 热点数据过期 缓存宕机或批量过期
防护手段 空值缓存、布隆过滤器 互斥锁、逻辑过期 过期时间打散、降级限流
经验法则:缓存穿透是“查无此人”型攻击,防护重点在于前置过滤,而非后置兜底。

2.2 空值缓存原理

空值缓存是最直接的穿透防护手段:当数据库查询返回空结果时,仍然将该 Key 写入缓存,但 Value 标记为 null 或特殊占位符,并设置较短的过期时间(如 30~60 秒)。后续相同 Key 的请求直接命中缓存空值,不再穿透数据库。

实现要点:

  • 空值缓存过期时间不宜过长,避免业务数据恢复后缓存仍返回空。
  • 需要区分“缓存空值”与“缓存数据不存在”,防止误判。
  • 对恶意攻击的随机 Key 无效——攻击者每次使用不同 Key,空值缓存无法覆盖。
// 伪代码示例:空值缓存
public Object getData(String key) {
    Object cacheValue = cache.get(key);
    if (cacheValue != null) {
        // 命中缓存,直接返回(包括空值标记)
        return cacheValue;
    }
    // 缓存未命中,查询数据库
    Object dbValue = db.query(key);
    if (dbValue == null) {
        // 数据库无记录,缓存空值,过期时间 30 秒
        cache.set(key, NULL_PLACEHOLDER, 30);
        return null;
    }
    // 数据库有记录,写入缓存
    cache.set(key, dbValue, 300);
    return dbValue;
}
注意:空值缓存只能防御“有限 Key 集合”的穿透,无法应对海量随机 Key 攻击。此时需要布隆过滤器进行前置过滤。

2.3 布隆过滤器原理

布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于集合中。它由二进制位数组和多个哈希函数组成,核心特性:

  • 不存在一定不存在:如果布隆过滤器判断元素不在集合中,则一定不在。
  • 存在不一定存在:存在一定的误判率(False Positive),但不会漏判(False Negative)。

工作原理:

  1. 初始化一个长度为 m 的位数组,所有位初始为 0。
  2. 添加元素时,使用 k 个哈希函数分别计算哈希值,将对应位数组位置置为 1。
  3. 查询元素时,同样计算 k 个哈希值,检查对应位是否全部为 1:
    • 如果任意一位为 0,则元素一定不存在。
    • 如果全部为 1,则元素可能存在(有误判概率)。
布隆过滤器工作原理 输入元素 Hash 1 Hash 2 Hash k 位数组(m 位) 1 0 1 0 1 0 1 0 0 1 0 0 1 0 0 1 某位为 0 → 不存在 全部为 1 → 可能存在 布隆过滤器:不存在一定不存在,存在不一定存在(有误判率)

2.4 布隆过滤器代码实现示例

以下是一个基于 Java 的简易布隆过滤器实现,使用 BitSet 和多个哈希函数:

import java.util.BitSet;

public class SimpleBloomFilter {
    private static final int DEFAULT_SIZE = 2 << 24; // 约 3355 万位
    private static final int[] SEEDS = {3, 7, 11, 13, 31, 37, 61};
    private BitSet bits = new BitSet(DEFAULT_SIZE);
    private HashFunction[] funcs = new HashFunction[SEEDS.length];

    public SimpleBloomFilter() {
        for (int i = 0; i < SEEDS.length; i++) {
            funcs[i] = new HashFunction(DEFAULT_SIZE, SEEDS[i]);
        }
    }

    // 添加元素
    public void add(String value) {
        for (HashFunction f : funcs) {
            bits.set(f.hash(value), true);
        }
    }

    // 判断元素是否存在
    public boolean mightContain(String value) {
        if (value == null) return false;
        for (HashFunction f : funcs) {
            if (!bits.get(f.hash(value))) {
                return false; // 某位为 0,一定不存在
            }
        }
        return true; // 全部为 1,可能存在
    }

    // 哈希函数内部类
    public static class HashFunction {
        private int cap;
        private int seed;

        public HashFunction(int cap, int seed) {
            this.cap = cap;
            this.seed = seed;
        }

        public int hash(String value) {
            int result = 0;
            for (int i = 0; i < value.length(); i++) {
                result = seed * result + value.charAt(i);
            }
            return (cap - 1) & result;
        }
    }

    // 使用示例
    public static void main(String[] args) {
        SimpleBloomFilter filter = new SimpleBloomFilter();
        filter.add("user:1001");
        filter.add("user:1002");
        filter.add("product:5001");

        System.out.println(filter.mightContain("user:1001"));  // true
        System.out.println(filter.mightContain("user:9999"));  // false(大概率)
        System.out.println(filter.mightContain("product:5001")); // true
        System.out.println(filter.mightContain("product:9999")); // false
    }
}
生产建议:实际项目中推荐使用 Google Guava 的 BloomFilter 或 Redis 4.0+ 的布隆过滤器模块(bf.add、bf.exists),避免重复造轮子。

2.5 布隆过滤器在缓存穿透防护中的部署架构

布隆过滤器防护缓存穿透架构 客户端请求 布隆过滤器 前置过滤层 缓存层 Redis / Local 数据库 MySQL / PG 不存在,直接拒绝 请求处理流程 1. 请求到达 → 先查询布隆过滤器 2. 布隆过滤器判断不存在 → 直接返回空,不查询缓存和数据库 3. 布隆过滤器判断可能存在 → 继续查询缓存 → 缓存未命中再查数据库
最佳实践:布隆过滤器 + 空值缓存组合使用。布隆过滤器拦截大部分非法 Key,空值缓存兜底少量合法但数据不存在的场景,数据库压力可降低 99% 以上。