资讯动态

缓存穿透解决方案:布隆过滤器原理、落地实战与误判兜底

发布时间:2026/10/1 3:14:34 来源:尧图企业网站定制
缓存穿透这个事做过后端高并发的人基本都遇到过。数据库CPU飙升、慢查询暴增、缓存命中率掉到谷底查完日志发现全是同一批根本不存在的key在反复闯关。布隆过滤器是我用过最有效的拦截手段之一这篇文章就完整复盘一下它是怎么工作的、怎么落地以及真实生产环境里会踩到哪些坑。这篇文章适合正在用Redis做缓存、又频繁被缓存和数据库都没有的数据请求困扰的后端开发。我会从一次线上事故讲起把原理、Guava本地实现、Redis集中式实现、全链路改造、以及误判控制全部过一遍最后给出一套可以直接抄作业的兜底组合方案。1. 缓存穿透是怎么把 MySQL 打挂的一次线上事故复盘1.1 事故现场查询日志里全是同一个幽灵ID去年某次大促前的压测阶段线上MySQL主库的CPU突然冲到95%慢查询队列里堆满了同一条SQLSELECT * 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.01ln(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 groupIdcom.google.guava/groupId artifactIdguava/artifactId version31.1-jre/version /dependency然后写一个订单查询用的过滤器import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; // 预估元素数量100万期望误判率1% BloomFilterLong 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); RBloomFilterLong 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 1000000BF.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 99999RedisBloom方案的优势是命令直接在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) { ListLong 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救了下来而是终于有精力去排查那些恶意请求到底是从哪个渠道进来的。如果你正被缓存穿透问题困扰我的建议是不要一上来就上布隆过滤器先看日志确认穿透来源把参数校验和空值缓存这两个成本最低的兜底做上然后再接分布式布隆过滤器。这三层组合起来穿透率才能压到真正可控的水平。布隆过滤器不是银弹但它一定是缓存穿透防线上最结实的那道闸门。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