☰
缓存穿透与布隆过滤器:原理、参数调优及 Redisson 实战
2026/9/30 7:42:40 网站建设 项目流程

1. 先搞清楚:缓存穿透到底是怎么回事

先说个我印象比较深的线上事故。有一年做电商订单查询优化,压测的时候数据库连接池突然被打满,慢查询日志里全是一个不存在的订单号。那会儿缓存策略很简单,就是常见的 Cache Aside——先查 Redis,查不到再去查数据库。问题就出在这个“查不到”上:只要有人恶意构造一批不存在的订单号,或者客户端代码里不小心传入脏数据,Redis 每次都查不到,于是每个请求都穿透到数据库,数据库每条都查不到,又不会把“查不到”这个结果写回缓存。结果就是,一个不存在的 key,就能把整个 DB 打得毫无还手之力。

这种现象就是典型的缓存穿透:请求的数据在缓存和数据库里都不存在,导致缓存形同虚设,流量直接打在存储层上。和它经常一起被提到的还有缓存击穿和缓存雪崩,但三个问题成因完全不同。击穿指的是某个热点 key 在过期瞬间,大量并发同时查到 DB;雪崩是大量 key 同时过期,或者 Redis 整体不可用导致请求全落到 DB。穿透则是最狡猾的那种——它可以针对任意的不存在 key,而且攻击成本极低,甚至只需要写个 for 循环换个 ID 就完了。

针对缓存穿透,业界有好几条路可以走:一是缓存空值,把查不到的结果也存进 Redis,设置一个很短的过期时间;二是接口层做参数校验,把明显非法的 id 直接拦截掉;三是引入布隆过滤器,在缓存前面再加一道“存在性校验”的闸门。空值缓存实现简单,但会有大量无用 key 占内存,而且挡不住不断换新 id 的攻击;参数校验只能处理格式上的非法请求。真正能把“不存在”这件事快速判断出来,又不怎么耗内存的方案,就是布隆过滤器。

我见过不少团队的首选方案是空值缓存,说实话,它对付一时的手滑确实好用,但面对持续构造不存在的 key 的脚本,它会让你 Redis 里堆满垃圾 key,还得靠定期清理来续命。布隆过滤器之所以被越来越多的人拿来解决穿透,核心逻辑在于:它把“key 存不存在”的判断前置了,不存在的请求根本不会进入后端存储,连查一次 DB 的机会都不给。

1.1 生产环境里,穿透是怎么被“放大”的

很多人觉得穿透就是个偶发现象,实际上它会在几个场景里被严重放大。最典型的是高并发下的“空查询放大”:当秒杀活动或者大促期间,前端拿到一批失效商品 ID,下游服务每秒钟几万甚至几十万的请求进来,全部命中同一个不存在的 key,数据库的连接池瞬间就被耗尽。另一个是撞库遍历:如果有人拿到你的业务 ID 规则,比如订单号是自增的或者时间戳+序列号,他写个脚本从 1 开始跑,Redis 里全是 miss,数据库就这么被轮番轰炸。

还有一类容易被忽略的场景:中间件重试风暴。上游的服务可能配了超时重试,比如 3 次超时重试乘以 3 个上游节点,等于同一条脏数据在数据库面前被放大成 9 次查询。加上缓存又不会记录负面结果,这个风暴会一直持续到有人手动干预。

所以解决穿透不能只靠“临时查一下有没有”的思路,而是要有一个稳定、快速、低内存开销的“存在性判断层”。布隆过滤器恰好就是为这种场景设计的:它能在极小的空间内告诉你一个元素“一定不存在”或者“可能存在”,查询时间复杂度是 O(k),k 通常是个位数。

1.2 穿透、击穿、雪崩:先分清三座大山

做缓存设计最忌讳把三个问题混为一谈。穿透是数据不存在;击穿是数据存在但是缓存刚好过期;雪崩是大批量 key 同时过期或者缓存节点故障。应对手段也不一样:

  • 穿透:布隆过滤器、空值缓存、参数校验
  • 击穿:互斥锁重建缓存、逻辑过期时间延长、热点 key 永不过期
  • 雪崩:过期时间加随机抖动、多级缓存、Redis 高可用

其中只有穿透是“数据本身不存在”造成的,布隆过滤器是这里最治本的手段。后续所有内容,都会围绕这一条主线展开。

2. 布隆过滤器的核心原理与数学设计

