☰
缓存穿透解决方案:布隆过滤器原理、落地实战与误判兜底
2026/10/1 3:14:29 网站建设 项目流程

缓存穿透这个事,做过后端高并发的人基本都遇到过。数据库CPU飙升、慢查询暴增、缓存命中率掉到谷底,查完日志发现全是同一批根本不存在的key在反复闯关。布隆过滤器是我用过最有效的拦截手段之一,这篇文章就完整复盘一下它是怎么工作的、怎么落地,以及真实生产环境里会踩到哪些坑。

这篇文章适合正在用Redis做缓存、又频繁被"缓存和数据库都没有的数据请求"困扰的后端开发。我会从一次线上事故讲起,把原理、Guava本地实现、Redis集中式实现、全链路改造、以及误判控制全部过一遍,最后给出一套可以直接抄作业的兜底组合方案。

1. 缓存穿透是怎么把 MySQL 打挂的:一次线上事故复盘

1.1 事故现场:查询日志里全是同一个"幽灵ID"

去年某次大促前的压测阶段,线上MySQL主库的CPU突然冲到95%,慢查询队列里堆满了同一条SQL:

SELECT * FROM order_tab WHERE order_id = 'xxx'

这条SQL本身很简单,order_id上有索引,单次执行只要零点几毫秒。但问题是它每秒被执行了几千次,而且每次查询结果都是空。空结果意味着什么?意味着缓存里没有,数据库里也没有,压根不存在这个订单。

我第一时间看到的是Redis监控里get命令的QPS并没有异常飙升,但DB的SELECT QPS暴涨了几十倍。这就是很典型的缓存穿透:请求的key在Redis里查不到,于是落到了DB,而DB里也不存在这条数据,于是每次都得真实执行一次DB查询,缓存永远无法回填。

继续排查应用日志后发现,这些请求的order_id并不是随机生成的,而是有人用脚本在遍历某个区间内的ID。这个区间里有一大批ID对应的订单早已被逻辑删除,数据库里查不到,Redis里自然也没有。攻击者或者爬虫只要跑一遍循环,就能让我们的DB承受成千上万次无效查询。

1.2 穿透、击穿、雪崩:先分清这三种故障

很多同学把缓存穿透和缓存击穿混为一谈,排查方向完全跑偏。我习惯用一张表区分它们:

故障类型故障对象触发时机核心特征常用对策
缓存穿透一个不存在的key每次请求都穿透缓存和DB都没有该key,无法回填缓存布隆过滤器、缓存空值、参数校验
缓存击穿一个热点key热点key失效的瞬间单个key过期,大量请求同时打到DB互斥锁重建缓存、逻辑过期
缓存雪崩大量key同一时间大面积失效多个key同时过期,请求全部落DB过期时间加随机值、多级缓存

穿透最阴险的地方在于它是"结构性无解"的:只要key不存在,缓存永远回填不了,你没法通过"查一次DB然后写入缓存"来化解,因为写入一个空值进去可能污染缓存。

1.3 穿透请求的共性特征

我后来复盘过多次这种事故,发现穿透请求有几个明显特征:

  • 请求的key在缓存和DB中都不存在,这是定义层面的特征;
  • 请求往往集中在少数几个"幽灵ID",或者呈规律性遍历,而不是均匀分布的随机流量;
  • Redis命中率剧烈下跌,但key总数并没有明显上涨;
  • 应用层日志中能看到大量"缓存未命中且DB查询为空"的记录,且这些记录集中在相同参数上。

如果你们监控系统里同时出现这几条,基本可以直接判定为缓存穿透,而不是单纯的DB慢查询。

2. 布隆过滤器的原理:位数组 + 多哈希的"记忆装置"

2.1 它是一个只记"见没见过"的大型登记簿

布隆过滤器的数据结构本质上就是一个很长的位数组(bit数组)和一组哈希函数。初始状态,这个位数组的所有位都是0。假设我们定义一个长度为16位、有3个哈希函数的布隆过滤器,现在把订单ID10001放进去:

  • 哈希函数1计算得到位置3,把位数组的第3位置为1;
  • 哈希函数2计算得到位置7,把第7位置为1;
  • 哈希函数3计算得到位置11,把第11位置为1。

再放入一个订单ID10002,它把位置2、8、12置为1。

查询10003是否存在时,同样计算3个哈希位置,只要这3个位置中有任何一个位是0,就可以断定10003从未被加入过——因为如果它被加入过,这3个位置一定都被置为1了,不可能出现0。

