news 2026/9/5 19:56:24

Redisson Bloom Filter:并发读写下,误判率为什么不再等于设计值

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redisson Bloom Filter:并发读写下,误判率为什么不再等于设计值

Redisson Bloom Filter:并发读写下,误判率为什么不再等于设计值

【免费下载链接】redissonRedisson: Valkey & Redis Java Client and Real-Time Data Platform. Sync/Async/RxJava/Reactive API. Over 50 Valkey and Redis based Java objects and services: Set, Multimap, SortedSet, Map, List, Queue, Deque, Semaphore, Lock, AtomicLong, Map Reduce, Bloom filter, Spring, Tomcat, Scheduler, JCache API, Hibernate, RPC, local cache..项目地址: https://gitcode.com/GitHub_Trending/re/redisson

上周,我们注册去重链路在大促期间开始漏放重复请求。过滤器是初始化时按 1% 误判率(FPP,False Positive Probability,即一个从未插入的元素被过滤器判定为"可能存在"的概率)建立的 Redisson Bloom Filter,运行一个月后抽样实测,误判率已经到设计值的四倍左右。这篇复盘记录从"过滤器说谎"的现象出发,把写路径、生命周期和监控三个环节逐一拆开看。

现象:流量上来后,老用户开始被当成新来的

故障表象很简单:去重接口本应拦截"已注册用户",但大促期间一批明明注册过的用户被判定为新用户,重复走完了整个注册流程。第一反应是"高并发写把过滤器写坏了"——这个猜测在当时很自然,因为直觉上多个节点同时add,位运算会互相踩踏。

但这个方向后来被推翻了。先看 Redisson 把过滤器拆成了什么。

先排查写路径:add 比想象中更原子

在 Redis 里,一个 Redisson Bloom Filter 实际占两个 key(见源码 redisson/src/main/java/org/redisson/RedissonBloomFilter.java):

  • 位数组本体:一个普通的字符串 key,元素指纹通过SETBIT/GETBIT读写;
  • 配置:一个 Hash key(形如{name}:config),存size(位数组长度)、hashIterations(每个元素要置位的哈希函数个数)、expectedInsertionsfalseProbability四个参数。

tryInit(expectedInsertions, falseProbability)做的事,就是用这两个参数按经典公式反推size(位数组要开多长)和hashIterations(每个元素置几个位)。位数组开多长、每个元素置几个位,直接决定误判率水平,这两个参数是过滤器全部行为的锚点。

客户端拿到元素后,先在本地算出要置的位下标,然后整段 add 逻辑封装进一个 Lua 脚本发给 Redis 执行。脚本的核心(节选自addAsync内嵌脚本)长这样,用来验证"写入是否真的原子、配置漂移是否会被服务端抓住":

local size = redis.call('hget', KEYS[1], 'size') local hashIterations = redis.call('hget', KEYS[1], 'hashIterations') assert(size == ARGV[1] and hashIterations == ARGV[2], 'Bloom filter config has been changed') -- 随后对客户端算好的每个下标执行 SETBIT,并统计"新置位的元素"数量

两个关键结论:

  1. 位级并发写不是问题。Redisson 客户端对 Redis 的写操作是单线程执行的,一个add里的所有SETBIT都在同一个 Lua 脚本内完成,不存在"两个节点同时置位互相踩踏"的空间。给add外面再包一层分布式锁,只是白白增加延迟。
  2. 服务端会校验客户端的配置视图。客户端实例在 JVM 内缓存了一份size/hashIterations(首次调用时懒加载),如果这份缓存和服务端不一致,add 的脚本直接断言报错,contains 的脚本则返回 0——正确性由服务端兜底,代价是下文要讨论的两类故障表现。

所以排查方向要从"元素级并发"转到"过滤器级生命周期"。

真正的风险点在生命周期

初始化之前读写:key 刚建好的那个窗口

如果配置 Hash 还不存在,客户端的懒加载会直接抛出IllegalStateException("Bloom filter is not initialized!")。线上踩到的场景是:新扩容的节点启动时先于运维脚本执行tryInit就收到了请求,日志里一片初始化异常。更隐蔽的变体是有人手动DEL掉了{name}:config这一个 key——位数组还在,但过滤器对所有客户端来说等于"未初始化",读路径全部报错。两个 key 必须成对出现、成对删除,任何绕过 Redisson API 的裸操作都是隐患。

一次没人注意的重新初始化:contains 静默返回 0

这是本次故障的真正根因。tryInit被一个定时任务重复执行过一次(幂等保护没做好),配置 Hash 被重写:size变了。旧 JVM 里还活着、缓存着旧配置的客户端实例,此后每次 contains 的脚本都命中"配置不一致"分支,静默返回 0——所有老用户被判为"不存在",去重等于失效。

注意这里没有任何报错,日志干净得反常。排查时正是靠"add 有断言报错、contains 却静默失败"这个不对称,才把范围锁到配置漂移上。如果业务里 contains 的失败率突然异常,第一个该做的动作是比对客户端内存中的size和服务端HGET {name}:config size是否一致。

超出设计容量:误判率公式不会说谎

第三个风险点是容量。误判率不是常数:设计值只在"插入量 ≤ expectedInsertions"时成立。插入量超过预期后,位数组填得越满,FPP 单调上升,过滤器只是"变得更不准",不会报错。本次故障里 4 倍的实测误判率,相当一部分就来自注册量冲到设计容量 1.6 倍而无人察觉。