要真正用好布隆过滤器,得先把它底层的原理嚼碎。它不太像常规的缓存结构,没有存具体的 key,只存 key 的“指纹特征”。你可以把它想象成一张巨大的只有 0 和 1 的签到表,每个元素来了,会在表上的几个固定位置盖个章;查询的时候,只要发现任何一个位置没有章,就说明这个元素肯定没来过。

2.1 位数组 + 多个哈希函数:不存数据却知道“你来过”

布隆过滤器的存储载体是一个位数组(bit array),初始所有位都是 0。往里加一个元素时,会通过 k 个不同的哈希函数,算出 k 个位下标,然后把这些位全部置为 1。判断一个元素是否存在时,同样算出这 k 个位下标,检查它们是不是全为 1。

这里的关键在于:如果存在任何一个位是 0,那这个元素必然不存在,因为它在添加的时候一定把那几个位都置成 1 了。如果全部位都是 1,那只能说“可能存在”——因为其他元素的哈希结果可能和它发生了碰撞,把同样的位置都占了。

用一个生活化的类比:假设你在一本花名册上,每个人的名字出现时会往几个固定的格子打钩。你查一个人有没有来过,只要发现他应打钩的格子里有一个是空的,就能确定他没来过。但如果所有格子都有钩,你只能说“他可能来过”,因为几个不同的人可能把格子填满了。这就是误判的由来。

我在实际项目里见过不少人误解布隆过滤器,以为它能精确判断存在。它恰恰只能精确判断“不存在”——只要有一个位是 0,答案就是 100% 的不存在。这种特性对缓存穿透来说已经足够了,因为我们真正想拦截的就是“数据库里根儿就没有”的那种请求。

2.2 误判率、容量、哈希次数:三个参数怎么平衡

布隆过滤器的设计有三个核心参数,缺一不可:

  • n:预期元素数量
  • p:可接受的误判率
  • m:位数组的长度
  • k:哈希函数的数量

最优的位数 m 由公式决定:m = -(n * ln(p)) / (ln2)²。最优哈希函数数量 k = (m / n) * ln2。

我带个具体数字走一遍。假设你预估的业务数据量是 100 万,希望把误判率控制在 1%。那么 ln(0.01) ≈ -4.605,ln2 ≈ 0.693,代入公式:

m = -(1000000 * -4.605) / (0.693 * 0.693) ≈ 958 万位 ≈ 1.2MB 内存

k = (9580000 / 1000000) * 0.693 ≈ 6.6,取整为 7

也就是说,100 万 key 的集合,只需要 1.2MB 左右的位数组,配合 7 个哈希函数,就能把误判率控制在 1% 以内。这个空间开销跟直接存 100 万个字符串相比,差距是数量级的。

从公式里你能看到几个规律:误判率 p 越小,m 越大,两者是对数关系,所以想从 1% 压到 0.1%,内存成本并不会翻十倍,但要牺牲一定空间;k 不是越大越好,超过最优值后误判率反而会回升,因为位被置 1 的速度太快了,查询时全 1 的概率变高。我在调参的时候从来不会拍脑袋定 k,都是先把 m 算出来再反推 k,后面实战部分会说具体怎么落地。

3. 从零手写布隆过滤器,再到 Redisson 接入实战

原理讲完,直接上代码。我挑两条路径:一条是用 Java 的 BitSet 手写一个极简版,适合你理解内部机制;另一条是生产环境最常用的 Redisson 的 RBloomFilter,拿来即用,省得自己造轮子。

3.1 用 Java 手写一个可用的布隆过滤器 Demo

