ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

布隆过滤器实战指南:原理、本地与Redis分布式实现

布隆过滤器实战指南:原理、本地与Redis分布式实现 1. 项目背景与核心痛点我最早接触布隆过滤器Bloom Filter是在一次解决缓存穿透问题的排查中。当时线上系统接了个大促活动瞬间流量上来之后数据库连接直接被打满。查日志发现大量请求都在查询数据库中根本不存在的商品ID缓存没命中数据库也没数据这些请求就直接打到了MySQL上。后来分析才知道这属于典型的缓存穿透——恶意请求或正常但无效的枚举请求绕过了缓存层把数据库当成了靶子。布隆过滤器就是用来解决这类问题的经典方案。它的核心能力是用一个很小的内存空间快速判断一个元素一定不存在或可能存在。注意这个措辞它不保证一定存在但能保证一定不存在。正是这个数学特性让它特别适合做挡在数据库前面的第一道过滤网。我当时在网上查了不少资料发现布隆过滤器的实现方案五花八门但大致可以分成两大类一类是本地单机版的基于JVM内存实现适合单机应用或应用内嵌场景另一类是分布式版的基于Redis实现适合多实例部署的微服务架构。两种方案各有优劣网上讲原理的多讲两种方案如何选型、如何落地的少。这篇文章我就把自己的实战过程完整拆解一遍包括方案设计、核心代码、参数计算和踩坑记录希望能帮到正被同样问题困扰的人。适合谁来参考如果你正在做Java后端开发遇到缓存穿透、URL去重、垃圾邮件过滤、用户名唯一性校验这类大数据量下判断元素是否存在的场景这篇文章可以直接给你一个落地参考。哪怕你只是刚接触布隆过滤器我也会从零开始把原理讲透。2. 布隆过滤器核心原理与参数设计2.1 原理的通俗解释布隆过滤器的本质是一个超大的位数组bit数组加上若干个哈希函数。位数组就像一个很长的格子纸条每个格子里只能写0或1。当你往布隆过滤器里添加一个元素时它会用K个不同的哈希函数对这个元素计算K个哈希值然后把位上对应的位置全部标记为1。当你查询一个元素是否存在时同样计算K个哈希值然后去检查这些位置是不是都为1。如果这K个位置里有任何一个为0那这个元素肯定不存在。如果全是1那就说明可能存在。这里需要理解一个关键点由于哈希冲突的存在不同元素可能映射到相同的位置上。当你写入的元素多了位数组上的1会越来越多后面查询一个根本不存在的元素可能恰好它映射的K个位置都已经被其他元素置为1了这就是误判的来源。用生活化的方式理解假设你在一张大画布上用K个颜色的笔各点一个点来标记一个人。后来画布上点越来越多你拿一个新人的照片去比对发现这K个颜色的位置恰好都有点但你其实不确定是这个人本人点的还是其他人点的。如果找到一个颜色位置没有点那就可以确定绝对不是这个人。2.2 三个核心参数与计算公式使用布隆过滤器前必须确定三个参数预计存储的元素数量n、期望的误判率p、位数组的长度m和哈希函数的个数k。它们之间不是随便定的有严格的数学关系。位数组长度m的计算公式为[ m -\frac{n \ln p}{(\ln 2)^2} ]哈希函数个数k的计算公式为[ k \frac{m}{n} \ln 2 ]这两个公式看着吓人实际用起来很简单。我举个例子假设系统预计有5000万个商品ID需要过滤期望误判率控制在1%。代入公式[ m -\frac{50000000 \times \ln(0.01)}{(\ln 2)^2} \approx 479252917 \text{ bit} \approx 57.14 \text{ MB} ]也就是说只需要大约57MB的内存空间就能支撑5000万数据量、1%误判率的需求。而如果把这5000万个ID直接放到HashMap里按每个ID 16字节算至少需要800MB内存。这就是布隆过滤器最大的价值——用极小的空间代价解决大数量级的判断问题。哈希函数个数k取整后大约是7。k不是越大越好k越大计算开销越大而且位数组被置为1的速度也越快反而会推高误判率。经验法则是k取m/n的0.693倍左右最合适实际工程中7到10个是最常见的取值范围。2.3 为什么它不能删除元素布隆过滤器一个常被忽略的限制它不支持删除操作。原因很简单当你把一个元素对应的K个位置从1改回0时你没法确定这些位置是否也被其他元素共享了。强行清成0会导致其他元素被误判为不存在这就破坏了一定不存在这个核心保证。如果你的业务场景需要删除数据有两种处理思路。一种是用计数布隆过滤器Counting Bloom Filter每个位改成计数器删除时做减一操作。但计数器会占用更多空间一般要用4位才能勉强够用。另一种更实际的做法是定期重建布隆过滤器比如每天凌晨流量低峰期把当天活跃的数据重新加载一遍。我在实际项目中用的就是第二种简单粗暴但可靠。3. 方案一本地单机版布隆过滤器实战3.1 为什么先做单机版如果你的应用是单体架构或者布隆过滤器的数据量不大、不要求多个服务实例之间共享过滤结果那么本地内存版是最简单、最快、性能最高的方案。它不需要额外的中间件依赖数据就在进程内存里查询一次只需要微秒级别。我当时先做单机版还有一个原因为了验证参数设计是否合理。直接用生产环境的数据量压测成本太高本地先跑一遍把误判率、内存占用测出来再决定是否需要上分布式方案。这种先本地验证再分布推广的思路我建议你也试试。3.2 Guava布隆过滤器实操Guava是Google开源的Java工具库它内置了一个BloomFilter实现封装得很完善。我先说结论如果需求不复杂直接用Guava就够了。下面是我当时的核心代码。import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; public class LocalBloomFilterDemo { // 预计数据量5000万 private static final int EXPECTED_INSERTIONS 50_000_000; // 期望误判率1% private static final double FPP 0.01; public static void main(String[] args) { // 创建布隆过滤器指定数据量和误判率 BloomFilterCharSequence bloomFilter BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), EXPECTED_INSERTIONS, FPP); // 模拟写入5000万个商品ID for (long i 0; i 5_000_000; i) { bloomFilter.put(sku_ i); } // 验证查询已存在的ID long start System.nanoTime(); boolean exists bloomFilter.mightContain(sku_123456); long cost System.nanoTime() - start; System.out.println(已存在判断结果: exists , 耗时: cost / 1000 微秒); // 验证查询不存在的ID统计误判率 int falsePositive 0; int testCount 100_000; for (int i 0; i testCount; i) { if (bloomFilter.mightContain(not_exist_ i)) { falsePositive; } } System.out.println(误判数: falsePositive , 误判率: (double) falsePositive / testCount); } }代码本身没什么复杂的地方但有几个细节值得注意。第一BloomFilter.create时传入的数据量和误判率会直接影响内部位数组大小你传的值越大内部占用的内存就越多。我当时用EXPECTED_INSERTIONS传的是5000万但实际生产环境初期可能只有500万数据这时候如果直接按5000万来建内存浪费会很严重。建议根据业务规划一个合理的峰值预估值不要太保守也不要过于悲观。第二Funnels.stringFunnel(StandardCharsets.UTF_8)必须指定字符集否则在跨环境部署时可能出现同一字符串编码不一致的问题导致哈希结果不同布隆过滤器直接失效。这个坑比较隐蔽建议显式指定UTF-8。第三Guava的BloomFilter是线程安全的内部使用了Striped锁机制对位数组进行分段加锁。但它的put和mightContain操作在并发量极高时仍会有一定竞争开销。我压测过单线程下百万次查询耗时大概在200毫秒级别8线程并发下会有明显放大。如果并发量极大可以考虑使用LongAddable之类的优化方案或者直接上Redis方案。3.3 本地版的优缺点总结本地单机版的优势很突出部署简单不需要额外维护中间件性能极高因为数据在JVM堆内没有网络IO成本低不需要购买额外的Redis实例或服务器资源。缺点同样明显数据无法跨进程共享。如果你的服务部署了多个实例每个实例的布隆过滤器都是独立的需要各自初始化一份数据。这会导致两个问题一是内存总量放大二是数据更新不一致——实例A加载了新数据但实例B还停留在旧数据上请求打到实例B上就可能判断错误。另一个隐蔽问题是应用重启后布隆过滤器里的数据会全部丢失需要重新构建。如果数据量小启动时加载还能接受如果要加载几百万条数据重启一次就要几十秒甚至几分钟这就痛苦了。所以本地版更适合对数据一致性要求不高、单实例部署、或者把布隆过滤器作为二级过滤一级用Redis本地再做一层加速的场景。4. 方案二分布式Redis版布隆过滤器实战4.1 为什么会想到用Redis当我准备把布隆过滤器用到生产环境的微服务集群上时本地版暴露出的跨实例问题让我果断转向了Redis。原因很简单Redis本身就是分布式缓存中间件所有实例共享同一个Redis Cluster布隆过滤器的数据只需要维护一份任何实例查询结果都是一致的。实现方案有两条路可以走。一条是使用Redisson——一个成熟的Java Redis客户端它直接封装了布隆过滤器使用方式跟Guava很像。另一条是自己用Redis的SETBIT和GETBIT命令手写实现。我的建议是如果没有特殊要求直接用Redisson别重复造轮子。但如果你用的是PHP、Go或其他语言或者Redis Cluster是公司统一封装的那手写实现也不难关键是把参数算对。4.2 Redisson布隆过滤器实操Redisson的RBloomFilter接口使用起来非常简洁。以同样的场景为例——5000万商品ID期望误判率1%。import org.redisson.Redisson; import org.redisson.api.RBloomFilter; import org.redisson.api.RedissonClient; import org.redisson.config.Config; public class RedisBloomFilterDemo { public static void main(String[] args) { // 1. 创建Redisson客户端 Config config new Config(); config.useSingleServer().setAddress(redis://127.0.0.1:6379); RedissonClient client Redisson.create(config); // 2. 获取布隆过滤器对象名称对应Redis里的一个key RBloomFilterString bloomFilter client.getBloomFilter(product_sku_filter); // 3. 初始化这里传入预计数据量和误判率 bloomFilter.tryInit(50_000_000L, 0.01); // 4. 添加元素 for (long i 0; i 1_000_000; i) { bloomFilter.add(sku_ i); } // 5. 查询 boolean exists bloomFilter.contains(sku_123456); System.out.println(判断结果: exists); client.shutdown(); } }这里需要重点关注tryInit方法。它的作用是根据你传入的数据量和误判率计算出位数组的大小和哈希函数的个数然后在Redis里创建对应的数据结构。值得注意的是tryInit只能在布隆过滤器尚未初始化时调用一次。如果已经初始化过再次调用不会改变原有配置而是直接返回false。如果你需要调整参数唯一的办法是换一个新的key名重来或者删除旧key重新初始化。Redisson底层是通过Lua脚本调用Redis的SETBIT、GETBIT命令来实现位操作保证整个写入和查询过程的原子性。add操作返回的是booleantrue表示元素之前可能已经存在false表示元素之前一定不存在。这个返回值在一些场景下很好用比如你可以用它来判断一个用户ID是否首次出现。4.3 Redis布隆过滤器的优化实践在生产环境使用Redis布隆过滤器有几个性能上的坑需要注意。第一个坑是网络IO开销。每查询一次布隆过滤器Redisson都要发一条命令到Redis。如果业务QPS非常高比如每秒几万次即使Redis本身很快也会带来明显延迟。我实测过单次查询的RTT往返延迟大约在0.5到1毫秒之间在本地网络环境已经算不错了。但如果你在业务逻辑里频繁调用累积起来对接口耗时的压力还是很大的。优化的方向是批量操作。如果你的业务天然就是批量判断的比如一次要判断100个商品ID是否存在Redisson提供了multiContains批量方法它内部会用pipeline把多条命令一次性发给Redis再一次性接收结果这样100个判断的网络开销接近1次。使用pipeline前后的耗时差别非常明显建议能批量就不要逐条调用。第二个坑是内存估算。布隆过滤器在Redis里的存储结构是字符串底层是bit数组。5000万数据、1%误判率的情况下需要大约57MB的bit数。换算成Redis字符串就是约6MB的字节数。但要注意Redis对位图有特殊的空间优化如果你的key是稀疏的Redis底层用的是byte数组按实际使用的bit位范围分配内存。而且在你第一次SETBIT一个很大的偏移量时Redis会一次性把中间的位全部填充为0这个过程可能造成短暂的阻塞。所以初始化布隆过滤器时建议一次性把数据批量写入不要分批次零散写入避免触发多次扩容分配。第三个坑是Redis Cluster的slot分布问题。如果你用的是Redis Cluster布隆过滤器的key会通过CRC16哈希算法被分配到某个槽点然后落到某个主节点。这个key占用的内存会集中在单个节点上数据量大了之后可能造成节点间内存不均衡。如果业务允许可以在key里加上业务标识来分散存储或者干脆用独立的Redis实例来存布隆过滤器数据避免和业务缓存抢占内存。4.4 手写Redis位图方案备选如果项目里不方便引入Redisson或者你需要更多的控制权也可以直接用Redis的命令手写一个简单的布隆过滤器。核心就是两个命令SETBIT和GETBIT。// 判断元素是否存在对每个哈希函数计算偏移量检查对应位是否为1 public boolean mightContain(String element) { int[] offsets getOffsets(element); for (int offset : offsets) { Boolean bit jedis.getbit(BLOOM_FILTER_KEY, offset); if (bit null || !bit) { return false; } } return true; } // 计算K个哈希函数的偏移量 private int[] getOffsets(String element) { int[] offsets new int[K]; byte[] digest md5Digest(element); for (int i 0; i K; i) { // 用MD5的不同字节段模拟多个哈希函数 int offset ((digest[i * 4] 0xFF) 24) | ((digest[i * 4 1] 0xFF) 16) | ((digest[i * 4 2] 0xFF) 8) | (digest[i * 4 3] 0xFF); offsets[i] Math.abs(offset % BIT_ARRAY_SIZE); } return offsets; }这是简化版的代码实际生产环境建议用MurmurHash这类分布更均匀的哈希算法。手写方案的好处是可控性极强坏处是你要自己处理参数计算、并发安全、Redis连接池等一系列问题。我个人的定位是如果你不是特别需要对底层做深度定制直接Redisson就完事了。5. 两种方案选型对比与最终决策建议5.1 对比表格对比维度本地单机版Guava分布式Redis版Redisson依赖仅JVM无外部依赖需要Redis额外依赖中间件性能微秒级无网络IO毫秒级有网络开销数据共享不支持多实例数据不一致支持所有实例共享一份数据持久化依赖JVM内存重启丢失持久化到Redis重启应用不丢失扩展性单机内存上限受限可扩展但注意Cluster槽点分配实现复杂度简单中等适用场景单实例应用、数据量小、可接受重启重建微服务集群、数据量大、需要跨实例共享5.2 决策建议我的经验是别一上来就想着上Redis版先判断你的场景符不符合下面任一条如果符合优先选本地版。第一应用是单实例部署不需要跨进程共享数据。比如一个后台管理系统的内部工具或者一个定时任务程序进程就一个数据就在进程内那本地版明显更合适。第二并发读写量极大超过了Redis的承载能力。本地版完全没有网络IO性能上限非常高。但你要接受一个事实Redis版慢一点但数据是一致的本地版极快但重启后要重建。第三布隆过滤器是作为二级缓存存在的。比如你前面已经有一层Redis缓存布隆过滤器只是为了减少不必要的数据库查询那么即使偶尔因为重启导致误判影响也有限——因为重载数据之后布隆过滤器可以很快恢复。反之如果满足下面这些条件果断选择Redis版。第一生产环境是微服务架构有多个实例同时对外提供接口。这时必须保证所有实例查询同一个布隆过滤器否则实例A判断不存在屏蔽了请求而实例B判断可能存在放行了请求逻辑不一致会造成线上故障。第二数据更新比较频繁而且希望立即可见。比如新用户注册需要检查用户名是否已经被占用用户注册后马上把新用户名加入布隆过滤器。这时候本地版需要同时更新所有实例几乎无法做到同步。第三需要持久化。如果你的布隆过滤器数据是经过长时间积累的比如历史了好几个月的用户ID应用重启后重新加载成本很高那就必须用Redis版。Redis的RDB和AOF持久化机制天然解决了这个问题。我自己在线上用的方案是组合拳布隆过滤器数据统一存在Redis里然后利用Caffeine做本地缓存把最近访问的过滤器结果缓存几秒钟既保证了数据一致性又降低了Redis的负载。这种Redis本地缓存的组合方式在性能和数据一致性之间取得了很好的平衡推荐你在实践中也试试。6. 常见问题与排查技巧实录6.1 误判率比预期高怎么排查布隆过滤器的误判率升高通常有三个原因。第一是实际数据量超过了预估的n导致位数组密度上升。这个很好排查看你布隆过滤器里实际写入了多少数据拿这个数和当初预估的n对比一下就能发现。第二是哈希函数的分布不够均匀。如果你用了自己写的简单哈希函数建议换用MurmurHash3、FNV等成熟的哈希算法。Guava和Redisson内置的哈希函数都是经过充分测试的一般来说不用太担心。如果是手写的方案可以用一小批数据实测一下误判率如果明显偏离理论值优先怀疑哈希函数的问题。第三是多个布隆过滤器共用了同一个Redis key。排查方法是检查Redis里的key数量和大小是否符合预期。我遇到过一种情况因为代码里key名配错了环境生产和测试环境的布隆过滤器写到了同一个key上生产数据把测试数据覆盖掉了误判率直接飙升。加个环境前缀比如prod:product_filter和test:product_filter能避免这种问题。6.2 布隆过滤器容量不够要不要换key重建线上容量不够是最尴尬的情况。数据量上来之后误判率飙升业务方反馈明明不存在的数据布隆过滤器老是判断可能存在导致大量无效请求打到数据库。这时候千万不要原地扩容。布隆过滤器一旦初始化位数组大小就固定了无法动态调整。你需要做的是根据当前实际数据量和业务增速重新计算n和p得出新的参数。用一个新key创建新的布隆过滤器比如product_filter_v2。把存量数据通过离线任务批量刷入新过滤器。验证新过滤器的误判率符合预期后切换代码中的key名。等旧key的过期时间到了自动删除或者手动删除释放内存。这个操作流程中第3步是最耗时的。如果存量数据有上千万条批量写入Redis可能需要几十分钟。建议选择流量低峰期操作或者先切流量再刷数据避免影响线上业务。6.3 Redis连接耗尽或超时布隆过滤器的调用如果量太大会把Redis的连接池打满。我在压测时遇到过这种情况某个接口里调用了三次布隆过滤器查询QPS一上去连接池就炸了。解决办法有几个方向。第一设置合理的连接池大小比如Lettuce或Jedis连接池的maxTotal一般设置为50到100之间不要盲目调大过大的连接池反而会造成Redis端的线程竞争。第二用批量接口代替单条查询尽量减少连接占用时间。第三引入本地缓存做一层削峰把热点过滤器结果缓存几秒到几十秒显著降低Redis的QPS。6.4 添加元素时Redis报错WRONGTYPE这个报错百分之九十是因为两种实现混用了。比如你之前用Redisson的getBloomFilter创建的过滤器底层是一个特殊的数据结构有人用SETBIT命令往同一个key上写入或者相反导致Redis里存储的数据类型和操作命令不匹配。排查方式很简单用TYPE key命令查看该key的类型。如果类型不是string说明你的操作方式和创建方式不一致。解决办法就是统一用一种客户端不要混用。7. 性能压测与踩坑记录7.1 压测方法与数据为了验证两种方案的真实性能差距我当时做了一组简单的压测。环境是4核8G的云服务器Redis部署在同一台机器上数据量级别设定为100万个元素误判率1%。本地版Guava单线程查询100万次平均耗时大约是80毫秒换算下来单次查询在80纳秒级别确实非常快。Redis版Redisson单线程查询10万次因为每次都有网络IO平均耗时大约是4.5秒单次查询约45微秒。如果批量查询1万个元素使用pipeline优化后总耗时大约在20毫秒单次查询降到2微秒。这个数据非常直观地说明了Redis版量大时一定要用批量别一条条查。7.2 压测中发现的两个关键问题压测过程中我发现本地版在高并发下的性能衰减比预想中快。原因是Guava BloomFilter内部的Striped锁机制在并发写入时会竞争同一个Cell的锁。如果你的场景是高频写入可以考虑用多个独立的布隆过滤器按某个维度分片比如按用户ID取模分到不同的过滤器上分散竞争。Redis版压测时遇到一个更隐蔽的问题在Redis Cluster环境下批量操作虽然用了pipeline但pipeline的key可能分布在不同的槽点上。Redisson会为每个槽点建立独立的连接所以一个批量操作最终会变成多个槽点上的多个pipeline性能提升幅度会打折扣。如果对性能要求极严可以考虑在key设计上加上槽点友好的hash tag比如{product_filter}:sku让同一批key落在同一个槽点上。7.3 生产环境上线的注意事项最后提醒几个上线时的细节。第一预热。布隆过滤器在启动时必须把存量数据先加载进去否则刚上线时可能把大量合法请求误判为不存在。我的做法是写一个独立的预热任务在应用启动后异步把最近30天活跃的数据刷进过滤器。第二监控。建议拉出布隆过滤器的三个核心指标当前元素数量、误判率通过定期抽样统计、滤波器占用的内存大小。这三个指标能让你提前发现容量不足的风险。第三告警。当当前元素数量接近预估值的80%时就要准备扩容或重建方案了。别等到误判率已经明显升高才处理那时用户已经开始受影响了。8. 实际项目中的扩展应用布隆过滤器的应用场景远不止缓存穿透。我这里分享三个我在实际项目中用到的场景供你扩展思路。第一个是内容系统的去重。我们有一个爬虫系统每天要采集大量文章URL入库前需要判断URL是否已经爬过。URL数量级在亿级别如果存到数据库里去查数据库压力巨大。用布隆过滤器放在数据库前面先把大量重复的URL挡掉数据库只需要处理真正的新URL。后来数据量上来误判率升高我们还加了定期重建的机制确保爬虫不会漏抓重要页面。第二个是用户推荐系统的黑名单过滤。在给用户做个性化推荐时需要把用户已经看过的内容过滤掉。有些用户的浏览历史有几十万条全量加载到内存不现实。布隆过滤器可以高效实现如果用户看过就尽量不推荐的功能——这里的尽量就是因为允许小概率误判即使误判为看过也顶多是少推荐了一个内容用户的体验损失很低。第三个是手机App的消息推送去重。我们遇到过推送服务重复发送通知的问题原因是有多个服务实例同时消费消息队列同一个消息被多个实例消费后都触发了推送。在推送前用布隆过滤器判断这个消息ID是否已经推送过如果可能存在就跳过就能极大减少重复推送。这里的容错空间比缓存穿透场景更大因为漏一个推送总比重复骚扰用户强。这三个场景的共同点是不要求100%准确允许小概率漏过但能在大规模数据下大幅降低存储和查询成本。布隆过滤器最核心的价值就是在节省资源和容忍少量误判之间找到了一条最优雅的路径。9. 写在最后的几个实操心得布隆过滤器是很优雅的数据结构但要在工程里用好它光看理论是不够的。我踩过几次坑之后总结出几条经验供你参考。第一参数设计别太理想化。预估数据量n的时候一定要乘上一个安全系数我一般取1.2到1.5。因为一旦上了生产数据增长往往比你预想的快重新换key重建的成本远高于当初多算的那点内存。第二误判率不要设得太低。看到很多新手喜欢把误判率设为0.0001%追求极致准确。但误判率每降低一个数量级需要的位数组长度就会显著增加。对于大多数场景0.1%到1%的误判率已经足够了。设得越低内存和性能开销越大收益却微乎其微。第三布隆过滤器不是银弹它只适合判断元素是否不存在的场景。如果你的业务要求绝对精确比如订单金额校验那还是老老实实用数据库或分布式锁。布隆过滤器再大也替代不了精确索引。第四永远不要忘了给布隆过滤器预留退路。线上环境的变化谁也说不准今天的5000万数据明年可能就变成5亿。布隆过滤器重建是最后的兜底方案但提前把重建流程自动化、脚本化会帮你省掉不少半夜上线的痛苦。我现在的项目里布隆过滤器已经成了基础组件之一和Redis、数据库配合得相当默契。每每当线上出现检查元素是否存在的需求我都会先想想能不能用布隆过滤器先挡一道大部分时候这个想法都能让系统轻松不少。
返回列表