我用一个生活化的类比来理解:想象一大面墙上有上万个孔洞,每个孔洞里可以插一面小旗。每"登记"一个新面孔,就在他对应的几个孔洞里插上旗子。查询一个人时,只需要看他对应的那几个孔洞是否全部插了旗:只要有任何一个孔洞没旗,这个人一定没登记过;如果全部都有旗,大概率登记过,但也不能排除几个人恰好把同一批孔洞都插满的情况。

2.2 为什么它能100%判定"不存在",却可能误判"存在"

这是布隆过滤器最核心的不对称性:

  • 如果布隆过滤器说"不在集合里",那它一定不在。因为只要存在过,对应的所有位必定全部为1,查询时不可能出现某位为0的情况。
  • 如果布隆过滤器说"在集合里",它只是"大概率在",因为有极小概率是多个不同元素的哈希位置叠加,刚好把查询元素的所有位置都覆盖成了1。

这个不对称性和缓存穿透场景是天作之合。穿透的本质威胁是"不存在的key打穿到DB",我们最想拦截的就是"一定不存在的key"。布隆过滤器恰好能给出一个百分之百确定的"不存在"结论。至于它偶尔把不存在的key误判成存在,那只是放行了一次请求,最终DB查询结果仍然是空,代价可控。

2.3 参数推导:位数组长度 m 和哈希函数个数 k 怎么算

布隆过滤器有三个核心参数:

参数含义怎么定
n预期要存入的元素数量根据业务存量数据量估算
p期望误判率一般取1%~0.1%,看业务容忍度
m位数组长度(bit数)由n和p通过公式计算
k哈希函数个数由m和n计算

标准公式有两个:

[ m = -\frac{n \ln p}{(\ln 2)^2} ]

[ k = \frac{m}{n} \ln 2 ]

这两个公式的意义一句话解释:在给定数据量和误判率要求下,算出需要多少bit才不会让位数组过早被塞满;再算出需要几个哈希函数才能让误判率真正贴近期望值。

2.4 计算实例:100万数据量、1%误判率只需要约1.14MB

假设我们要给100万个订单ID做过滤器,期望误判率为1%:

先算m。n等于100万,p等于0.01,ln(0.01)约等于-4.605,(ln2)^2约等于0.4804:

[ m = \frac{1000000 \times 4.605}{0.4804} \approx 9586000 \text{ bit} ]

除以8就是约1.14MB。100万条数据只需要1.14MB,这是布隆过滤器最诱人的地方。

再算k:

[ k = \frac{9586000}{1000000} \times 0.6931 \approx 6.64 ]

取整数7,也就是用7个哈希函数。

如果把误判率降到0.1%,m会变成约1800万bit,也就是约1.8MB,仍然非常小。对比一下用Set去重存储100万个订单ID,至少要几十MB,差距非常明显。

3. 本地接入:Guava BloomFilter 的用法与单机隐藏问题

3.1 三行代码接入 Guava 布隆过滤器

在我们Java技术栈里,最简单的接入方式是Guava自带的BloomFilter。它内部已经实现了位数组扩展、哈希计算和最优参数选择,不需要你自己去算m和k。

先引入依赖:

<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>31.1-jre</version> </dependency>

然后写一个订单查询用的过滤器:

import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; // 预估元素数量100万,期望误判率1% BloomFilter<Long> orderBloomFilter = BloomFilter.create( Funnels.longFunnel(), 1_000_000L, 0.01); // 订单创建成功后调用put方法写入过滤器 orderBloomFilter.put(10001L); // 查询时先判断 boolean maybeExists = orderBloomFilter.mightContain(10001L); if (!maybeExists) { // 过滤器说一定不存在,直接返回空,不用查库 return null; } // 过滤器说可能存在,再走正常的缓存+DB查询

create方法会自动根据expectedInsertions和falseProbability计算出内部位数组长度和哈希函数数量,不需要你操心。put负责写入,mightContain负责判断。

3.2 参数设置经验:把 n 估大 20%,别让它失控

使用Guava版本时最容易犯的错误是低估n。很多人拿当前数据量填进去,但业务是持续增长的。如果实际插入的元素量超过预估的n,误判率会快速恶化,甚至接近失控。