import java.util.BitSet; public class SimpleBloomFilter { private final BitSet bits; private final int size; private final int hashCount; public SimpleBloomFilter(int size, int hashCount) { this.size = size; this.hashCount = hashCount; this.bits = new BitSet(size); } public void add(String key) { for (int i = 0; i < hashCount; i++) { int index = hash(key, i); bits.set(index, true); } } public boolean mightContain(String key) { for (int i = 0; i < hashCount; i++) { int index = hash(key, i); if (!bits.get(index)) { return false; } } return true; } private int hash(String key, int seed) { int h = key.hashCode() ^ (seed * 0x9E3779B9); h = h ^ (h >>> 16); // 避免负数下标,用 & 0x7FFFFFFF 做截断 return (h & 0x7FFFFFFF) % size; } public static void main(String[] args) { SimpleBloomFilter filter = new SimpleBloomFilter(1_000_000, 7); filter.add("order_10001"); filter.add("order_10002"); System.out.println("order_10001 -> " + filter.mightContain("order_10001")); System.out.println("order_99999 -> " + filter.mightContain("order_99999")); } }

这个写法有几个细节值得注意。第一,Java 里key.hashCode()可能返回负数,直接取余会得到负数下标,我在这里做了一个符号位截断;第二,多哈希函数的实现不是随机取几个不同算法,而是用同一个 hashCode 配合不同的种子做扰动,这样既保证了计算效率,又能让几个哈希结果尽量不相关;第三,BitSet默认是 64 位内部存储,对于 1 亿量级的位数组依然没有问题,但如果你的 m 超过 Integer.MAX_VALUE,就得考虑分段位图或者换用 Redis 位图了。

手写版的优点是清晰,缺点是生产环境有很多外围问题要处理:怎么持久化、怎么多节点同步、怎么保证线程安全、怎么避免重复重建。所以真实项目里我更推荐直接用现成的中间件实现。

3.2 生产环境接入 Redisson RBloomFilter 的正确姿势

Redisson 内置了 RBloomFilter 的实现,底层是把位数组存储在 Redis 的 String 结构里,利用SUBSTR和SETBIT这类命令操作二进制位。接入成本非常低,而且天然支持分布式环境下的并发读写。

<dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.23.4</version> </dependency>
import org.redisson.Redisson; import org.redisson.api.RBloomFilter; import org.redisson.api.RedissonClient; import org.redisson.config.Config; public class BloomFilterExample { public static void main(String[] args) { Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:6379"); RedissonClient client = Redisson.create(config); RBloomFilter<String> bloomFilter = client.getBloomFilter("productIdBloomFilter"); // 预期 100 万条数据,误判率 1% bloomFilter.tryInit(1_000_000L, 0.01); // 初始化阶段:把存量商品 ID 全部加入 bloomFilter.add("product_10001"); bloomFilter.add("product_10002"); // 查询拦截 boolean exists = bloomFilter.contains("product_10003"); if (!exists) { // 直接返回,不进缓存,不打数据库 return; } // 可能存在,继续走正常的缓存 -> 数据库链路 client.shutdown(); } }

tryInit这个方法很关键。它就像我们上面公式算出来的那组参数:给定位数 m 和哈希函数数量 k,然后初始化位数组。执行完后,你可以通过bloomFilter.getExpectedSize()和bloomFilter.getFalseProbability()校验参数是否生效。

需要注意一点:tryInit只在过滤器不存在时生效,如果 Redis 里已经有这个名字的过滤器,它会直接跳过初始化,不会重置已有数据。这个设计在做发布和回滚时非常有用,但也带来了一个坑——如果你改了预期数据量,应用升级后未必会重建过滤器,后面我在踩坑部分会专门讲。

4. 布隆过滤器在缓存架构中的落地细节

代码只是第一步,真正难的是把它放进整个缓存链路里,并且不出幺蛾子。我在多个项目里落过这个方案,有一些架构层面的决策值得展开聊。

4.1 过滤器放哪里:应用本地还是 Redis

这是一个很容易被犹豫的问题。实现上有两种选择:一种是把布隆过滤器放在每个应用进程内,比如用一个 Guava 的 BloomFilter 或者自研的 BitSet;另一种是像 Redisson 那样放在 Redis 里,多个应用共享同一个过滤器。

我的建议是:如果业务规模不大、只有单机部署,放本地完全够用,性能也更好,一次内存查询微秒级,完全没有网络开销。但只要你的应用是多实例部署,或者有多个下游服务都要做同样的存在性校验,就应该放到 Redis 里。

原因很简单:本地过滤器每个实例一份,某个订单 ID 被写入时,同一个数据在不同实例上的位图是从不同时间点增量更新的,A 实例可能已经加了,B 实例还没加,导致出现“假阴性”——本应存在的 key 被判为不存在,直接拦截了正常请求。这种问题在分布式环境里极难排查,因为它是间歇性的,跟流量分发有关。共享 Redis 里的同一把过滤器就不会有这个问题。

这里有个折中方案,也是我现在比较常用的:本地放一个只读副本用于高频判断,Redis 里放权威版本。通过订阅 Redis 的变更消息或者定时同步,把新增的 key 异步同步到本地副本。判断时先在本地查,查不到且能明确判定不存在就直接返回;如果本地是“可能存在”,再到 Redis 复查一次。这样既能享受本地查询的速度,又能避免多实例数据不一致。当然,这个方案复杂度高了不少,业务量没到那个量级不必强行上。

4.2 数据预热与更新策略:不要等上线了才去初始化

布隆过滤器最大的特点就是不能凭空添加大量数据,得提前把所有合法 key 灌进去。很多团队第一次接的时候,直接在业务启动流程里写了个bloomFilter.add(),线上跑起来才发现:存量几百万条数据根本没进去,所有用户请求都被过滤器误判成不存在了,业务直接“白屏”。

正确的做法是写一个独立的预热脚本,在上线前把全量合法 ID 扫出来,批量写入过滤器。我习惯用分批提交的方式,比如每批 1000 个,避免一次性写入导致 Redis 的 SETBIT 命令过多阻塞。这里尤其要注意,预热脚本和应用发布要分开,先跑脚本,再发应用,中间留一个状态位给运维确认。

数据更新上也要想清楚:业务新增了一个合法 ID,必须在写入数据库的同时也写进布隆过滤器。常见的实现是在业务 Service 层里,DB insert 成功后同步调用 add 接口;或者通过 MQ 异步补写,能减少一次调用的 RT,但要接受极短的延迟窗口——在这个窗口期内,新写入的数据可能会被过滤器误判为不存在。如果业务对这个延迟非常敏感,我建议用同步写,牺牲一点点性能换来一致性。

4.3 误判之后怎么办:兜底拦截与结果协商

布隆过滤器允许“宁可错杀一万,不可放过一个”,所以对“可能存在”的 key,必须继续走完整的缓存和数据库链路。真正过滤掉的是“绝对不存在”的 key,这部分恰好是我们想拦截的穿透流量。

那误判的“可能存在”怎么办?它带来的问题是:一个本来不存在的 key 算出来全 1,于是请求继续往下走,最终还是会打到数据库。这种情况的比例就是误判率 p。比如 p 设定为 1%,意味着 100 个不存在的 key 里,有 1 个会漏网穿到 DB。注意,漏网的前提是它被放行,后面还能靠空值缓存或者限流兜底,所以不会造成灾难性压力。

如果业务对误判极其敏感,有两条路可以走。第一,把 p 设得再低一点,比如 0.01%,代价是位数组更大,哈希函数也更多;第二,给过滤器后面再接一层短时间空值缓存,让漏网的一次性请求被缓存挡住,重复的脏请求依然不会到 DB。我个人经验是,这两者结合最舒服:布隆过滤器挡住 99% 的不存在请求,短时间空值缓存挡住剩下 1% 里的重复部分,数据库收到的脏查询会少得几乎看不见。

5. 踩坑记录与问题排查速查表

布隆过滤器看起来简单,用起来一坑接一坑。我把自己遇到过的、以及帮别人排查过的典型问题整理出来,这些经验在常规文档里基本找不到,希望对你有直接帮助。

5.1 过滤器冷启动,业务数据全军覆没

这是最典型的生产事故。解决方式前面已经说过了:预热脚本必须在应用发布前执行。这里补充一个可落地的流程:

  1. 从数据库全量抽取合法 ID 集合,导出到一份文件或者直接用 SQL 按主键分批扫;
  2. 跑离线脚本,调用 RBloomFilter 的 add 方法批量写入;
  3. 写一个校验任务,随机抽样 1 万个合法 ID,调用 contains 确认结果全部为 true;
  4. 校验通过后再发布新版本应用。

我在第 3 步上吃过亏。当时部署完发现线上低峰期还好,高峰期大量“订单不存在”的报错,一查原来是预热脚本跑完 90% 就强制结束了,少了 10% 的数据没有进去。从那以后,我要求预热的最后一步必须做全量或者抽样的回读验证,宁可多花几分钟,不能省这口气。

5.2 上线后想扩大容量,我却把已有数据弄丢了

布隆过滤器没有删除操作,也没法扩容——一旦在 Redis 里初始化了 m 和 k,修改预期元素量或者误判率都需要重建过滤器。我见过有同事直接删掉旧 key,重建一个新的 RBloomFilter,结果因为预热脚本没跑,线上又白屏了一次。

正确做法是滚动重建:创建一张新名字的过滤器,比如productIdBloomFilter_v2,预热完成后在应用配置里切换过滤器名称,走一个灰度发布开关。切换之后别急着删旧过滤器,保留一段时间,万一新过滤器异常还能回退。这个思路本质上是把布隆过滤器当成一个需要版本管理的“数据表”,而不是一个可以随时更新的内存变量。

5.3 线程安全、Redis 集群兼容与多环境复用

Redisson 的 RBloomFilter 在多线程环境下是安全的,因为底层每个操作都是通过 Redis 命令执行的,相当于天然做了分布式锁。但如果你是自研本地布隆过滤器,多线程添加和查询就要注意 BitSet 本身不是线程安全的,最好加读锁或者使用原子的 set 操作。

另外,Redisson 的 RBloomFilter 在 Redis Cluster 集群模式下是可以正常工作的,它按 key 做哈希分布,只要所有客户端请求同一个 key 就会路由到同一个 slot。但要注意:如果你的 Redis 启用了 Cluster,那么布隆过滤器所在的 key 必须和业务缓存 key 规划好命名空间,避免和集群迁移冲突。

多环境复用上,我建议生产、预发、测试各连接到不同的 Redis 数据库实例或不同 key 前缀,否则测试环境写进去的脏数据会影响生产判断。我在一个项目里见过预发环境往一个共享的 Redis key 里塞了几千条测试商品 ID,结果生产环境查询时布隆过滤器莫名其妙返回 true,让不少无效请求穿了进来。

5.4 快速问题排查速查表

现象可能原因处理方式
所有查询都被快速拦截,业务数据查不到过滤器冷启动,存量数据未预热跑预热脚本,回读校验后灰度切换
过滤器的误判率明显高于设定值哈希数量 k 设置不是最优按公式重算 m 和 k,重建过滤器
多个应用实例判断结果不一致本地布隆过滤器数据未同步改为 Redis 共享过滤器,或用消息同步副本
想扩大容量后数据丢失重建过滤器前未预热采用 v2 重建 + 切换开关方式
大促期间 Redis CPU 异常升高过滤器位数组过大,SETBIT 频繁分批写入、错峰预热、必要时升级本地副本

这张表是我根据多年的运维笔记改写的,不一定覆盖所有场景,但遇到问题先从这五个方向排查,基本能覆盖 80% 的情况。

5.5 一个常被忽略的并发角落:首查之后的写缓存

接入布隆过滤器之后,有一个并发逻辑容易被写崩。假设请求判断为“可能存在”,放行到缓存,缓存没有,于是多个线程同时查了数据库,都拿到空结果,然后各自回去写缓存。这里如果直接把“空值”写回 Redis,因为布隆过滤器本身是按合法 key 校验的,后续同一个非法请求就会因为空值缓存在而被直接挡在缓存层,不会再到数据库——这本来是个好事。

但问题在于,如果你用空值缓存配合布隆过滤器,空值的过期时间必须设置得很短,比如 30 秒,否则一个被误判的 key 会在 Redis 里存活很久,下次它有了新的数据(比如用户 30 分钟后下了单),依然住在缓存里,而布隆过滤器里并不一定包含这个新 key,查询链路会一直读到旧的空值,造成业务上的“幽灵阻断”。

我在一个交易系统里遇到过类似问题:用户刚下单后马上查订单详情,由于布隆过滤器的增量 add 在下单服务里是异步发送的,订单还没同步到过滤器,导致用户第一次查询被“可能存在”放过,随后读到一个空的缓存,30 秒内订单一直显示不存在。后来我们规定:写入数据库成功后必须先同步更新布隆过滤器,再返回业务成功,同时把空值缓存的过期时间压到 10 秒以内,并关闭对新增订单的写空值动作。

这个小细节很多人不会注意,但它是布隆过滤器方案在真实业务中体验感和数据一致性的关键。如果你们在架构评审时只讨论了“用布隆过滤器防穿透”,却没讨论新增数据如何及时加入过滤器、空值缓存如何避免误导,上线后大概率会在数据一致性上翻车。

我个人从这些项目里得到的最深体会是:布隆过滤器不是装上就完事的工具,而是一个需要在发布流程、数据同步、回滚预案上都做配套设计的组件。它解决缓存穿透很有效,但每引入一个“高效判断层”,周边的一致性成本就会往上涨一点。先用公式把参数算清楚,再把预热和增量维护当成一等公民来设计,这套方案才算真正落地了。

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

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

立即咨询