如何解决缓存穿透?布隆过滤器与空值缓存实战详解

来源:Linux教程作者:弥生美月头衔:网络博主
导读:本期聚焦于弥生美月创作的《如何解决缓存穿透?布隆过滤器与空值缓存实战详解》,敬请观看详情。缓存穿透是指查询一个缓存和数据库中都不存在的数据,请求每次都直接打到数据库,高并发场景下极易拖垮整个服务。本文从原理层面剖析缓存穿透产生的原因和典型攻击场景,重点讲解两种主流解决方案:空值缓存和布隆过滤器。空值缓存实现简单,通过缓存空结果并设置较短过期时间来拦截重复请求;布隆过滤器利用位数组和多个哈希函数以极小的内存开销判断元素一定不存在或可能存在,适合海量数据场景。文章还结合Redis给出可直接落地的代码示例,对比两种方案的适用场景、优缺点以及如何组合使用构建更完善的防护体系。

缓存穿透是分布式系统里最经典的高并发问题之一。简单来说,用户请求的数据在缓存和数据库中都不存在,缓存永远无法命中,每一次请求都会穿透缓存直达数据库。正常业务中这类请求比例很低,但一旦遇到恶意攻击(比如用大量不存在的ID发起请求)或者爬虫批量扫描,数据库就会在瞬间承受远超承载能力的压力,严重时直接拖垮整个服务。本文围绕这个问题的两种主流解法:空值缓存和布隆过滤器展开,配合代码示例讲清楚各自的原理、实现和适用边界。

如何解决缓存穿透?布隆过滤器与空值缓存实战详解

一、缓存穿透到底是怎么发生的

先看一个典型的业务流程:客户端发起请求,服务先查Redis缓存,缓存未命中就查MySQL,拿到结果后写回缓存再返回。这个逻辑在数据存在的时候没有任何问题,但如果请求的ID在数据库里压根不存在呢?比如用户访问一个商品详情页,传入的goodsId为-1或者一个随机的长串数字,数据库查询返回空,而空结果通常不会被写入缓存。于是下一个相同的请求到来时,缓存依然未命中,又去查一次数据库。

在低并发的正常业务中,这种空查询的代价可以忽略。但当有人恶意构造几十万个不存在的ID并发请求时,问题就被急剧放大:每个请求都绕过缓存,MySQL的连接池迅速被占满,CPU和磁盘IO飙升,最终导致整个数据库服务不可用,进而引发连锁故障,依赖该库的其他业务也跟着挂掉。这种情况和缓存击穿(某个热点key过期瞬间大量请求打到数据库)不同,穿透的显著特征是被查询的数据从头到尾就不存在,缓存永远不会被填充。

常见 triggers 包括:恶意攻击者用随机ID刷接口、业务自身允许用户传入任意ID进行查询(如根据订单号查订单)、缓存和数据库数据不一致期间的老ID访问等。理解了成因,对应的解决思路也就清晰了:要么让不存在的数据也能在缓存层被拦截,要么在请求到达数据库之前就判断出数据不存在。

二、方案一:空值缓存,简单直接的止血方案

空值缓存的思路非常朴素:既然数据库查不到结果会导致缓存无法填充,那就把空结果也缓存起来。第一次查询goodsId=-1时数据库返回空,我们在Redis里写入一个特殊标记(比如空字符串或者固定占位值"NULL"),并设置一个较短的过期时间,比如60秒。之后一分钟内所有针对这个ID的请求都会命中缓存拿到空标记,直接返回,不再触碰数据库。

下面是一段基于Java和Redis的示例实现:

public Object queryGoods(Long goodsId) {
    String key = "goods:" + goodsId;
    // 1. 先查缓存
    String value = redis.get(key);
    if (value != null) {
        // 命中空值标记,直接返回,避免穿透到数据库
        if ("NULL".equals(value)) {
            return null;
        }
        return JSON.parseObject(value, Goods.class);
    }
    // 2. 缓存未命中,查询数据库
    Goods goods = goodsMapper.selectById(goodsId);
    if (goods == null) {
        // 数据库中也不存在,写入空值标记并设置较短过期时间
        redis.set(key, "NULL", 60, TimeUnit.SECONDS);
        return null;
    }
    // 3. 正常数据写回缓存,过期时间可以长一些
    redis.set(key, JSON.toJSONString(goods), 30, TimeUnit.MINUTES);
    return goods;
}

这个方案实现成本极低,几十行代码就能上线,对业务代码的侵入也很小。但它的局限性同样明显:第一,空值的过期时间设长了会占用大量缓存内存,设短了又可能在过期后再次穿透;第二,如果攻击者每次都用不同的随机ID发请求,每个新ID都会产生一次数据库空查询,空值缓存根本拦不住,这种情况下它只能起到缓解作用;第三,空值数据和正常数据共享缓存空间,大量空值可能把热点数据挤出去,需要权衡内存分配。