我习惯在预估n的基础上再加20%~30%的余量。比如当前订单量峰值是80万,我就按100万来初始化过滤器。内存只多花不到20%,但能保证未来一段时间内误判率不会因为数据增长而翻倍。

还有一点:误判率p不要直接填0.0001这种极端值。p越小需要的位数组越长,计算代价也越高。对绝大多数缓存穿透拦截场景,1%的误判率已经完全够用。DB即使被1%的无效请求打到,也远不足以形成压力。

3.3 本地方案的真实局限:重启、多实例、扩容

Guava的本地布隆过滤器虽然接入简单,但我在生产环境里只把它用于"单机小服务+可接受短时间丢失"的场景。它有三个硬伤:

  • 重启即丢失。布隆过滤器存在JVM内存里,服务一重启,所有历史写入全部清空。如果没做重启后的预热补偿,过滤器的拦截能力直接归零,穿透流量瞬间恢复。
  • 多实例不一致。应用是集群部署时,请求可能被负载均衡分发到任意一台机器。订单A创建时只写进了实例1的过滤器,下次查询如果落在实例2上,实例2的过滤器会认为订单A一定不存在,直接返回空。这是非常隐蔽的业务故障。
  • 扩容困难。数据量增长超过预期后,没法动态扩大位数组,只能重建整个过滤器。Guava本身也没有提供持久化和重建机制。

如果你们的应用是单节点,或者只是做本地缓存兜底,Guava方案够用。但真正的线上高可用场景,我强烈建议把过滤器挪到集中式存储里。

4. 集中式方案:Redisson 与 RedisBloom 怎么选

4.1 为什么生产环境要把过滤器放进 Redis

把布隆过滤器放进Redis之后,前面说的三个硬伤就全部解决了:过滤器数据由Redis统一持有,任意服务实例查询到的结果一致;Redis有RDB和AOF持久化,服务重启后数据不丢;扩容时可以通过增加Redis内存或者重建过滤器来完成。

从架构上看,我们本来就有Redis作为缓存层,多加一套布隆过滤器的位数组不过多占约几MB内存,却能把缓存穿透的流量挡在缓存之前,性价比极高。

4.2 Redisson RBloomFilter 接入示例

如果项目里已经在用Redisson做分布式锁或缓存客户端,可以直接用它的RBloomFilter。

Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:6379"); RedissonClient redisson = Redisson.create(config); RBloomFilter<Long> bloomFilter = redisson.getBloomFilter("order:bloom"); // 只在未初始化时初始化,项目重启不要重复调用初始化 bloomFilter.tryInit(1_000_000L, 0.01); // 写入 bloomFilter.add(10001L); // 查询 boolean maybeExists = bloomFilter.contains(10001L);

这里有一个很多人踩过的坑:tryInit和init的区别。init不管过滤器是否已经初始化,都会强制重置位数组;tryInit只在未初始化时创建,已经存在就直接返回false。如果你们在Spring Bean初始化方法里用了init,每重启一次应用就会把线上过滤器清空一次,后果很严重。正确做法是永远用tryInit。

Redisson的底层实际上是用Redis的String结构或者BitMap来存储位数组,对业务代码而言不需要关心内部细节,只要知道它是一个分布式的、可持久化的布隆过滤器即可。

4.3 RedisBloom 模块方案与命令

如果不想引入Redisson,也可以给Redis安装RedisBloom模块。RedisBloom是Redis官方生态里的布隆过滤器模块,加载后直接使用自带的过滤命令。

启动时加载模块:

redis-server --loadmodule /path/to/redisbloom.so

使用Docker的话直接拉带模块的镜像:

docker run -p 6379:6379 redislabs/rebloom:latest

初始化一个过滤器:

BF.RESERVE order:bloom 0.01 1000000

BF.RESERVE后面依次是key、期望误判率、预估元素数量。写入和查询:

BF.ADD order:bloom 10001 BF.EXISTS order:bloom 10001

批量版本是BF.MADD和BF.MEXISTS,一次可以处理多个元素,预热存量数据时非常有用:

BF.MADD order:bloom 10001 10002 10003 BF.MEXISTS order:bloom 10001 99999

RedisBloom方案的优势是命令直接在Redis服务端执行,性能高且原子性强;缺点是运维上多了一个模块依赖,尤其是在Redis Cluster里,每台节点都要加载模块,升级运维复杂一些。

4.4 三套方案横向对比

