提起 Redis,很多人第一反应是缓存、分布式锁、排行榜这些熟面孔。但有一个数据结构,平时不起眼,真到缓存穿透打垮数据库、或者几亿条数据要做去重时,你才发现它是救命稻草——没错,就是布隆过滤器(Bloom Filter)。这玩意儿用极小的内存就能判断“一个东西大概率不存在”,虽然它说“不存在”就一定不存在,但它说“存在”却可能是误判。这种概率型数据结构,配合 Redis 使用,简直是高并发场景下的性价比之王。这篇文章我打算从三个层面展开:什么时候该上布隆过滤器、它的核心数学原理到底是什么、以及在 Redis 生态里怎么把它真正落地。我还会把自己踩过的坑和排查思路一并放进来,尤其是参数怎么算、误判率怎么控制、数据多了之后怎么办这些细节。不管你是刚接触这个概念,还是已经在 Spring Boot 项目里接入了 Redis 但不敢确定参数,这篇都值得你花几分钟看完。1. 什么场景会让你想起布隆过滤器?1.1 缓存穿透:数据库的隐形杀手这是布隆过滤器最经典的应用场景。正常情况下,热点数据都缓存到 Redis,查询走缓存就返回了。但总有一些请求带着根本不存在的 ID 打过来,比如恶意攻击者伪造一批不存在的用户 ID,或者前端传参没校验好。这些请求在缓存里查不到,就会一层层打到数据库,数据库也查不到,但每一次查询都是实打实的开销。如果并发一大,数据库连接池直接被打满,整个服务跟着雪崩。以前很多团队的做法是缓存空值:查不到就缓存一个 null,设置短过期时间。但这个方法有两个问题:一是大量不存在的 key 会占满 Redis 内存,虽然设了过期时间,但架不住请求频率高;二是攻击者只要换不同的 ID,空值缓存就形同虚设。布隆过滤器则是把这个判断前置:查询前先走一遍过滤器,如果过滤器说“没有”,直接返回,根本不会去查缓存和数据库。它不仅省了数据库的查询,还省了缓存写入的无效开销。1.2 海量数据去重:用几百 MB 顶几个 G另一个高频场景是去重。比如爬虫系统要判断一个 URL 是否已经抓取过,推荐系统要判断用户是否已经看过某条内容,或者消息系统要判断一条消息 ID 是否被消费过。这些场景有几个共同特点:数据量巨大,动辄几千万甚至几亿条;对少量误判能容忍;同时要求查询效率极高。如果用 Redis 的 Set 或者数据库的唯一索引去重,几亿条数据存下来,内存或者存储的代价非常可观,查询效率也会随数据量增加而下降。布隆过滤器在这类场景几乎是为所欲为:几亿条 URL,可能只需要几百 MB 内存就能搞定,查询复杂度是 O(k),k 是哈希函数个数,一般在 5 到 7 个之间,速度极快。它最妙的一点是,你提前预估一个容量上限,后续不管塞多少条数据,内存占用几乎恒定,代价只是误判率逐渐升高。1.3 已读和曝光过滤这类“宁可错杀”业务你刷信息流的时候,为什么已经看过的内容很少再次出现?布隆过滤器就是这类“已读过滤”的常见实现。它的逻辑是:用户看过一条内容,就把这条内容的 ID 塞进过滤器;推荐系统每次推荐前先查一下,过滤器说存在(注意是可能存在的误判),就跳过不推。这类业务对误判的容忍度很有意思——宁可漏掉一些没看过的新内容(误判成看过),也不想重复推用户已经刷过的内容。因为后者直接影响用户体验。也就是说,它的容错方向是“错杀”而不是“放过”。布隆过滤器天然契合这种需求:它会把某些不存在的元素误判为存在,但绝不会把存在的元素误判为不存在。这意味着,已经看过的一定会被过滤,没看过的只有极少概率被误杀。方向完全对得上。2. 布隆过滤器的原理:一句话讲清它为什么这么强2.1 位数组加哈希函数,两个零件拼出核心原理不要被“概率型数据结构”这种词唬住,布隆过滤器的底层就是一个足够长的二进制位数组(bit array),配上一组哈希函数。初始状态下所有位都是 0。当你插入一个元素时,用 k 个不同的哈希函数分别对这个元素计算哈希值,得到 k 个位置,把这 k 个位置全部置为 1。查询一个元素时,同样算一遍 k 个位置,只要有一个位置是 0,就说明这个元素一定不在集合中;如果 k 个位置全是 1,那只能说明它“可能在”,因为这几个位置可能是其他元素插入时被“抹”成 1 的——这就是误判的来源。你可能会问,为什么用 k 个哈希函数而不是一个?因为一个哈希函数只能映射到一个位,碰撞概率极高。多个哈希函数相当于把元素均匀地“稀释”到位数组的多个位置,任何两个元素共占所有相同位的概率会变得特别低。位数组的长度和哈希函数的数量,直接决定了误判率的大小,这也是后面参数计算的核心。2.2 数学推导:误判率是怎么来的,公式又怎么用假设位数组长度为 m 位,已经插入了 n 个元素,哈希函数个数为 k。任意一个位在插入 n 个元素后仍然为 0 的概率,是每个哈希函数都没映射到它的概率的叠加:一个元素经过 k 个哈希函数处理后,某个特定位置没有被它置为 1 的概率是 (1 - 1/m)^k,近似为 e^(-k/m)。插入 n 个元素后,这个位仍为 0 的概率就是 e^(-nk/m)。那么误判率 p 就是:某一个元素查询时,它的 k 个位置恰好全都被其他元素置为 1 的概率,即:p ≈ (1 - e^(-kn/m))^k这个公式虽然看起来只是几个字母,但它给了我们三个极其有用的工程结论:位数组长度 m 越大,误判率越低,因为位之间更“稀”,碰撞概率下降。哈希函数个数 k 有一个最优值,不是越多越好,太多了反而会让位数组更快被填满。已插入元素数量 n 增大,误判率会非线性上升,所以预估数据量非常重要。当 k 的取值使得误判率最低时,k = (m/n) * ln2,此时误判率约等于 (1 - e^(-kn/m))^k = (1/2)^k。这句话翻译成人话就是:在最优参数下,误判率随着哈希函数个数的增加呈指数级下降。每多一个哈希函数,误判率大约减半。2.3 为什么不支持删除:这个天生缺陷捅过多少人布隆过滤器另一个广为人知的限制是:不能删除元素。原因是,你把某个元素对应位置的位从 1 改回 0,很可能这个位也被其他元素映射过,你一改,其他元素就“消失”了。很多业务最开始不知道这一点,直接把布隆过滤器用在了需要频繁删除的场景中,比如用户取消已读标记、商品下架后移除商品 ID。结果数据删掉之后,过滤器里对应的位还是 1,于是这个已经删除的 ID 仍然会被误判成“存在”。我见过有的团队为了解决这个问题,引入了计数布隆过滤器(Counting Bloom Filter),把每个位从 1 bit 扩展成计数器,删除的时候做减计数。但代价是内存占用翻了数倍,而且计数器还有溢出风险,实际用起来并不轻松。更通用的做法,是定期重建过滤器:把当前有效数据重新刷一遍,或者干脆设计成分片 + 双缓冲,一边用一边重建。具体怎么做,我放到后面的实战环节再展开。3. Redis 里布隆过滤器的三种落地姿势3.1 方式一:用 Redis bitmap 手写布隆过滤器Redis 本身没有内置布隆过滤器数据结构,但它提供了 bitmap 操作命令 SETBIT 和 GETBIT,底层是字符串类型,按位操作,非常高效。自己动手实现一个成本很低,也就是封装几个方法:插入元素时,用 k 个哈希函数算出 k 个位置,分别执行 SETBIT;查询时,对 k 个位置执行 GETBIT,只要有一个返回 0,就判断不存在。这里有个关键点:哈希函数怎么设计。你可以用 MD5、SHA-1 这类常规哈希算法,对元素先做一次哈希,得到一个 128 位或者更长的哈希值,然后截取不同片段来模拟多个独立的哈希函数。一个更通用的做法是使用双重哈希(double hashing)技巧:定义两个基础哈希函数 h1(x) 和 h2(x),第 i 个哈希函数为 h1(x) + i * h2(x)。这样只需要算两次哈希,就能得到任意数量的哈希函数位,而且分布质量足够好。我直接用 Java 写个统一封装的例子,你们感受一下核心逻辑:public class RedisBloomFilter { private static final String KEY_PREFIX = "bloom:"; private static final long BASE = 1024L; private Jedis jedis; /** * 计算哈希位置 * @param element 原始值 * @param m 位数组长度 * @param k 哈希函数个数 */ private long[] hashPositions(String element, long m, int k) { long[] positions = new long[k]; // 两阶段哈希 long h1 = Math.abs(MurmurHash.hash64(element, 0)); long h2 = Math.abs(MurmurHash.hash64(element, 1)); for (int i = 0; i < k; i++) { positions[i] = Math.abs((h1 + i * h2) % m); } return positions; } /** * 向布隆过滤器添加元素 */ public void add(String key, String element, long m, int k) { long[] positions = hashPositions(element, m, k); String redisKey = KEY_PREFIX + key; for (long pos : positions) { jedis.setbit(redisKey, pos, true); } } /** * 判断元素是否存在(可能误判) */ public boolean exists(String key, String element, long m, int k) { long[] positions = hashPositions(element, m, k); String redisKey = KEY_PREFIX + key; for (long pos : positions) { if (!jedis.getbit(redisKey, pos)) { return false; } } return true; } }这种方式的好处是零依赖,无论你用的什么 Redis 版本,只要支持 SETBIT 就能跑。坏处也很明显:所有参数(m、k)都要自己在业务代码里定义,而且每次添加和查询都是多条命令,如果涉及批量操作,性能上会有额外开销。更关键的是,使用公用的位数组时,一旦数组长度设置过大,SETBIT 的 offset 很大,虽然在字符串上的操作仍然是 O(1),但内部会有一定的内存碎片风险。3.2 方式二:RedisBloom 模块,官方能力一步到位如果不想重复造轮子,业界更通用的方案是加载 RedisBloom 模块。这个模块给 Redis 增加了原生的布隆过滤器类型,命令也很简洁,BF.ADD、BF.MADD、BF.EXISTS、BF.MEXISTS,以及用于初始化参数的 BF.RESERVE。使用 RedisBloom 的好处,首先是原生的 C 语言实现,性能远比自己 Lua 脚本模拟高;其次是容量和误判率由模块内部自动管理,你只需要在创建时指定预期数据量和误判率,剩下的事它自己搞定。这对我这种对数学公式没那么敏感的人来说,简直是福音。核心操作示例:# 创建过滤器,预期数据量 1000 万,误判率 1% BF.RESERVE myfilter 0.01 10000000 # 添加元素 BF.ADD myfilter "user:12345" # 批量添加 BF.MADD myfilter "user:12346" "user:12347" "user:12348" # 判断是否存在 BF.EXISTS myfilter "user:12345" # 批量判断 BF.MEXISTS myfilter "user:12346" "user:99999"这里有个细节要提醒:BF.RESERVE 只能在过滤器创建之时设置参数,如果过滤器已经存在,再执行 BF.RESERVE 会直接报错。所以要提前规划好数据规模,不要在线上跑了一个月之后才后悔当初参数设得太小。Java 侧对接 RedisBloom 也很简单,如果你是 Spring Boot + Lettuce,可以直接调用原生命令。Jedis 也有对应的 BF 类接口,在较新版本里已经内置了布隆过滤器相关方法,不用搞什么花活。实测下来,用 RedisBloom 做几十亿级别的去重,单机 Redis 支撑 QPS 到达几万完全没压力。3.3 方式三:本地布隆过滤器 + Redis,两级缓存拦截第三种方式是我个人在高并发项目里比较偏爱的一种:在应用本地内存里再放一个布隆过滤器作为一级判断,Redis 的布隆过滤器作为二级判断。为什么这么设计?因为纯 Redis 布隆过滤器虽然快,但每次查询都要走一次网络 RTT。如果请求量极大,这个网络开销会累积成一个不小的瓶颈。而本地内存里的布隆过滤器能做到零网络消耗,判断一个元素不存在时,直接在本地就拦截掉了。只有本地判断“可能存在”时,才会继续查 Redis 的过滤器做二次确认。具体的做法是:在本地用 Guava 的 BloomFilter,或者自己用 BitSet 实现一个,缓存几千万条数据的映射关系;每秒钟周期性地把新增数据同步到 Redis 布隆过滤器。当本地过滤器和 Redis 过滤器同时判断“存在”时,才放行到下游做真正的查询。要注意的是,本地布隆过滤器的容量不宜过大,否则 JVM GC 压力会飙升,我自己一般控制在千万级以内,配合 Redis 层处理更多数据。这个方案的典型收益是:绝大部分不存在的请求在本地就被拦截,Redis QPS 压力大幅下降,整体吞吐量有显著提升。代价是代码复杂度更高,数据同步逻辑需要格外小心,否则会出现本地过滤器和 Redis 过滤器不一致的情况。3.4 三种方式的选型对比维度Redis bitmap 手写RedisBloom 模块本地 + Redis 两级内存效率中,需要自己精确控制位数组长度高,模块内部优化较好较高,本地容量受限于 JVM 内存性能中,多次 RTT高,原生命令支持批量最高,本地直接拦截运维依赖无额外依赖需要加载模块,升级 Redis 时需注意版本兼容需要规划数据同步机制适用场景简单场景、不想引入额外依赖的小项目大规模去重、对性能要求高的场景超高 QPS、成本敏感的头部业务一句话总结:个人开发或者原型验证,bitmap 手写就够了;企业级生产环境,尽早引入 RedisBloom;如果你所在的公司对成本极其敏感且 QPS 已经大到了离谱的程度,再考虑两级方案。4. 参数怎么算:m、k 和数据量之间的平衡艺术4.1 已知 n 和 p,反推 m 和 k布隆滤波器工程落地最核心的,其实是动手算参数这一步。很多新手直接照抄网上的 BF.RESERVE 参数,一份配置跑遍所有业务,结果数据量一上来误判率飙升,出了事还找不到原因。计算过程并不复杂。已知预期的元素数量 n 和可接受的误判率 p,按照以下公式就能得到最优参数:位数组长度 m 的计算公式:m = - (n * ln p) / (ln 2)^2哈希函数个数 k 的计算公式:k = (m / n) * ln 2我用一个真实场景举例。假设你的业务是过滤 1 亿条用户 ID,要求误判率不超过 1%(也就是 p=0.01):m = -(100000000 * ln 0.01) / (ln 2)^2 ≈ -(100000000 * (-4.60517)) / 0.48045 ≈ 958505838 位换算成内存是 958505838 / 8 / 1024 / 1024 ≈ 114.2 MB。你看,一亿条数据,只需要 114 MB 的内存就能换来 1% 的误判率。要是用 Set 存这一亿条 ID,内存起码是这个数字的几十倍。继续算 k:k = (958505838 / 100000000) * 0.693 ≈ 6.64,取整为 7。也就是需要 7 个哈希函数。这个组合的含义是:在误判率 1% 的前提下,每查询一个元素最多做 7 次哈希计算和 7 次内存位判断,速度极快。如果想把误判率降到 0.1%,m 会变成大概 1.43 GB,k 依然是 7 左右。你可以看到,误判率降一个数量级,内存基本上要翻十倍,这就是概率型结构的本质——你不可能既要极低误判率又要极小内存。4.2 数据量预估失误是头号坑参数计算有一个致命前提:n 的预估必须靠谱。但业务数据量往往不是一个固定值,它会随着运营活动、用户增长而持续上涨。很多人建过滤器的时候,n 取了当前数据的 1.2 倍,结果半年后数据量翻倍,误判率就从 1% 涨到了 10% 甚至更高。这不是过滤器坏了,而是当初的参数已经没法满足新数据的需要了。我有一个比较实用的建议:如果可以接受内存开销,n 按照未来两到三年的规模去估,宁可让初始误判率低一点,也不要中途发现不够用。如果内存真的紧张,那就设计一套定期重建的机制。比如每周跑一个离线任务,把当前全量数据重新刷到一个新的布隆过滤器里,然后把 Redis 里的 key 切换到新的过滤器上,再删掉旧的。这个过程中要注意双写:切换期间新增的数据要同时写到新旧过滤器,避免切换窗口期漏数据。4.3 误判率的上限设计:业务容忍度才是决定因素参数设计的另一个关键,是要搞清楚业务实际能容忍多大的误判率,而不是蒙一个 1% 或者 0.1%。缓存穿透场景中,误判率对应的是“有多少请求会穿透过滤器打到数据库”,1% 的误判率就意味着 1% 的无效请求会打到下游。如果你的下游扛得住这 1% 的请求,那就没必要为了降到 0.1% 多花十倍内存。已读过滤场景中,误判率对应的是“有多少没看过的新内容会被误判成已读”,对用户体验的影响要重得多,这时候就建议把 p 设到 0.1% 甚至 0.01%。这里我给一个计算内存预算的经验公式:byte = (n * -ln(p)) / (8 * 0.693^2)。这个是直接从 m 的公式换算到字节的,方便你在心里快速做估算。5. 实战中遇到的坑:记录一些真正见血的问题5.1 为什么误判率比理论值高太多?这是我被问得最多的问题。你按公式算好了 m 和 k,误判率理论上应该是 0.1%,但跑了一段时间之后,统计出来的误判率是 1% 甚至更高。原因通常有三个:第一,n 超过了你创建时预估的数据量。过滤器一旦满了,误判率会指数上升。解决办法就是定期统计已插入的元素数量,如果超过了预估值的 80%,就要立刻准备扩容或重建。第二,哈希函数选得不好,导致位分布不均匀。有的团队自己写的哈希函数质量差,多个哈希函数的输出有相关性,结果某些位反复被置 1,某些位从头到尾都是 0。解决办法简单粗暴:优先使用经受过大量验证的哈希算法,比如 MurmurHash 或者 CityHash,不要自己发明哈希函数。第三,bitmap 方式手写时用错了位计数方式,比如位数组长度 m 取得太小,而数据量又特别大,所有位都被置 1 了。这种情况过滤器会退化成为“永远返回存在”,也就是说全部误判,基本等于报废。5.2 数据删除后过滤器失效怎么办?前面提到布隆过滤器不能删除,这在业务里一定会踩雷。举个例子:你在电商系统里用布隆过滤器判断商品 ID 是否已下架,但是商家重新上架了同一个商品 ID,照理它应该能通过过滤器查询,但过滤器里那个 ID 对应的位还是 1,于是这个商品永远被认为是“已下架”,造成了实际的一个漏单事故。面对这种必须支持删除的诉求,可以按时间衰减的方式切分多个布隆过滤器:比如用月维度建一个过滤器,数据只进当月的过滤器,查询时同时查当前月和上月的过滤器,这样上个月的数据自然退化掉。也可以用 Cuckoo Filter(布谷鸟过滤器)替代,它支持删除元素,但 Redis 原生没有实现,需要引入额外的模块或者自己实现,复杂度又上去了。根据我的实践,如果在初期就意识到需要删除操作,优先考虑是否能用“不删除”的逻辑重新设计一下业务规则,往往比在技术上硬解更实惠。5.3 RedisBloom 模块的一些小坑RedisBloom 虽然好用,但也有一些细节容易掉坑。比如误判率参数你设成 0.01,模块内部会按照浮点数处理,但有的旧版本模块对 p 的取值有下限限制,比如不能小于 0.001,你传个 0.0001 进去它会在运行时报错。还有升级 Redis 的时候,因为 RedisBloom 是外部模块,必须确认模块版本和 Redis 版本兼容才能加载,否则可能导致 Redis 进程启动失败。我就曾经有过一次线上事故,因为安全扫描要求升级 Redis 版本,结果旧版模块在新版 Redis 上无法加载,好在及时发现回滚了 Redis 版本,才没有造成更长时间的业务中断。另外,RedisBloom 的容量也不是无限大的。如果你在 BF.RESERVE 里设置了 capacity=10000000,实际插入超过这个值后,过滤器不会报错,而是误判率开始不受控制地上涨。这类“不报错但劣化”的行为最可怕,有时候还是靠监控误判率才发现的。我个人的习惯是每天定时用 BF.INFO 查看过滤器的当前容量使用情况,超过 80% 就启动扩容预案。5.4 常见问题排查速查表现象可能原因解决思路误判率异常升高插入量超过预估容量重建过滤器,增大 capacity 或降低 p 值明明刚插入却查不到写入了不同的 Redis key统一 key 命名规范,检查是否存在 key 前缀不匹配过滤器无法创建key 已被使用BF.RESERVE 只对不存在的 key 生效,换新 key 或先 DELRedis 启动失败RedisBloom 版本不兼容升级模块到与 Redis 匹配的版本,启动前用 MODULE LIST 验证数据量未超但误判率偏高哈希函数质量差更换为 MurmurHash 等成熟哈希算法内存占用过大capacity 设置过大或位数过多根据公式重新估算 m,避免无脑调低 p6. 实战里的设计心得与扩展思路布隆过滤器本质上是一个“乐观赌徒”式结构,用极小的内存换取一个“概率性的确定性”。它的应用边界非常明显:误判方向固定,但不支持删除;数据量预估准确与否,直接影响服务质量。我在实际项目中总结出几条经验,可能对你有参考价值。第一,布隆过滤器适合做入口拦截,而不是唯一依据。所有判断为“存在”的请求,还是要继续走原逻辑做精确校验的。比如缓存穿透场景中,过滤器说“存在”的 ID 再去查缓存和数据库,过滤器说“不存在”的直接拒绝。这样设计,误判不会造成数据错误,只是多了一次本来可以省略的查询。第二,容灾设计上,建议加一个降级开关。如果 Redis 突然不可用,布隆过滤器查询失败时,可以选择放行所有请求,让下游缓存和数据库去承担压力,而不是把整个服务卡死在过滤器这一层。第三,多数据源最终一致性场景下,可以考虑加上消息队列异步同步数据到过滤器,避免同步接口阻塞主流程。最后再分享一个小技巧:你可以为不同的 key 做分片。一个大过滤器出了问题,影响面巨大。但如果拆成 10 个小过滤器,每个负责一部分数据,某个出问题的时候就能快速定位,也能单独重建,不用全量重刷。我碰到过一个场景,把用户 ID 按 UID 的哈希值拆到了 16 个分片,单个分片的数据量波动小,误判率也稳定得多。这种设计和数据库分库分表的思路异曲同工,都是通过拆散隔离故障边界,让系统变得更可维护。布隆过滤器不是什么黑魔法,它的核心价值就一句话:在可接受误判的前提下,用极低成本拦截“确定不存在”的访问。把这个认知落实到位,选对落地方式、算对参数、做好容量规划和降级预案,它就能成为你高并发架构里一块非常扎实的基石。
提起 Redis,很多人第一反应是缓存、分布式锁、排行榜这些熟面孔。但有一个数据结构,平时不起眼,真到缓存穿透打垮数据库、或者几亿条数据要做去重时,你才发现它是救命稻草——没错,就是布隆过滤器(Bloom Filter)。这玩意儿用极小的内存就能判断“一个东西大概率不存在”,虽然它说“不存在”就一定不存在,但它说“存在”却可能是误判。这种概率型数据结构,配合 Redis 使用,简直是高并发场景下的性价比之王。这篇文章我打算从三个层面展开:什么时候该上布隆过滤器、它的核心数学原理到底是什么、以及在 Redis 生态里怎么把它真正落地。我还会把自己踩过的坑和排查思路一并放进来,尤其是参数怎么算、误判率怎么控制、数据多了之后怎么办这些细节。不管你是刚接触这个概念,还是已经在 Spring Boot 项目里接入了 Redis 但不敢确定参数,这篇都值得你花几分钟看完。1. 什么场景会让你想起布隆过滤器?1.1 缓存穿透:数据库的隐形杀手这是布隆过滤器最经典的应用场景。正常情况下,热点数据都缓存到 Redis,查询走缓存就返回了。但总有一些请求带着根本不存在的 ID 打过来,比如恶意攻击者伪造一批不存在的用户 ID,或者前端传参没校验好。这些请求在缓存里查不到,就会一层层打到数据库,数据库也查不到,但每一次查询都是实打实的开销。如果并发一大,数据库连接池直接被打满,整个服务跟着雪崩。以前很多团队的做法是缓存空值:查不到就缓存一个 null,设置短过期时间。但这个方法有两个问题:一是大量不存在的 key 会占满 Redis 内存,虽然设了过期时间,但架不住请求频率高;二是攻击者只要换不同的 ID,空值缓存就形同虚设。布隆过滤器则是把这个判断前置:查询前先走一遍过滤器,如果过滤器说“没有”,直接返回,根本不会去查缓存和数据库。它不仅省了数据库的查询,还省了缓存写入的无效开销。1.2 海量数据去重:用几百 MB 顶几个 G另一个高频场景是去重。比如爬虫系统要判断一个 URL 是否已经抓取过,推荐系统要判断用户是否已经看过某条内容,或者消息系统要判断一条消息 ID 是否被消费过。这些场景有几个共同特点:数据量巨大,动辄几千万甚至几亿条;对少量误判能容忍;同时要求查询效率极高。如果用 Redis 的 Set 或者数据库的唯一索引去重,几亿条数据存下来,内存或者存储的代价非常可观,查询效率也会随数据量增加而下降。布隆过滤器在这类场景几乎是为所欲为:几亿条 URL,可能只需要几百 MB 内存就能搞定,查询复杂度是 O(k),k 是哈希函数个数,一般在 5 到 7 个之间,速度极快。它最妙的一点是,你提前预估一个容量上限,后续不管塞多少条数据,内存占用几乎恒定,代价只是误判率逐渐升高。1.3 已读和曝光过滤这类“宁可错杀”业务你刷信息流的时候,为什么已经看过的内容很少再次出现?布隆过滤器就是这类“已读过滤”的常见实现。它的逻辑是:用户看过一条内容,就把这条内容的 ID 塞进过滤器;推荐系统每次推荐前先查一下,过滤器说存在(注意是可能存在的误判),就跳过不推。这类业务对误判的容忍度很有意思——宁可漏掉一些没看过的新内容(误判成看过),也不想重复推用户已经刷过的内容。因为后者直接影响用户体验。也就是说,它的容错方向是“错杀”而不是“放过”。布隆过滤器天然契合这种需求:它会把某些不存在的元素误判为存在,但绝不会把存在的元素误判为不存在。这意味着,已经看过的一定会被过滤,没看过的只有极少概率被误杀。方向完全对得上。2. 布隆过滤器的原理:一句话讲清它为什么这么强2.1 位数组加哈希函数,两个零件拼出核心原理不要被“概率型数据结构”这种词唬住,布隆过滤器的底层就是一个足够长的二进制位数组(bit array),配上一组哈希函数。初始状态下所有位都是 0。当你插入一个元素时,用 k 个不同的哈希函数分别对这个元素计算哈希值,得到 k 个位置,把这 k 个位置全部置为 1。查询一个元素时,同样算一遍 k 个位置,只要有一个位置是 0,就说明这个元素一定不在集合中;如果 k 个位置全是 1,那只能说明它“可能在”,因为这几个位置可能是其他元素插入时被“抹”成 1 的——这就是误判的来源。你可能会问,为什么用 k 个哈希函数而不是一个?因为一个哈希函数只能映射到一个位,碰撞概率极高。多个哈希函数相当于把元素均匀地“稀释”到位数组的多个位置,任何两个元素共占所有相同位的概率会变得特别低。位数组的长度和哈希函数的数量,直接决定了误判率的大小,这也是后面参数计算的核心。2.2 数学推导:误判率是怎么来的,公式又怎么用假设位数组长度为 m 位,已经插入了 n 个元素,哈希函数个数为 k。任意一个位在插入 n 个元素后仍然为 0 的概率,是每个哈希函数都没映射到它的概率的叠加:一个元素经过 k 个哈希函数处理后,某个特定位置没有被它置为 1 的概率是 (1 - 1/m)^k,近似为 e^(-k/m)。插入 n 个元素后,这个位仍为 0 的概率就是 e^(-nk/m)。那么误判率 p 就是:某一个元素查询时,它的 k 个位置恰好全都被其他元素置为 1 的概率,即:p ≈ (1 - e^(-kn/m))^k这个公式虽然看起来只是几个字母,但它给了我们三个极其有用的工程结论:位数组长度 m 越大,误判率越低,因为位之间更“稀”,碰撞概率下降。哈希函数个数 k 有一个最优值,不是越多越好,太多了反而会让位数组更快被填满。已插入元素数量 n 增大,误判率会非线性上升,所以预估数据量非常重要。当 k 的取值使得误判率最低时,k = (m/n) * ln2,此时误判率约等于 (1 - e^(-kn/m))^k = (1/2)^k。这句话翻译成人话就是:在最优参数下,误判率随着哈希函数个数的增加呈指数级下降。每多一个哈希函数,误判率大约减半。2.3 为什么不支持删除:这个天生缺陷捅过多少人布隆过滤器另一个广为人知的限制是:不能删除元素。原因是,你把某个元素对应位置的位从 1 改回 0,很可能这个位也被其他元素映射过,你一改,其他元素就“消失”了。很多业务最开始不知道这一点,直接把布隆过滤器用在了需要频繁删除的场景中,比如用户取消已读标记、商品下架后移除商品 ID。结果数据删掉之后,过滤器里对应的位还是 1,于是这个已经删除的 ID 仍然会被误判成“存在”。我见过有的团队为了解决这个问题,引入了计数布隆过滤器(Counting Bloom Filter),把每个位从 1 bit 扩展成计数器,删除的时候做减计数。但代价是内存占用翻了数倍,而且计数器还有溢出风险,实际用起来并不轻松。更通用的做法,是定期重建过滤器:把当前有效数据重新刷一遍,或者干脆设计成分片 + 双缓冲,一边用一边重建。具体怎么做,我放到后面的实战环节再展开。3. Redis 里布隆过滤器的三种落地姿势3.1 方式一:用 Redis bitmap 手写布隆过滤器Redis 本身没有内置布隆过滤器数据结构,但它提供了 bitmap 操作命令 SETBIT 和 GETBIT,底层是字符串类型,按位操作,非常高效。自己动手实现一个成本很低,也就是封装几个方法:插入元素时,用 k 个哈希函数算出 k 个位置,分别执行 SETBIT;查询时,对 k 个位置执行 GETBIT,只要有一个返回 0,就判断不存在。这里有个关键点:哈希函数怎么设计。你可以用 MD5、SHA-1 这类常规哈希算法,对元素先做一次哈希,得到一个 128 位或者更长的哈希值,然后截取不同片段来模拟多个独立的哈希函数。一个更通用的做法是使用双重哈希(double hashing)技巧:定义两个基础哈希函数 h1(x) 和 h2(x),第 i 个哈希函数为 h1(x) + i * h2(x)。这样只需要算两次哈希,就能得到任意数量的哈希函数位,而且分布质量足够好。我直接用 Java 写个统一封装的例子,你们感受一下核心逻辑:public class RedisBloomFilter { private static final String KEY_PREFIX = "bloom:"; private static final long BASE = 1024L; private Jedis jedis; /** * 计算哈希位置 * @param element 原始值 * @param m 位数组长度 * @param k 哈希函数个数 */ private long[] hashPositions(String element, long m, int k) { long[] positions = new long[k]; // 两阶段哈希 long h1 = Math.abs(MurmurHash.hash64(element, 0)); long h2 = Math.abs(MurmurHash.hash64(element, 1)); for (int i = 0; i < k; i++) { positions[i] = Math.abs((h1 + i * h2) % m); } return positions; } /** * 向布隆过滤器添加元素 */ public void add(String key, String element, long m, int k) { long[] positions = hashPositions(element, m, k); String redisKey = KEY_PREFIX + key; for (long pos : positions) { jedis.setbit(redisKey, pos, true); } } /** * 判断元素是否存在(可能误判) */ public boolean exists(String key, String element, long m, int k) { long[] positions = hashPositions(element, m, k); String redisKey = KEY_PREFIX + key; for (long pos : positions) { if (!jedis.getbit(redisKey, pos)) { return false; } } return true; } }这种方式的好处是零依赖,无论你用的什么 Redis 版本,只要支持 SETBIT 就能跑。坏处也很明显:所有参数(m、k)都要自己在业务代码里定义,而且每次添加和查询都是多条命令,如果涉及批量操作,性能上会有额外开销。更关键的是,使用公用的位数组时,一旦数组长度设置过大,SETBIT 的 offset 很大,虽然在字符串上的操作仍然是 O(1),但内部会有一定的内存碎片风险。3.2 方式二:RedisBloom 模块,官方能力一步到位如果不想重复造轮子,业界更通用的方案是加载 RedisBloom 模块。这个模块给 Redis 增加了原生的布隆过滤器类型,命令也很简洁,BF.ADD、BF.MADD、BF.EXISTS、BF.MEXISTS,以及用于初始化参数的 BF.RESERVE。使用 RedisBloom 的好处,首先是原生的 C 语言实现,性能远比自己 Lua 脚本模拟高;其次是容量和误判率由模块内部自动管理,你只需要在创建时指定预期数据量和误判率,剩下的事它自己搞定。这对我这种对数学公式没那么敏感的人来说,简直是福音。核心操作示例:# 创建过滤器,预期数据量 1000 万,误判率 1% BF.RESERVE myfilter 0.01 10000000 # 添加元素 BF.ADD myfilter "user:12345" # 批量添加 BF.MADD myfilter "user:12346" "user:12347" "user:12348" # 判断是否存在 BF.EXISTS myfilter "user:12345" # 批量判断 BF.MEXISTS myfilter "user:12346" "user:99999"这里有个细节要提醒:BF.RESERVE 只能在过滤器创建之时设置参数,如果过滤器已经存在,再执行 BF.RESERVE 会直接报错。所以要提前规划好数据规模,不要在线上跑了一个月之后才后悔当初参数设得太小。Java 侧对接 RedisBloom 也很简单,如果你是 Spring Boot + Lettuce,可以直接调用原生命令。Jedis 也有对应的 BF 类接口,在较新版本里已经内置了布隆过滤器相关方法,不用搞什么花活。实测下来,用 RedisBloom 做几十亿级别的去重,单机 Redis 支撑 QPS 到达几万完全没压力。3.3 方式三:本地布隆过滤器 + Redis,两级缓存拦截第三种方式是我个人在高并发项目里比较偏爱的一种:在应用本地内存里再放一个布隆过滤器作为一级判断,Redis 的布隆过滤器作为二级判断。为什么这么设计?因为纯 Redis 布隆过滤器虽然快,但每次查询都要走一次网络 RTT。如果请求量极大,这个网络开销会累积成一个不小的瓶颈。而本地内存里的布隆过滤器能做到零网络消耗,判断一个元素不存在时,直接在本地就拦截掉了。只有本地判断“可能存在”时,才会继续查 Redis 的过滤器做二次确认。具体的做法是:在本地用 Guava 的 BloomFilter,或者自己用 BitSet 实现一个,缓存几千万条数据的映射关系;每秒钟周期性地把新增数据同步到 Redis 布隆过滤器。当本地过滤器和 Redis 过滤器同时判断“存在”时,才放行到下游做真正的查询。要注意的是,本地布隆过滤器的容量不宜过大,否则 JVM GC 压力会飙升,我自己一般控制在千万级以内,配合 Redis 层处理更多数据。这个方案的典型收益是:绝大部分不存在的请求在本地就被拦截,Redis QPS 压力大幅下降,整体吞吐量有显著提升。代价是代码复杂度更高,数据同步逻辑需要格外小心,否则会出现本地过滤器和 Redis 过滤器不一致的情况。3.4 三种方式的选型对比维度Redis bitmap 手写RedisBloom 模块本地 + Redis 两级内存效率中,需要自己精确控制位数组长度高,模块内部优化较好较高,本地容量受限于 JVM 内存性能中,多次 RTT高,原生命令支持批量最高,本地直接拦截运维依赖无额外依赖需要加载模块,升级 Redis 时需注意版本兼容需要规划数据同步机制适用场景简单场景、不想引入额外依赖的小项目大规模去重、对性能要求高的场景超高 QPS、成本敏感的头部业务一句话总结:个人开发或者原型验证,bitmap 手写就够了;企业级生产环境,尽早引入 RedisBloom;如果你所在的公司对成本极其敏感且 QPS 已经大到了离谱的程度,再考虑两级方案。4. 参数怎么算:m、k 和数据量之间的平衡艺术4.1 已知 n 和 p,反推 m 和 k布隆滤波器工程落地最核心的,其实是动手算参数这一步。很多新手直接照抄网上的 BF.RESERVE 参数,一份配置跑遍所有业务,结果数据量一上来误判率飙升,出了事还找不到原因。计算过程并不复杂。已知预期的元素数量 n 和可接受的误判率 p,按照以下公式就能得到最优参数:位数组长度 m 的计算公式:m = - (n * ln p) / (ln 2)^2哈希函数个数 k 的计算公式:k = (m / n) * ln 2我用一个真实场景举例。假设你的业务是过滤 1 亿条用户 ID,要求误判率不超过 1%(也就是 p=0.01):m = -(100000000 * ln 0.01) / (ln 2)^2 ≈ -(100000000 * (-4.60517)) / 0.48045 ≈ 958505838 位换算成内存是 958505838 / 8 / 1024 / 1024 ≈ 114.2 MB。你看,一亿条数据,只需要 114 MB 的内存就能换来 1% 的误判率。要是用 Set 存这一亿条 ID,内存起码是这个数字的几十倍。继续算 k:k = (958505838 / 100000000) * 0.693 ≈ 6.64,取整为 7。也就是需要 7 个哈希函数。这个组合的含义是:在误判率 1% 的前提下,每查询一个元素最多做 7 次哈希计算和 7 次内存位判断,速度极快。如果想把误判率降到 0.1%,m 会变成大概 1.43 GB,k 依然是 7 左右。你可以看到,误判率降一个数量级,内存基本上要翻十倍,这就是概率型结构的本质——你不可能既要极低误判率又要极小内存。4.2 数据量预估失误是头号坑参数计算有一个致命前提:n 的预估必须靠谱。但业务数据量往往不是一个固定值,它会随着运营活动、用户增长而持续上涨。很多人建过滤器的时候,n 取了当前数据的 1.2 倍,结果半年后数据量翻倍,误判率就从 1% 涨到了 10% 甚至更高。这不是过滤器坏了,而是当初的参数已经没法满足新数据的需要了。我有一个比较实用的建议:如果可以接受内存开销,n 按照未来两到三年的规模去估,宁可让初始误判率低一点,也不要中途发现不够用。如果内存真的紧张,那就设计一套定期重建的机制。比如每周跑一个离线任务,把当前全量数据重新刷到一个新的布隆过滤器里,然后把 Redis 里的 key 切换到新的过滤器上,再删掉旧的。这个过程中要注意双写:切换期间新增的数据要同时写到新旧过滤器,避免切换窗口期漏数据。4.3 误判率的上限设计:业务容忍度才是决定因素参数设计的另一个关键,是要搞清楚业务实际能容忍多大的误判率,而不是蒙一个 1% 或者 0.1%。缓存穿透场景中,误判率对应的是“有多少请求会穿透过滤器打到数据库”,1% 的误判率就意味着 1% 的无效请求会打到下游。如果你的下游扛得住这 1% 的请求,那就没必要为了降到 0.1% 多花十倍内存。已读过滤场景中,误判率对应的是“有多少没看过的新内容会被误判成已读”,对用户体验的影响要重得多,这时候就建议把 p 设到 0.1% 甚至 0.01%。这里我给一个计算内存预算的经验公式:byte = (n * -ln(p)) / (8 * 0.693^2)。这个是直接从 m 的公式换算到字节的,方便你在心里快速做估算。5. 实战中遇到的坑:记录一些真正见血的问题5.1 为什么误判率比理论值高太多?这是我被问得最多的问题。你按公式算好了 m 和 k,误判率理论上应该是 0.1%,但跑了一段时间之后,统计出来的误判率是 1% 甚至更高。原因通常有三个:第一,n 超过了你创建时预估的数据量。过滤器一旦满了,误判率会指数上升。解决办法就是定期统计已插入的元素数量,如果超过了预估值的 80%,就要立刻准备扩容或重建。第二,哈希函数选得不好,导致位分布不均匀。有的团队自己写的哈希函数质量差,多个哈希函数的输出有相关性,结果某些位反复被置 1,某些位从头到尾都是 0。解决办法简单粗暴:优先使用经受过大量验证的哈希算法,比如 MurmurHash 或者 CityHash,不要自己发明哈希函数。第三,bitmap 方式手写时用错了位计数方式,比如位数组长度 m 取得太小,而数据量又特别大,所有位都被置 1 了。这种情况过滤器会退化成为“永远返回存在”,也就是说全部误判,基本等于报废。5.2 数据删除后过滤器失效怎么办?前面提到布隆过滤器不能删除,这在业务里一定会踩雷。举个例子:你在电商系统里用布隆过滤器判断商品 ID 是否已下架,但是商家重新上架了同一个商品 ID,照理它应该能通过过滤器查询,但过滤器里那个 ID 对应的位还是 1,于是这个商品永远被认为是“已下架”,造成了实际的一个漏单事故。面对这种必须支持删除的诉求,可以按时间衰减的方式切分多个布隆过滤器:比如用月维度建一个过滤器,数据只进当月的过滤器,查询时同时查当前月和上月的过滤器,这样上个月的数据自然退化掉。也可以用 Cuckoo Filter(布谷鸟过滤器)替代,它支持删除元素,但 Redis 原生没有实现,需要引入额外的模块或者自己实现,复杂度又上去了。根据我的实践,如果在初期就意识到需要删除操作,优先考虑是否能用“不删除”的逻辑重新设计一下业务规则,往往比在技术上硬解更实惠。5.3 RedisBloom 模块的一些小坑RedisBloom 虽然好用,但也有一些细节容易掉坑。比如误判率参数你设成 0.01,模块内部会按照浮点数处理,但有的旧版本模块对 p 的取值有下限限制,比如不能小于 0.001,你传个 0.0001 进去它会在运行时报错。还有升级 Redis 的时候,因为 RedisBloom 是外部模块,必须确认模块版本和 Redis 版本兼容才能加载,否则可能导致 Redis 进程启动失败。我就曾经有过一次线上事故,因为安全扫描要求升级 Redis 版本,结果旧版模块在新版 Redis 上无法加载,好在及时发现回滚了 Redis 版本,才没有造成更长时间的业务中断。另外,RedisBloom 的容量也不是无限大的。如果你在 BF.RESERVE 里设置了 capacity=10000000,实际插入超过这个值后,过滤器不会报错,而是误判率开始不受控制地上涨。这类“不报错但劣化”的行为最可怕,有时候还是靠监控误判率才发现的。我个人的习惯是每天定时用 BF.INFO 查看过滤器的当前容量使用情况,超过 80% 就启动扩容预案。5.4 常见问题排查速查表现象可能原因解决思路误判率异常升高插入量超过预估容量重建过滤器,增大 capacity 或降低 p 值明明刚插入却查不到写入了不同的 Redis key统一 key 命名规范,检查是否存在 key 前缀不匹配过滤器无法创建key 已被使用BF.RESERVE 只对不存在的 key 生效,换新 key 或先 DELRedis 启动失败RedisBloom 版本不兼容升级模块到与 Redis 匹配的版本,启动前用 MODULE LIST 验证数据量未超但误判率偏高哈希函数质量差更换为 MurmurHash 等成熟哈希算法内存占用过大capacity 设置过大或位数过多根据公式重新估算 m,避免无脑调低 p6. 实战里的设计心得与扩展思路布隆过滤器本质上是一个“乐观赌徒”式结构,用极小的内存换取一个“概率性的确定性”。它的应用边界非常明显:误判方向固定,但不支持删除;数据量预估准确与否,直接影响服务质量。我在实际项目中总结出几条经验,可能对你有参考价值。第一,布隆过滤器适合做入口拦截,而不是唯一依据。所有判断为“存在”的请求,还是要继续走原逻辑做精确校验的。比如缓存穿透场景中,过滤器说“存在”的 ID 再去查缓存和数据库,过滤器说“不存在”的直接拒绝。这样设计,误判不会造成数据错误,只是多了一次本来可以省略的查询。第二,容灾设计上,建议加一个降级开关。如果 Redis 突然不可用,布隆过滤器查询失败时,可以选择放行所有请求,让下游缓存和数据库去承担压力,而不是把整个服务卡死在过滤器这一层。第三,多数据源最终一致性场景下,可以考虑加上消息队列异步同步数据到过滤器,避免同步接口阻塞主流程。最后再分享一个小技巧:你可以为不同的 key 做分片。一个大过滤器出了问题,影响面巨大。但如果拆成 10 个小过滤器,每个负责一部分数据,某个出问题的时候就能快速定位,也能单独重建,不用全量重刷。我碰到过一个场景,把用户 ID 按 UID 的哈希值拆到了 16 个分片,单个分片的数据量波动小,误判率也稳定得多。这种设计和数据库分库分表的思路异曲同工,都是通过拆散隔离故障边界,让系统变得更可维护。布隆过滤器不是什么黑魔法,它的核心价值就一句话:在可接受误判的前提下,用极低成本拦截“确定不存在”的访问。把这个认知落实到位,选对落地方式、算对参数、做好容量规划和降级预案,它就能成为你高并发架构里一块非常扎实的基石。