实践中有一些优化技巧:给空值单独设置更短的TTL;对单ID的空值缓存做次数限制,超过阈值直接拉黑该ID一段时间;或者用独立的Redis实例或单独的key前缀来隔离空值数据。总体来说,空值缓存适合数据不存在的情况有限且可枚举的业务,作为第一道防线非常合适。

三、方案二:布隆过滤器,海量数据下的内存利器

布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,由一个很长的位数组和若干个独立的哈希函数组成。写入一个元素时,用k个哈希函数分别计算出k个位置,把这些位置的二进制位设置为1;查询某个元素时,同样计算k个位置,只要其中有任何一个位置是0,就可以断定这个元素一定不存在;如果所有位置都是1,则说明这个元素可能存在(因为可能是其他元素把这些位占了,即存在误判率)。

这个特性恰好契合缓存穿透的防护需求:布隆过滤器里存的是所有合法ID,请求到来时先查过滤器,判断为一定不存在就直接拒绝,根本不需要查缓存和数据库。注意布隆过滤器的判断是单向的——说不存在就绝对不存在,说存在可能是误判。所以它适合挡住不存在的数据,而不能替代缓存本身。对于删除场景它也有短板:位数组的位不能直接清零,因为多个元素可能共享同一个位,删除一个元素可能影响其他元素的判断,因此布隆过滤器一般只支持新增不支持删除。

使用Redis实现布隆过滤器有两条路:一条是用Redis 4.0之后官方提供的RedisBloom模块,直接调用BF.ADD和BF.EXISTS命令:

# 创建一个布隆过滤器,预计插入100万元素,误判率0.1%
BF.RESERVE goods:filter 0.001 1000000
# 添加元素
BF.ADD goods:filter 10001
# 判断元素是否存在,返回1表示可能存在,返回0表示一定不存在
BF.EXISTS goods:filter 10001
BF.EXISTS goods:filter 999999999

另一条路是自己基于Redis的SETBIT和GETBIT命令实现。下面是Java版本的简化实现:

public class SimpleBloomFilter {
    private static final int BIT_SIZE = 2 << 24; // 约3300万个bit
    private static final int[] SEEDS = {3, 7, 11, 13, 31, 37, 61};

    public void add(String key) {
        for (int seed : SEEDS) {
            int index = hash(key, seed);
            // 对应位置置为1
            redis.setBit("bloom:goods", index, true);
        }
    }

    public boolean mightContain(String key) {
        for (int seed : SEEDS) {
            int index = hash(key, seed);
            // 只要有一个位置为0,则一定不存在
            if (!redis.getBit("bloom:goods", index)) {
                return false;
            }
        }
        return true; // 所有位置都为1,可能存在(有误判概率)
    }

    private int hash(String key, int seed) {
        int h = 0;
        for (int i = 0; i < key.length(); i++) {
            h = h * seed + key.charAt(i);
        }
        // 与运算保证索引落在位数组范围内且为正数
        return (BIT_SIZE - 1) & h;
    }
}

使用时要注意几个关键点:布隆过滤器需要在系统启动或数据变更时预先加载全量合法ID;新增数据时要同步写入过滤器,否则新数据会被误判为不存在;误判率和位数组大小、哈希函数个数成反比,业界常用的经验公式是当元素数量为n、误判率为p时,最优位数组大小约为n乘以负ln(p)再除以ln2的平方。以1亿个元素、1%误判率为例,仅需约120MB内存,远小于直接存储所有ID的开销,这正是它在海量数据场景下无可替代的原因。

四、两种方案对比与组合实践

下表从多个维度对比两种方案,帮助选型:

对比维度空值缓存布隆过滤器
实现复杂度低,改动业务代码即可较高,需维护数据结构同步
内存开销与空值key数量成正比极小,与误判率要求相关
拦截效果仅能拦截重复出现的相同key可拦截所有不存在的key
数据删除支持支持,等TTL过期即可不支持删除(可用计数布隆过滤器变通)
适用场景数据不存在情况有限、并发不高数据量大、key空间不可枚举

生产环境中更推荐两者结合使用:请求先经过布隆过滤器,判断一定不存在的直接拒绝;判断可能存在的继续查缓存,缓存未命中查数据库,查到空结果再写入空值缓存兜底。这样既覆盖了随机ID攻击的场景,又兜住了布隆过滤器误判带来的少量穿透流量,形成双保险。

此外还可以补充一些辅助手段增强防护:在接入层做好参数合法性校验,比如ID必须为正整数、格式不符合直接拒绝;对单个用户或IP的查询频率做限流;针对明显异常的访问模式启用风控策略。缓存穿透的防护从来不是单一组件的事,而是从入口校验、缓存策略到数据结构层层设防的体系化工程,理解布隆过滤器和空值缓存这两个核心武器的原理与边界,是搭建这套体系的第一步。

缓存穿透布隆过滤器空值缓存修改时间:2026-09-12 12:44:47

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260912/55334.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。