对比维度Guava本地RedissonRedisBloom
存储位置JVM内存RedisRedis
接入成本依赖Guava即可依赖Redisson需要加载模块
多实例一致性不一致一致一致
重启恢复丢失,需重建依赖Redis持久化依赖Redis持久化
批量写入效率高一般,可用pipeline高,有BF.MADD
运维复杂度最低低中

如果让我选,我会这样判断:单机小服务用Guava;已经引入Redisson的团队直接用Redisson;有能力管理Redis模块的团队用RedisBloom,因为它操作最直观、批量性能最好。

5. 布隆过滤器落地:预热、读改写全链路改造

5.1 预热:把存量合法ID灌进去,别让刚上线的过滤器"六亲不认"

很多人忽略预热,觉得写几行代码把过滤器接进去就完事了。大错特错。

布隆过滤器刚创建时是空的,里面没有任何元素。如果不做预热就直接上线,所有查询请求都会被过滤器判定为"一定不存在",然后返回空。那不只是缓存穿透的问题,而是整个业务接口直接不可用。

正确的做法是:在发布前写一个预热任务,从数据库分批扫描当前所有合法订单ID,批量写入过滤器。

public void preheatBloomFilter() { int pageSize = 1000; long lastId = 0L; // 分批扫描,避免一次加载全量数据导致内存溢出 while (true) { List<Long> orderIds = orderMapper.selectIdBatch(lastId, pageSize); if (CollectionUtils.isEmpty(orderIds)) { break; } orderBloomFilter.addAll(orderIds); lastId = orderIds.get(orderIds.size() - 1); } log.info("布隆过滤器预热完成,共 {} 条", totalCount); }

预热的时间要提前算好。我当时测试过,100万条数据用BF.MADD每批500条注入,大概需要几十秒到一分钟,完全可以在发布流程里增加一个前置任务完成。记住一个发布顺序:先跑预热任务,确认过滤器里的数量已经达到存量规模,再切换线上流量。否则上线后会有短暂的穿透窗口。

5.2 读链路改造:先问过滤器,再问缓存,最后才问 DB

接入布隆过滤器后,完整的订单查询链路应该变成这样:

public Order queryOrder(Long orderId) { // 第一步:基础合法性校验 if (orderId == null || orderId <= 0) { return null; } // 第二步:布隆过滤器拦截一定不存在的ID if (!orderBloomFilter.contains(orderId)) { return null; } // 第三步:查缓存 String cacheKey = "order:info:" + orderId; Order order = cache.get(cacheKey); if (order != null) { return order; } // 第四步:缓存未命中去查DB order = orderMapper.selectByPrimaryKey(orderId); if (order != null) { cache.set(cacheKey, order); } else { // 第五步:兜底,缓存空值,吸收误判导致的回源 cache.set(cacheKey, EMPTY_PLACEHOLDER, 60); } return order; }

顺序是有讲究的。参数校验放在最前面,因为它不需要任何外部依赖;布隆过滤器其次,因为一次Redis操作比查DB便宜得多;再往后才是缓存和DB。

5.3 写链路维护:新增数据如何进入过滤器

读链路改造完成之后,最容易被遗漏的是写链路。如果只做了查询拦截,但订单创建的代码没有把新订单ID写入过滤器,那么新产生的合法订单查询时会被过滤器误判为"不存在",直接被返回空,这又是一个致命bug。

正确的做法是在订单创建成功后,同步把订单ID写入过滤器:

@Transactional public Long createOrder(OrderCreateDTO dto) { Order order = new Order(); // ... 填充订单数据并insert orderMapper.insert(order); // 事务提交后写入布隆过滤器 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { orderBloomFilter.add(order.getId()); } }); return order.getId(); }

注意一定要在事务提交之后再写入过滤器。如果事务还没提交就把ID写进过滤器,一旦事务回滚,过滤器里就会残留一个不存在的ID。虽然残留ID的危害只是放行一次DB空查询,不算严重,但能避免就避免。

5.4 数据删除后布隆过滤器里的"幽灵"怎么处理

布隆过滤器最让人头疼的一点是它不支持删除元素。订单被取消或者逻辑删除后,过滤器里仍然保留着这个ID的位置标记,后续查询它时过滤器仍然说"可能存在",请求还是会打到DB。

这是一个可以接受的问题。原因是:被删除的ID是有限的存量,它只会放行一次DB空查询,之后如果加了空值缓存,连DB都不会再打。真正需要担心的是大量已删除ID反复被恶意扫描,这时候靠空值缓存和限流兜底,而不是靠过滤器。

我的实践经验是定期重建过滤器。比如每天凌晨低峰期,用一个定时任务重新初始化过滤器并灌入当前有效订单ID。这样既能把已删除的"幽灵"清出去,又能修正数据量增长带来的误判率上升,一箭双雕。

再激进一点的做法是双过滤器交替。维护两个key,比如order:bloom:active和order:bloom:standby,切换流量时先预热standby,然后把查询切过去,再清掉active。这个方案适合对可用性要求极高的场景,一般团队用不到。

6. 误判率的代价和三道兜底防线

6.1 误判的真正危险:同一个不存在的key反复进DB

布隆过滤器的误判不等于"DB被打穿一次就结束"这么简单。误判是稳定复现的:某个不存在的ID被误判为存在后,它每次被查询都会被放行。如果这个ID恰好是攻击者用来扫描的ID,那它在布隆过滤器这里是永远无法被拦截的,每次请求都会走到DB。

所以我一直强调一个观点:布隆过滤器可以把穿透率降低99%,但剩下那1%的误判流量仍然需要别的机制去兜底。原理上不存在一个纯布隆过滤器就能100%防穿透的方案。

6.2 兜底一:空值短缓存,吸收误判回源

对查询结果为空的key,设置一个短TTL的空值缓存。这是成本最低、见效最快的兜底手段。

Order order = cache.get(cacheKey); if (order == null) { order = orderMapper.selectByPrimaryKey(orderId); if (order == null) { // 缓存空值60秒,防止同一误判ID反复穿透 cache.set(cacheKey, EMPTY_VALUE, 60); } else { cache.set(cacheKey, order, 3600); } } return order;

空值缓存的负面效果是占用了少量缓存空间,而且可能出现短暂的数据不一致(比如该ID之后真的创建了订单,60秒内缓存仍是空值)。对于订单这类低频创建的业务,短TTL的空值缓存完全可接受;对于高频写入的业务,TTL可以压到30秒甚至更短。

6.3 兜底二:参数与合法性校验,拦截根本不值得进过滤器的请求

布隆过滤器只能拦截"格式合法但不存在"的key,拦截不了"格式非法"的请求。比如订单ID为正整数,但请求带了一个负数或者非数字字符串,过滤器根本不会匹配,直接查DB也是浪费。

所以在接过滤器之前一定要有参数校验:

public Order queryOrder(String orderIdStr) { Long orderId; try { orderId = Long.valueOf(orderIdStr); } catch (NumberFormatException e) { throw new IllegalArgumentException("非法订单ID"); } if (orderId <= 0) { throw new IllegalArgumentException("非法订单ID"); } // 再走布隆过滤器 }

很多穿透攻击就是用超长字符串、负数、特殊字符来绕过逻辑的,参数校验这层筛掉之后,真正进入过滤器的流量会干净很多。

6.4 兜底三:限流与监控重建

最后一道防线是限流和监控。即使布隆过滤器、空值缓存全上了,仍然可能出现极端情况:数据量增长远超预估导致误判率飙升,或者业务侧被人用合法ID遍历刷接口。

我建议在关键接口上做两层监控:

  • 监控"过滤器判断存在但DB查询为空"的比例,如果这个比例明显超过预设误判率,说明过滤器参数已经不合适,需要触发重建任务;
  • 对单IP或单用户的查询频率做限流,超过阈值直接拒绝或拉长响应,防止有人用合法ID来回扫描。

重建过滤器时不需要停服务。可以新建一个key,把存量数据灌进去,然后通过配置中心切开关,让查询逻辑读新的key,确认无误后再清理旧key。这个方案我在多个项目里用过,非常稳。

最后说点实在的

我在实际项目里用的是Redisson方案,配合空值缓存和参数校验,把线上缓存穿透从每秒数千次DB空查询降到了基本为0。最有价值的不是把DB救了下来,而是终于有精力去排查那些恶意请求到底是从哪个渠道进来的。

如果你正被缓存穿透问题困扰,我的建议是:不要一上来就上布隆过滤器,先看日志确认穿透来源,把参数校验和空值缓存这两个成本最低的兜底做上,然后再接分布式布隆过滤器。这三层组合起来,穿透率才能压到真正可控的水平。布隆过滤器不是银弹,但它一定是缓存穿透防线上最结实的那道闸门。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询