Redis布隆过滤器实战:从缓存穿透原理到工程落地

发布时间:2026/9/19 3:09:00
Redis布隆过滤器实战:从缓存穿透原理到工程落地 前几天监控面板告警整个组都紧张了一回商品详情接口的 MySQL 慢查询在半小时里翻了三倍。查日志发现一批带着随机商品 ID 的请求绕过了 Redis 缓存每次都真实地打到数据库里。这几乎是最经典的缓存穿透现场。我当时处理的办法就是用布隆过滤器Bloom Filter挡在缓存和数据库之间结合 Redis 位图落地内存只占原来预估的几十分之一效果立竿见影。这篇文章我就从场景、原理、落地和踩坑四个角度把 Redis 布隆过滤器一次讲透。适合正被缓存穿透困扰的后端同学也适合准备 Redis 面试的人在项目介绍里多一个杀手锏。1. 一个让 Redis 内存爆掉的经典场景1.1 缓存穿透攻击成本极低数据库代价极高缓存穿透的描述很简单请求带了一个 Redis 里没有、数据库里也不存在的 key缓存永远不命中每次都穿透到数据库。正常流量里这类请求只是偶发但一旦有人拿脚本遍历不存在的 ID数据库就会被大量无效查询淹没。慢查询开始刷屏连接数被打满最后整个接口都可能雪崩。很多人第一反应是加空值缓存查不到数据就把空值写进 Redis设置几十秒的过期时间。这个方案对“固定几个非法 ID 反复请求”是有效的但碰到攻击者不断生成新 ID 就不好使了。每来一个新的不存在的 ID缓存又得miss一次数据库又得白查一次。本质上空值缓存只是把问题延后并没有解决“如何低代价判断一个数据到底存不存在”这件事。缓存治理里常说的穿透、击穿、雪崩三兄弟布隆过滤器主要治的就是穿透。它站在缓存和数据库中间用极小的内存代价回答一个关键问题这个数据到底存不存在。1.2 用 Set 做存在性判断内存是无底洞没有布隆过滤器的时候最常见的存在性判断方案是把所有合法 ID 塞进 Redis Set用 SISMEMBER 判断。它精确、无误差、支持删除小数据量下体验极好。问题是数据量一大内存立刻变成无底洞。举个例子。假设订单表里有 1000 万条历史数据我把所有订单号UUID36 字节都放进 Redis Set。Redis 的 Set 底层是哈希表每个元素除了存字符串本身还要维护 dictEntry、SDS 头、指针等开销。保守估算单元素内存占用在 80 到 100 字节。1000 万个 UUID 就是 800MB 到 1GB。如果业务一年增长 1000 万三年后这个 Set 要吃掉 2 到 3GB 内存。作为缓存服务器这笔开销非常肉疼。有人会说订单号太长换成自增 ID 用 int 存行不行可以自增 ID 大概 8 字节加哈希表开销单个元素至少还得 40 到 60 字节1000 万条一样要 400MB 以上。而且很多业务里用户手机号、推荐内容 ID、URL 这类字符串根本没法转成 int。量级一旦上去精确集合的代价就是线性增长的躲不掉。1.3 布隆过滤器为什么能“以小博大”布隆过滤器的核心思路是我不存你的真实数据我只存你的哈希痕迹。一个很长的位数组配若干个哈希函数元素来了把哈希结果对着的位置置 1判断的时候看看对应位置是不是 1。它不保存原始数据所以内存只跟位数组长度有关和数据本身的长度无关。1000 万元素在 1% 误判率下只需要大约 11.4MB比 Set 小了差不多两个数量级。代价是什么呢它会有误判一个明明不存在的元素有可能被判断为“可能存在”也就是放过去查询数据库。但它绝对不会把真实存在的元素判断为不存在。这个“宁可放过一千不可错杀一个”的特性恰好就是防穿透最需要的。2. 布隆过滤器的数学底牌位数组与多次哈希的取舍2.1 工作机制插入是置位查询是找缝隙布隆过滤器由一个长度为 m 的位数组和 k 个相互独立的哈希函数组成。插入一个元素 x 时把 x 分别喂给 k 个哈希函数得到 k 个位置全部置为 1。查询一个元素 y 时同样算出 k 个位置只要其中有一个位置是 0就能断定 y 从来没有被插入过如果 k 个位置全是 1只能说 y “可能存在”。理解这里的关键在于“可能有”和“一定有”的不对称。位数组全为 1 时那 k 个位置有可能确实是 y 自己留下的但也有可能是其他元素的哈希结果碰巧占满了这 k 个位置。布隆过滤器之所以有误差就是因为这种哈希碰撞造成的误报。它只可能发生在“查询本来不在集合里的元素”时真实存在于集合里的元素留下的痕迹真实存在查询时必定能通过。用生活化的方式理解位数组就像一面挂满了照片的墙。每张照片代表一个元素的哈希位置。你要找一个人只要墙上某个位置没有他的照片说明他根本没来过但如果所有对应位置都有照片也不能证明就是他自己贴的可能是别人贴满了这些位置。2.2 三个公式和一组可直接抄的参数布隆过滤器有四个变量n 是已插入元素个数m 是位数组长度k 是哈希函数个数p 是误判率。它们之间的近似关系是p ≈ (1 - e^(-kn/m))^k反过来给定元素量 n 和期望误判率 p位数组的最小长度满足m ≈ -n * ln(p) / (ln2)^2哈希函数的最优数量是k ≈ (m / n) * ln2我直接给一组计算好的参数现场估算时抄就行。预计元素量 n误判率 p位数组长度 m内存占用哈希函数个数 k100 万1%958 万 bit约 1.14MB71000 万1%9585 万 bit约 11.4MB71000 万0.1%1.44 亿 bit约 17.2MB101 亿1%9.59 亿 bit约 114MB7注意第三个例子误判率从 1% 降到 0.1%内存只从 11.4MB 涨到 17.2MB。这是因为公式里 m 和 ln(p) 是线性关系每往下压一个数量级位数组只是按比例增加。这也是布隆过滤器很友好的地方你想要的精度越高代价增长是平滑的不会突然爆炸。2.3 为什么不能删除元素标准布隆过滤器不支持删除。原因很简单多个元素可能共享同一个位。删除一个元素时把它对应的 k 个位置全部清零很可能会顺带把另一个元素的哈希位也清了。之后再去查询那个幸存元素得到的结论就会从“可能存在”变成“一定不存在”。这种假阴性杀伤力极大业务直接出现数据丢失的诡异现象。有人会提 Counting Bloom Filter它把位数组换成计数器数组删除时对应位置减一到零才算真正清除。它能解决删除问题但内存开销比标准布隆过滤器高出一个量级计数器本身还可能溢出工程上用得并不多。更常见的是“定期重建”或“新旧双过滤器切换”旧过滤器逐渐退场新过滤器承接新数据整个过程对业务无感。2.4 在 Redis 里的“布隆过滤器”其实是两种东西面试里经常有人把“Redis 布隆过滤器”当成一种独立的数据类型这个说法不太准确。Redis 原生层面并没有专门的布隆数据结构但它提供了针对 string 类型的位操作命令 SETBIT、GETBIT位数组完全可以落在 string 上所以基于原生位图自己实现布隆过滤器是完全可行的。官方模块 RedisBloom 则不一样它在 Redis 里新增了真正的布隆过滤器数据结构存储在里面的 key 类型显示为 MBbloom--用 BF.ADD、BF.EXISTS 操作。日常交流可以说“Redis 布隆过滤器”但面试时能分清“原生位图实现”和“模块实现”是加分项。3. Redis 上实现布隆过滤器的三条路线与选型3.1 路线一原生位图脚本零依赖适合理解原理如果 Redis 是云厂商托管实例不一定允许装模块原生位图就是最通用的方案。核心实现就是 SETBIT 和 GETBIT。下面这个 Python 示例可以直接跑适合学习也适合临时验证想法。# 需要 pip install redis mmh3 import mmh3 import redis class RedisBloomFilter: def __init__(self, client, key, m, k): self.client client self.key key self.m m # 位数组长度 self.k k # 哈希函数个数 def _positions(self, item): # 用双哈希生成 k 个位置保证位置在 [0, m) 内 h1 mmh3.hash(item, seed0) 0xFFFFFFFF h2 mmh3.hash(item, seed42) 0xFFFFFFFF return [(h1 i * h2) % self.m for i in range(self.k)] def add(self, item): for pos in self._positions(item): self.client.setbit(self.key, pos, 1) def exists(self, item): for pos in self._positions(item): if self.client.getbit(self.key, pos) 0: return False return True client redis.Redis(hostlocalhost, port6379, db0) # 1000万元素、1%误判率m≈9585万 bitk≈7 bf RedisBloomFilter(client, bf:order, 95_850_000, 7) bf.add(order:10001) print(bf.exists(order:10001)) # True print(bf.exists(order:99999999)) # 大概率 False小概率 True这个实现有几个点要特别注意。SETBIT 的偏移量上限是 2^32 - 1也就是底层字符串最多 512MB上面 9585 万 bit 约 11.4MB完全够用。另外每次 add 和 exists 都要发 k 次命令网络往返次数多查询量大的话可以改用 Lua 脚本把 k 次 GETBIT 合并成一次调用或者用 BITFIELD 命令一次性读取多个 bit。3.2 路线二RedisBloom 模块生产环境首选如果 Redis 是自建的强烈建议直接用 RedisBloom 模块。启动方式最简单的是 Dockerdocker run -d --name redis-bloom -p 6379:6379 redis/redis-stack-server:latest进入 redis-cli 验证几个核心命令redis-cli BF.RESERVE bf:order 0.01 10000000 OK BF.ADD bf:order order:10001 (integer) 1 BF.EXISTS bf:order order:10001 (integer) 1 BF.EXISTS bf:order order:99999999 (integer) 0 BF.INFO bf:orderBF.RESERVE 后面跟三个参数key、期望误判率、预估容量。一定不要图省事直接 BF.ADD因为不 RESERVE 的话RedisBloom 会按默认 capacity100、error_rate0.01 创建过滤器数据量一超过 100 就开始扩容产生多个子过滤器误判率会偏离预期查询延迟也会变高。内存上要注意BF.RESERVE 会立刻按容量分配位数组内存所以初始化动作尽量放在低峰期避免一次性分配大量内存对实例造成瞬时压力。3.3 路线三Redisson 封装Java 项目最省事Java 项目如果已经用了 Redisson那连哈希函数都不用自己写直接用它封装好的 RBloomFilter。下面是一个使用伪代码RBloomFilterString bf redisson.getBloomFilter(bf:order); // 初始化预估元素数量 1000 万误判率 1% bf.tryInit(10_000_000L, 0.01); bf.add(order:10001); boolean contains bf.contains(order:10001);Redisson 内部把位数组写到 Redis 的 string key 上哈希和位运算都在客户端内存完成Redis 只负责存储位数组。需要注意的是不同版本初始化 API 略有差异有的版本是 tryInit(expectedInsertions, falseProbability)有的版本要拆成 trySetCapacity 和 trySetProbability。写代码之前翻一下当前依赖的版本。3.4 三条路线怎么选我的建议方案依赖性能特点适合场景原生位图脚本无额外依赖每个操作 k 次网络往返可优化学习原理、原型验证、无法安装模块的托管 RedisRedisBloom 模块需要安装模块命令在 Redis 内执行BF.MEXISTS 批量查询自建 Redis 的生产环境首选Redisson 封装引入 Redisson客户端算哈希Redis 只存位数组Java 项目快速集成不能装模块的场景我的判断是能装模块就装模块。RedisBloom 是 Redis 官方生态的模块批量命令和内部优化都更成熟。而原生脚本方案的优势是零依赖适合给团队做技术培训也适合临时兜底。4. 实战用布隆过滤器守住缓存穿透链路4.1 完整链路请求、缓存、布隆、数据库布隆过滤器最常见的落点是在缓存和数据库之间。完整链路是这样的请求带着 ID 进来先查 Redis 缓存。缓存命中的直接返回。缓存未命中不急着查库先查布隆过滤器。布隆过滤器判断“不存在”直接返回空结果这一步挡住绝大多数无效请求。布隆过滤器判断“可能存在”才真正去查 MySQL。MySQL 查到数据就回填缓存查不到就说明是真的不存在或伪造数据返回空结果。把这套流程落到 Java 代码里大概长这样public Object getOrderDetail(String orderId) { String cacheKey order:detail: orderId; Object cached redis.get(cacheKey); if (cached ! null) { return cached; } // 布隆过滤器前置拦截 if (!orderBloomFilter.contains(orderId)) { return null; // 一定不存在直接返回不打 DB } // 到这里说明“可能存在”继续查 DB OrderDO order orderMapper.selectByOrderId(orderId); if (order null) { // 短 TTL 空值缓存防止同一个 ID 反复穿透 redis.set(cacheKey, , 60); return null; } redis.set(cacheKey, JSON.toJSONString(order), 3600); return order; }注意最后那段空值缓存我是建议加的。布隆过滤器已经拦掉了大部分无效请求但仍有小概率把不存在的 ID 放过去。对同一个攻击 ID 来说如果数据库里确实没有数据每次都会被放行去查库这个流量虽然小了但还存在。加一个 60 秒的空值缓存把漏网之鱼再拦一次DB 压力基本归零。4.2 预热是第一步不带存量数据上线是灾难布隆过滤器必须保证“真实存在的数据一定被判断为存在”这个特性成立的前提是这些数据真的被插入过过滤器。如果数据库里本来就有 5000 万条历史订单过滤器却是空的那查询任何一条历史订单都会被判断为不存在直接拒绝返回。这不是布隆过滤器原理上的漏报而是使用姿势错了。所以上线前必须先做预热把存量数据批量导入过滤器。RedisBloom 的 BF.MADD 一次可以添加多个 item效率比单个 BF.ADD 高很多。预热完成之后用 BF.INFO 看一眼当前已插入的元素数量确认和预期匹配了再切换流量。4.3 参数怎么估从业务数据量反推预估元素量 n 的时候把存量、增量和冗余一起算进去。比如当前合法订单量是 5000 万过去一年增长了 1000 万系统还要再服务三年那 n 至少按 8000 万到 1 亿估。宁可多给容量布隆过滤器没有到期时间容量给多了只是多占用一点内存容量给少了就会出现异步扩容、误判率上升这些麻烦。误判率 p 一般取 0.01 就够用。你要理解这个 1% 的含义它不会把真实数据判没只会把 1% 的不存在请求放过去打数据库。也就是说本来有 10000 个无效请求会穿透到 DB使用后只会剩 100 个。如果业务对误判特别敏感或者攻击流量特别大可以把 p 压到 0.001内存从 11.4MB 涨到 17.2MB这点成本几乎无感。4.4 预热之外的回归测试上线布隆过滤器之后一定要做一轮回归测试重点验证真实存在的历史数据会不会被误杀。我一般会写一个校验脚本随机抽几百个存量订单 ID逐个 BF.EXISTS全部返回存在才算通过。这个步骤没什么技术含量但能避免最尴尬的上线事故。5. 我在上线和压测中踩过的坑5.1 位图过大成为 bigkey删除要小心原生位图方案里如果 m 是 9585 万这个 string key 就有 11.4MB。11.4MB 看起来不大但它是一个完整的 valueRedis 是单线程模型查询、持久化、主从同步都要搬运整个 value。量大的时候网络带宽和 CPU 都会受影响。更危险的是删除。如果哪天上线错了要清理这个 key直接 DEL 会阻塞 Redis 一段时间期间所有请求都卡住。Redis 4.0 之后可以用 UNLINK 异步删除但还是那句话能提前规划好 key 的容量和生命周期比事后补救强。5.2 集群模式下的大 key 热点Redis Cluster 里一个 key 只存在一个 slot布隆过滤器作为一个大 string key只落在集群的某一个节点上。如果这个 key 查询特别频繁那个节点就会变成热点其他节点闲着。布隆过滤器天然是整体没法按普通数据那样拆成多个 key 做 hash 分片。一个实践上可行的思路是拆桶把业务 id 哈希后取模分成 16 个布隆过滤器 key比如 bf:order:0 到 bf:order:15。查询时根据 id 的哈希值只查一个桶这样 key 的负载分散了但预热和导入时要多写几段代码。如果过滤器本身只有 11MB单实例也能扛住高并发不一定要拆关键是心里要有这个热点意识。5.3 参数设置里的两个极端过度追求低误判、容量预估不足我见过一些人为了追求“绝对放心”把误判率设成 0.000001位数组长度直接多出好几倍布隆过滤器的内存优势大打折扣。前面说过了1% 的误判率对防穿透场景完全够用流量打进来后只剩 1% 会穿透到下一层这已经是数量级的改善。别为了数据好看去堆资源。反过来容量给少了更麻烦。RedisBloom 在数据量超过 capacity 后会创建新的子过滤器BF.INFO 里的 Number of filters 会大于 1查询时要遍历所有子过滤器误判率上升延迟也变高。所以容量估算时宁可多给 20% 到 30% 的冗余。5.4 序列化方式不一致过滤器等于白做这一点在 Java 项目里特别容易踩。如果你用 Spring Data Redis 的 RedisTemplate 操作原生位图RedisTemplate 的 keySerializer 和 valueSerializer 必须前后一致。添加数据时用 StringRedisSerializer查询时换成 JDK 序列化那同一个逻辑 ID 在 Redis 里的实际字节串就不一样算出来的哈希位置自然也对不上。这会导致一个诡异的结果明明之前 add 过contains 却永远返回 false。排查这种问题很费时间我的习惯是所有布隆过滤器相关的 key 和 item一律显式使用 StringRedisSerializer添加和查询走同一个序列化器不要依赖默认配置。5.5 并发初始化 Redisson 过滤器的问题Redisson 的 RBloomFilter 在第一次调用 tryInit 时会向 Redis 写入过滤器的元信息。多个应用实例同时启动时每个实例都 tryInit就可能出现一个实例写入成功另一个实例读到的配置不一致或抛异常的情况。数据量小的时候不一定复现线上多实例发布时就很随机。解决方式有两种一种是在独立的任务模块里提前初始化发布应用前先跑一次初始化脚本另一种是给初始化动作加分布式锁保证只有一个线程执行 tryInit。我推荐前者简单可控。6. 布隆过滤器与它的邻居们Set、HyperLogLog 怎么选6.1 三个工具回答了三个不同的问题很多人在选型时会纠结布隆过滤器、Set、HyperLogLog其实它们解决的问题并不完全一样。Set 是精确判断“某个元素在不在集合里”支持删除代价是内存随元素量线性增长。HyperLogLog 非常特殊它只回答“集合去重后大约有多少个元素”单 key 最多占用 12KB 内存标准误差 0.81%。但它不能回答“某个具体元素在不在”因为它根本没保存元素内容。布隆过滤器介于两者之间用一部分误判率换内存的大幅下降回答的是“某个元素是否大概率存在”。能力Set布隆过滤器HyperLogLog判断元素是否存在精确有误判不支持统计去重数量可以 SCARD不支持支持误差约 0.81%删除元素支持不支持不支持典型内存1000 万元素约 800MB 以上约 11.4MB约 12KB典型场景秒杀白名单、好友关系防穿透、黑名单、URL 去重UV 统计6.2 组合使用才是常态不要以为三个工具只能选一个。实际生产里经常是组合拳UV 统计用 HyperLogLog防穿透用布隆过滤器精确黑名单用小 Set。我甚至见过有人用 Set 保存“最近一周活跃用户”用布隆过滤器保存“历史全部用户”冷热数据分开处理内存和准确率兼顾。这种组合思路比争论哪个技术更好更有意义。7. 面试怎么答从原理到项目的三层表达7.1 第一层把原理讲成故事面试官问“布隆过滤器了解吗”先别急着抛公式。你可以先用自己的话讲清楚它是什么一个很长的位数组加若干个哈希函数插入一个元素就是把几个位置置 1查询时只要有一个位置是 0就说明一定不存在全部是 1 只能说可能存在。能把这个核心讲明白面试官就知道你是真懂不是背答案。7.2 第二层把参数算给他看接着把误判率公式摆出来p ≈ (1 - e^(-kn/m))^k同时给出 m ≈ -n * ln(p) / (ln2)^2 和 k ≈ (m / n) * ln2。你可以现场算1000 万元素期望误判率 1%位数组长度大约 9585 万 bit约 11.4MB哈希函数数量 7 个误判率降到 0.1%内存约 17.2MB哈希函数数量约 10 个。面试到这基本就会进入下一个深度问题。7.3 第三层把项目讲出取舍面试加分项是把布隆过滤器放进具体业务场景里讲清楚当年的问题和选型逻辑。你可以说当时商品详情接口被随机 ID 打穿 MySQL缓存空值方案挡不住变化多端的 ID于是引入布隆过滤器。预估存量 5000 万、三年后 8000 万误判率取 1%用 RedisBloom 的 BF.RESERVE 初始化上线前批量预热再配合短 TTL 空值缓存兜底。讲的时候把坑也带一句比如序列化不一致、bigkey 删除、容量估算这些细节才是真实项目经验的证据。7.4 追问防御几个高频细节布隆过滤器会不会漏判原理上不会前提是初始化时把所有存量数据都 add 进去了。为什么不能删除位是共享的直接清零会引入假阴性。误判率受什么影响n、m、k 三个变量共同决定k 太少或太多都会让误判率变差。Redis 原生和模块实现有什么区别原生是 string 上的位操作模块是独立数据结构 MBbloom--。数据量特别大怎么办可以拆桶成多个过滤器或者考虑 Counting Bloom Filter 等变体。最后说点个人体会。布隆过滤器不是银弹我见过一些项目数据量只有百万级也硬要上结果又是维护模块又是写脚本收益并不大。我的判断标准很简单集合量在百万以下直接 Set几十 MB 内存完全能接受到了千万级且存在性判断非常频繁再上布隆过滤器如果只是想知道“今天多少独立访客”用 HyperLogLog。希望这篇不只是让你会背公式而是真的能在项目里少踩一个坑。