另外单个过滤器有硬上限:tryInit计算出的size超过Integer.MAX_VALUE * 2L(约 42.9 亿 bit,合 512MB 位数组)会直接抛异常。容量规划要留出余量,真正要突破这个量级时,正确做法是按业务键分片成多个过滤器,而不是拉大单个size

初始化:把"只做一次"交给服务端

tryInit的实现值得单独看一眼,因为它回答了"并发初始化会不会写坏"的问题。下面是部署侧要用的最小初始化代码,tryInit返回false表示"已经有别的调用方初始化过了":

RBloomFilter<Long> filter = redisson.getBloomFilter("reg_user"); // 预期 1000 万条,误判率 1%;size 与 hashIterations 由服务端按公式推导 filter.tryInit(10_000_000, 0.01);

它把"检查是否已初始化 + 写入配置"合进同一个 Lua 脚本(EXISTS为真则返回 0),所以任意多个节点同时启动、同时调tryInit,也只有一个成功,不需要应用层加锁。真正需要防的不是并发初始化,而是初始化后的配置变更——sizehashIterations一旦定下就不该再变,任何想"改参数"的冲动都应该翻译成"换一个名字重建"。

监控水位:怎么知道过滤器快满了

布隆过滤器本身不提供精确计数,但count()给出了无偏估计:它统计位数组已置位的数量c,代入-m/k * ln(1 - c/m)反推插入量。配合设计容量就能做水位告警,这段监控逻辑要验证的就是"插入量是否在逼近设计容量":

long estimated = filter.count(); double load = estimated / (double) filter.getExpectedInsertions(); if (load > 0.8) { alert("bloom filter load {},接近设计容量,误判率将上升", load); }

另一条线是对 contains 结果做抽样核对:定期取一批已知存在的元素批量contains,命中率显著低于 100% 就是"静默返回 0"类配置漂移的信号——这类故障不报错,只有主动抽样才能发现。再补一条内存观测(sizeInMemory同时覆盖位数组和配置两个 key),水位、命中率、内存三个指标同时看,基本能覆盖过滤器会出的主要问题。

把恢复变成标准动作

确认要重建时,不要在线改任何参数,走"旁路重建 + 改名切换":新建reg_user_v2,从权威数据源(DB)回填并抽样验证,然后改名顶替旧名字。切换用rename完成,它对位数组和配置两个 key 一起原子改名,同名旧 key 被直接替换:

RBloomFilter<Long> newFilter = redisson.getBloomFilter("reg_user_v2"); // 回填与抽样验证通过后,原子顶替旧名(旧数据即被覆盖) newFilter.rename("reg_user");

切换瞬间读到旧配置的客户端,下一次调用会被服务端断言拒绝或静默判空,但不会读错;重启或重建实例后即恢复一致。若只是短期过载而非数据过期,也可以先用expire给过滤器设 TTL 兜底,到期后按上述流程重建。


复盘的结论可以收成三句话:Redisson 的写路径本身是原子的,元素级并发不需要额外加锁,要给并发加防护的是初始化、改名、删除这些生命周期操作;sizehashIterations一经确定就不可变,任何参数调整都应换成换名重建;容量水位、contains 抽样命中率、内存占用三个监控指标里,后两个对静默故障尤其关键。过滤器会"说谎",但它的谎话是有模式的,把生命周期操作收敛到 API 内、把监控前置,这类异常基本都能提前看到。

相关参考:

  • 官方文档:docs/data-and-services/collections.md
  • 源码实现:redisson/src/main/java/org/redisson/RedissonBloomFilter.java

【免费下载链接】redissonRedisson: Valkey & Redis Java Client and Real-Time Data Platform. Sync/Async/RxJava/Reactive API. Over 50 Valkey and Redis based Java objects and services: Set, Multimap, SortedSet, Map, List, Queue, Deque, Semaphore, Lock, AtomicLong, Map Reduce, Bloom filter, Spring, Tomcat, Scheduler, JCache API, Hibernate, RPC, local cache..项目地址: https://gitcode.com/GitHub_Trending/re/redisson

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/5 19:54:12

Flipper Zero 固件安装完整指南:DFU 刷写与 SD 卡更新 5 步完成

Flipper Zero 固件安装完整指南&#xff1a;DFU 刷写与 SD 卡更新 5 步完成 【免费下载链接】awesome-flipperzero &#x1f42c; A collection of awesome resources for the Flipper Zero device. 项目地址: https://gitcode.com/GitHub_Trending/aw/awesome-flipperzero …

作者头像 李华
网站建设 2026/9/5 19:44:54

GPT-4o Vision API实战:从本地图片识别到结构化输出的完整工作流

不需要引子铺垫&#xff0c;直接聊正经事。最近不少朋友拿着本地一堆图片问我&#xff1a;怎么才能让多模态大模型帮我把这些图里的信息自动整理出来&#xff1f;要真正落地跑通一个“本地图片识别 → 多模态 AI 分析 → 结构化输出”的工作流&#xff0c;大多数人卡住的地方根…

作者头像 李华
网站建设 2026/9/5 19:42:04

3步跑通微信聊天记录导出:把十年的对话完整存进自己硬盘

3步跑通微信聊天记录导出&#xff1a;把十年的对话完整存进自己硬盘 【免费下载链接】WeChatMsg 提取微信聊天记录&#xff0c;将其导出成HTML、Word、CSV文档永久保存&#xff0c;对聊天记录进行分析生成年度聊天报告 项目地址: https://gitcode.com/GitHub_Trending/we/WeC…

作者头像 李华