☰
SpringBoot整合Redis布隆过滤器:Lua脚本批量插入优化实践
2026/10/13 5:22:05 网站建设 项目流程

1. 为什么需要批量插入:单条写入方案到底慢在哪

先还原一下我当时的现场。某个业务要上线一个防刷接口,规则很简单:把80万条存量用户ID先灌进布隆过滤器,之后请求进来先过过滤器,不在名单里就直接挡掉,在名单里才继续走后面的业务逻辑。我第一版实现非常简单粗暴——用循环逐条执行SETBIT,80万条数据跑了将近一分半钟,上线那天的固化参数调整场景下,反复重跑好几次,整个人都不好了。这也是大多数人第一次接触"往Redis布隆过滤器里批量插数据"这个需求时最常见的状态。

标题里的三个关键词其实代表了三个层次的问题:SpringBoot是应用层入口,Redis布隆过滤器是数据存储层,Lua脚本批量插入是连接两者的优化手段。这篇博文完整还原我当时如何用SpringBoot封装Lua脚本,把80万条历史数据初始化时间从单条写入的近百秒量级压到秒级,并且把过程中的选型考量、脚本拆解、实测数据和踩坑记录都摊开来讲。

1.1 布隆过滤器在SpringBoot项目里最常见的两个用途

布隆过滤器本质上是一个很长的bit数组加若干个哈希函数。写入时,对某个value做k次哈希,把对应的k个bit位全部置成1;查询时,对value做同样的k次哈希,只要这k个bit位里存在任何一个为0,就说明这个value绝对不存在,如果全部为1,说明"有可能存在"——注意,是可能,因为不同value的哈希位置可能互相覆盖,产生误判。

在SpringBoot后端项目里,它最常见的两个落点:

  • 缓存穿透拦截:查询用户信息、商品详情这类热点数据前,先用布隆过滤器判断key是否存在。如果过滤器说"不存在",直接返回空,根本不会打到数据库。
  • 防刷与白名单:刚才提到的场景,将允许通过的ID维护在过滤器里,请求进来先过滤。

无论是哪种用途,都逃不开一个前置步骤:把历史存量数据一次性灌进过滤器。这个动作就是布隆过滤器的初始化,也是"批量插入"这个需求的直接来源。

1.2 单条写入到底慢在哪

如果直接用SETBIT一条一条写,80万条数据要发80万次 Redis 命令。问题的关键不在Redis执行SETBIT本身,而在于每一次命令都会经历一次完整的网络往返。即使你的应用和Redis在同一台机器上,本机回环也有额外的socket开销;一旦Redis在独立服务器上,一次往返的耗时可能就是0.1ms~0.5ms,80万次往返就是80秒到400秒的量级。这还没把客户端连接池的分配等待算进去。

当时我的测试数据是:80万条ID,每条7个哈希位置,也就是560万次SETBIT,单条循环写入用了约92秒。而这个过程中Redis真正执行写操作的时间其实很短,绝大部分时间都耗在"发送命令→等待响应→发送下一条"的串行等待里。

所以优化思路非常明确:减少网络往返次数。而Lua脚本恰好能做到这一点——把一批数据的写入逻辑封装成一个脚本,一次发送给Redis,脚本内部连续执行多个SETBIT,应用层只需要等待一次结果返回。

1.3 为什么不能只用本地布隆过滤器替代

也有人问过我:既然Redis里写布隆过滤器这么麻烦,直接在应用内存里放一个Guava布隆过滤器不行吗?技术上当然可以,但对绝大多数服务化部署场景不适用:

  • 每个实例都是独立内存,A机器写入的过滤器B机器不可见,必须每台机器各自初始化一遍,数据一致性难以保证。
  • 重启即失效。应用发版、扩容时,过滤器需要重新初始化,在这个窗口期内缓存穿透拦截直接失效。
  • 内存不可控。虽然Guava过滤器本身不大,但多个实例副本合计下来仍然浪费。

用Redis做共享布隆过滤器,相当于把"过滤器状态"从应用内存里剥离出来,变成一个跨实例共享的基础设施。再加上Redis的String本身就支持bit操作,天然适合存放bit数组,所以从架构角度来说,Redis方案是更稳妥的选择。

2. Lua脚本方案的取舍:三条路对比与为什么选它

确定用Redis之后,摆在我面前的有三条实现路线,这里直接放对比结果。

方案是否依赖外部模块批量能力可定制性适用环境
RedisBloom的BF.MADD是,需要安装模块较强,一条命令写多个value较低,逻辑固定允许安装模块的云服务或自建Redis
应用循环SETBIT否差,每个value多次网络往返高任何环境,但性能差
Lua脚本+SETBIT否强,一次脚本调用完成大批量写入高,可自定义校验、统计、多key写入任何环境,推荐

2.1 三条路线的横向对比

先说BF.MADD。如果你的Redis已经装了RedisBloom模块,这确实是最省事的方式:

BF.MADD bloom:user:2024 userId1 userId2 userId3 ... userIdN

一条命令批量写入,内部由模块完成位计算。但问题也很现实:很多公司自建的Redis根本不会装这个模块,尤其是多套环境、多个机房共用的Redis集群,升级模块还要走变更审批。而且BF.MADD的逻辑完全固定,如果你的业务需要在写入的同时做一些额外处理,比如统计、记录日志、同时维护另一个白名单key,它就无能为力了。

再说应用层循环SETBIT。这是最直观的方案,任何会Redis命令的人都能写出来,但前面已经算过账了,80万条数据要80万次网络往返,性能上完全不可接受。

最后是Lua脚本+SETBIT。它在不依赖任何模块的前提下,把一次批量写入压缩成一次网络往返。脚本内部可以写分支逻辑,可以调用redis.call执行多个命令,扩展性最好。这也是我最终选择的方案。

2.2 "应用层算位置,Redis脚本只负责写"的分工逻辑

我这里的核心设计决策是:哈希位置的计算放在Java应用层完成,Lua脚本只负责接收一串位置数组并执行SETBIT。可能有人觉得奇怪:布隆过滤器的哈希不就应该在Redis里算吗?

这样分工有三个原因:

  • Java生态里哈希实现的成熟度和可控性好。用Guava的murmur3,代码简单、容易单元测试,位置计算结果可以在完全不连Redis的情况下验证正确。
  • Lua脚本里写哈希算法又笨重又难调试。Lua虽然能写循环、能算算术,但字符串哈希处理起来远不如Java顺手,一旦脚本出错,排错成本很高。
  • 脚本职责单一,安全边界清晰。脚本只做"置位",不负责业务计算,参数由应用层精确算好传进来,脚本逻辑越短,潜在bug越少。

写成像下面这样的职责划分就非常清晰:

应用层:对每个value计算k个哈希位置,得到位置数组 传输层:位置数组作为Lua脚本的ARGV参数,一次性发送给Redis Redis层:Lua脚本遍历位置数组,逐个SETBIT

2.3 Lua脚本在Redis中的执行语义:原子但不回滚

这里要顺便澄清一个容易误解的点。Redis执行Lua脚本时,整个脚本是在Redis单线程事件循环里运行完毕的,期间不会有其他客户端的命令插进来,所以从外部视角看,脚本内的所有SETBIT具备原子性——你不会看到"一半写入完成了、另一半还没写"的中间状态。

但特别要注意的是,Redis的Lua脚本没有事务回滚机制。如果脚本执行到一半抛错了,已经执行的SETBIT不会自动撤销。只不过我们的场景里,SETBIT是一个幂等操作,重复把某个位置置成1没有任何副作用,所以即使脚本中途失败,重跑一遍也不会污染数据。这算是"写入型脚本"才有的容错红利,如果是"先删后写"类型的脚本,就要小心很多。

3. 动手前的最关键设计:位置怎么算,脚本怎么写

批量插入的性能再快,如果布隆过滤器参数没设计对,后面所有查询都是白搭。这一章节先说参数计算,再逐段拆解Lua脚本。

3.1 布隆过滤器的两个关键参数:位数组长度m与哈希函数个数k

布隆过滤器有两个关键参数:位数组长度 m和哈希函数个数 k。它们的取值取决于两个输入:预期存储的数据量 n,以及能接受的误判率 p。标准计算公式如下:

m = - n * ln(p) / (ln2)^2 k = (m / n) * ln2

从工程角度,n通常由业务量估算,p则取决于业务容忍度。以我当时的需求为例:存量用户预计100万,允许1%的误判率(误判只会让极少量的非白名单请求穿透过去,业务可接受),代入公式得到:

m = -1000000 * ln(0.01) / (ln2)^2 ≈ 9585058 bit ≈ 1.14 MB k = (9585058 / 1000000) * ln2 ≈ 6.64,向上取整为 7

也就是说,100万条数据、1%误判率,只需要大约1.2MB的Redis内存,用7个哈希函数就能满足要求。这个内存量级其实非常友好,很多第一次算的人都会被这个小数字意外到。

需要注意一个细节:k要取整数,实际误判率会比理论目标略低一点,因为向上取整会让哈希位置更多,反而更"严格"。还有Redis的String最大长度是512MB,换算成bit位上限约43亿,绝大多数业务场景都够用。

3.2 Java端位置计算:独立seed的多个哈希

确定 m 和 k 之后,应用层要做的就是对每个value计算k个哈希位置。我用的方法是给每个哈希函数分配一个独立的seed,再调用Guava的murmur3:

import com.google.common.hash.Hashing; import java.nio.charset.StandardCharsets; public long[] hashPositions(String value, int bitSize, int hashNum) { long[] positions = new long[hashNum]; for (int i = 0; i < hashNum; i++) { long hash = Hashing.murmur3_128(i).hashString(value, StandardCharsets.UTF_8).asLong(); positions[i] = Math.floorMod(hash, bitSize); } return positions; }

这里有两个实现要点。第一,Math.floorMod而不是%,因为%在Java里对负数会返回负值,直接当offset用会报错,floorMod能保证结果落在0 ~ bitSize-1的合法区间。第二,murmur3的seed从0到k-1变化,相当于k个相互独立的哈希函数,不同seed产生的哈希值分布均匀,不容易在很小范围内扎堆。

3.3 Lua脚本逐段拆解

下面就是批量写入的核心脚本。它的职责被刻意压得很小:接收位置数组,逐个置位,返回实际写入的次数。

-- KEYS[1] : 布隆过滤器对应的key,例如 bloom:user:2024 -- ARGV[1] : 位数组长度m,用于边界校验 -- ARGV[2..]: 应用层算好的位置序列,每个value对应k个连续位置 local m = tonumber(ARGV[1]) if m == nil or m <= 0 then return -1 end local writeCount = 0 for i = 2, #ARGV do local pos = tonumber(ARGV[i]) if pos ~= nil and pos >= 0 and pos < m then redis.call('SETBIT', KEYS[1], pos, 1) writeCount = writeCount + 1 end end return writeCount

脚本逻辑非常简单,但几个点值得说明一下:

  • #ARGV取的是ARGV表的长度。由于Redis传入的ARGV是连续数字下标的数据结构,所以这个长度就是参数个数。
  • 脚本内部对每个位置都做了边界校验。正常情况下应用层算出来的位置不会越界,但增加防御性判断可以防止异常数据把Redis写崩。
  • 返回值writeCount是实际执行SETBIT的次数,注意它不是"去重后的位置数"——如果同一批数据里出现同样的位置,它会被重复计数,但重复置1不会产生新影响。所以这个返回值更适合用来做"脚本是否正常跑完"的日志监控,而不是精确统计写入了多少数据。

3.4 为什么脚本里不直接用BF.ADD

如果你仔细看上面的脚本,会发现里面完全是自己处理的位运算,没有用BF.ADD。再强调一次原因:RedisBloom模块在很多存量Redis环境中并不存在,直接写BF.ADD在部署时大概率会得到unknown command错误。用SETBIT方案相当于把布隆过滤器的实现层握在自己手里,不依赖任何Redis扩展,在任何环境都能跑。当然,如果你们公司已经标准化使用了RedisBloom,那用BF.MADD完全可以,两者并不冲突。

4. SpringBoot工程内的落地代码:从依赖到批量写入

参数和脚本设计完毕,接下来就是在SpringBoot工程里把它们串起来。这一节给出可以直接参考的完整代码骨架。

4.1 依赖与Redis配置:优先使用StringRedisTemplate

既然是操作Redis的bit位,最怕的就是key序列化方式不对。Spring Data Redis里,如果用默认的JdkSerializationRedisSerializer,存到Redis里的key会带一长串二进制前缀,和你在命令行里看到的bloom:user:2024完全不是一回事。更麻烦的是,不同客户端、不同版本反序列化出来的字节可能不一致,导致A实例写入的布隆过滤器key和B实例查询的key对不上,过滤器形同虚设。

所以这里不要图省事用RedisTemplate,直接用StringRedisTemplate。它的key和value都按String方式存取,和Redis命令行操作行为完全一致。依赖方面只需要引入spring-boot-starter-data-redis,以及用到的Guava:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> </dependency>

yml配置按常规来即可:

spring: data: redis: host: 127.0.0.1 port: 6379 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 4

4.2 Lua脚本常量与批量写入Service

脚本内容可以单独抽成一个常量类,方便单元测试和以后复用:

public final class BloomFilterLuaScripts { public static final String BATCH_INSERT = "local m = tonumber(ARGV[1])\n" + "if m == nil or m <= 0 then\n" + " return -1\n" + "end\n" + "local writeCount = 0\n" + "for i = 2, #ARGV do\n" + " local pos = tonumber(ARGV[i])\n" + " if pos ~= nil and pos >= 0 and pos < m then\n" + " redis.call('SETBIT', KEYS[1], pos, 1)\n" + " writeCount = writeCount + 1\n" + " end\n" + "end\n" + "return writeCount\n"; private BloomFilterLuaScripts() { } }

然后定义一个Service,封装批量插入逻辑。注意DefaultRedisScript<Long>是线程安全的,可以在初始化时创建一次,重复使用。

@Service public class BloomFilterInitService { private static final String BLOOM_KEY = "bloom:user:2024"; private static final int BIT_SIZE = 9_585_058; private static final int HASH_NUM = 7; private static final int BATCH_SIZE = 2000; private final StringRedisTemplate stringRedisTemplate; private final DefaultRedisScript<Long> batchInsertScript; public BloomFilterInitService(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; this.batchInsertScript = new DefaultRedisScript<>(BloomFilterLuaScripts.BATCH_INSERT, Long.class); } public int batchInsert(List<String> values) { if (values == null || values.isEmpty()) { return 0; } List<String> args = new ArrayList<>(values.size() * HASH_NUM + 1); args.add(String.valueOf(BIT_SIZE)); for (String value : values) { long[] positions = hashPositions(value); for (long position : positions) { args.add(String.valueOf(position)); } } Long result = stringRedisTemplate.execute( batchInsertScript, Collections.singletonList(BLOOM_KEY), args.toArray(new String[0]) ); return result == null ? 0 : result.intValue(); } public boolean mightContain(String value) { long[] positions = hashPositions(value); for (long position : positions) { Boolean bit = stringRedisTemplate.opsForValue().getBit(BLOOM_KEY, position); if (bit == null || !bit) { return false; } } return true; } private long[] hashPositions(String value) { long[] positions = new long[HASH_NUM]; for (int i = 0; i < HASH_NUM; i++) { long hash = Hashing.murmur3_128(i).hashString(value, StandardCharsets.UTF_8).asLong(); positions[i] = Math.floorMod(hash, BIT_SIZE); } return positions; } }

这里顺便把mightContain方法也放进来了。这是布隆过滤器的查询方法,初始化完成后可以用它验证写入是否成功。

4.3 从文件或数据库读存量数据的批处理入口

批量插入的入口一定是从某处读取存量数据,再分批调用batchInsert。如果数据在数据库里,可以分批查询ID列表,每查出一批就灌一批;如果数据在文件里,逐行读取并攒批。我这里以文件为例:

public void initFromFile(String filePath) { List<String> batch = new ArrayList<>(BATCH_SIZE); try (BufferedReader reader = Files.newBufferedReader(Paths.get(filePath), StandardCharsets.UTF_8)) { String line; while ((line = reader.readLine()) != null) { String id = line.trim(); if (id.isEmpty()) { continue; } batch.add(id); if (batch.size() >= BATCH_SIZE) { int written = batchInsert(batch); log.info("已写入一批:{}, 实际置位次数:{}", batch.size(), written); batch.clear(); } } if (!batch.isEmpty()) { batchInsert(batch); } } catch (IOException e) { throw new RuntimeException("读取存量数据文件失败", e); } }

每批写入完成后打一条日志,既能确认进度,又能在出问题时定位到是哪一批数据异常。我这里加了一句"实际置位次数"的输出,它和批大小乘以哈希函数个数应该是对得上的,如果对不上,优先怀疑脚本有没有被真正执行完整。

4.4 初始化后的验证:用getBit检查写入结果

初始化完成后,验证工作其实很简单:从已写入的数据里随机挑几个,调用mightContain,结果应该都是true;再挑一些肯定不在集合里的数据,结果大概率是false。这里说"大概率",是因为布隆过滤器天然存在误判,可能会有极少数根本不在集合里的value被判定为"可能存在",这是正常现象。

此外建议在第一次上线前写一个单元测试,用很小的位数组参数跑一遍完整的"写入-查询"流程。比如把BIT_SIZE临时改成1000、HASH_NUM=3,插入100条数据后验证100条数据全部命中,再插入另外100条未见过的数据统计误判数量,看是否在预期范围内。这种测试能把"位计算错误"这类问题在部署前暴露出来。

5. 实测数据:80万条历史数据,从92秒到7秒

这一节的数据来自我当时本机环境的一次对比测试,供参考。环境是普通工作笔记本,Redis 7.0单机部署在本地,客户端与Redis在同一台机器,所以网络往返的损耗已经算是偏小的了——如果是跨服务器,单条循环方案的差距会拉得更大。

5.1 单条循环与Lua批量对比

测试数据量80万条,每个value对应7个哈希位置。结果如下:

方案网络往返次数总耗时
循环单条SETBIT80万次约92秒
Lua脚本,每批1000条800次约11秒
Lua脚本,每批2000条400次约9秒
Lua脚本,每批5000条160次约7秒

单条方案花了将近一分半钟,而Lua批量方案哪怕是最保守的每批1000条,也只要11秒左右。两者的差距主要就是网络往返次数从80万降低到了几百。一旦Redis和应用分处两台机器,这个数字差距会更加夸张。

5.2 batchSize到底应该设多少

从上面表格可以看到,总耗时并不和batchSize严格反比。因为80万条数据的SETBIT总次数是固定的,这部分的Redis执行时间不会因为批量而减少,batchSize主要影响的是网络往返和脚本解析的开销。所以当batchSize从1000涨到5000时,总耗时从11秒降到7秒,伤耗并不剧烈。

但batchSize也不是越大越好,它有两个隐形代价:

  • 单次脚本参数数量膨胀。batchSize为5000时,ARGV数量就是5000 * 7 + 1 = 35001个字符串。如果batchSize拉到2万,就是14万个参数,一次发送的数据包可能达到几MB,不仅网络层压力大,Redis解析参数列表也要花时间。
  • 单次脚本阻塞时间拉长。Redis是单线程执行脚本的,脚本执行期间其他命令全部排队。一个5000条的脚本内执行35000次SETBIT,本地大概几十毫秒,但如果你的Redis同时在服务线上流量,这几十毫秒的阻塞可能会引起明显的毛刺。

所以batchSize的选择是个平衡题。我个人目前倾向于1000~5000这个区间,数据量大时选5000,数据量小且Redis压力敏感时选1000,基本不会出大问题。

5.3 参数计算表:不同量级和误判率下的大致内存

再给一张参数计算参考表,方便做容量规划时快速估算:

数据量n期望误判率p位数组长度m(bit)哈希函数个数k实际占用内存
10万1%958,5067约0.12MB
100万1%9,585,0587约1.14MB
1000万1%95,850,5847约11.43MB
100万0.1%14,377,50010约1.71MB

注意这里的"实际占用内存"是Redis里这个key理论占用的内存,实际Redis内存管理可能会有少量额外开销,但整体量级已经是"百万数据只需一兆多"这个级别。所以在内存规划上,布隆过滤器本身基本不构成压力,主要开销还是集中在初始化阶段的CPU和网络。

6. 踩坑记录:脚本缓存、序列化、阻塞与重启灾难

这一章节全是实操里踩过的坑。代码写对了只是第一步,要让它在真实环境稳定运行,下面几个问题必须注意。

6.1 RedisTemplate序列化坑:过滤器写完却完全失效

这是很多第一次用Redis做布隆过滤器的人最容易踩的坑。如果用默认的RedisTemplate存key,实际写入Redis的key会是经过JDK序列化的二进制对象,而不是你肉眼看到的bloom:user:2024。这带来两个问题:

  • 你在Redis客户端里查GET bloom:user:2024可能查不到任何东西。
  • 更隐蔽的是,应用重启后或者换了序列化配置,同一个逻辑key可能被序列化成不同的字节,导致新的写入和旧的写入落到了两个不同的key上。

最终的解决方案就是前面说的:布隆过滤器相关的操作一律用StringRedisTemplate。这不是偷懒,而是让key的语义在应用层、Redis层、命令行之间保持一致。

6.2 NOSCRIPT错误与脚本预热

Spring Data Redis执行DefaultRedisScript时,默认先尝试EVALSHA,如果Redis里没有对应脚本的缓存,会回退执行EVAL并完成脚本缓存。这个机制在绝大多数情况下能工作,但有极少数场景——比如连接被重建、Redis重启、集群切换——会短暂出现脚本不在缓存中的情况,如果此时并发很高,可能会偶发报NOSCRIPT No matching script。

解决方案是预先在应用启动阶段暖一次脚本:

@PostConstruct public void warmUpScript() { stringRedisTemplate.execute( batchInsertScript, Collections.singletonList("bloom:warmup"), String.valueOf(BIT_SIZE) ); }

注意这个预热脚本执行时,ARGV只有BIT_SIZE一个值,Lua脚本里for i = 2, #ARGV循环不会进入,所以不会写任何实际数据,纯粹是让Redis把脚本体缓存下来。预热完成后,后续的所有EVALSHA就能顺利执行了。

6.3 单批参数过多:发送失败与长时间阻塞

有一次我把batchSize临时调大到了5万,结果程序直接抛了异常。原因是单个脚本的参数数量达到了5万 * 7 = 35万个位置参数,数据包体积达到几MB,连接级传输和Redis侧参数解析直接超时。

即便没超时,这么大的脚本单次执行也会长时间占用Redis单线程,让整个Redis实例对其他请求的响应陷入停顿,线上毛刺就是这么产生的。所以批量插入不只看总耗时,更要关注单次脚本的阻塞时长。几百毫秒的脚本阻塞在低峰期勉强能接受,在高峰期可能就是事故。建议在初始化任务执行期间,配合监控关注Redis的cmdstat_evalsha耗时分布,一旦出现明显的耗时峰值,立刻调小batchSize。

6.4 Redis重启后过滤器"归零"的隐患

这是布隆过滤器方案最容易让人忽视的运维风险。布隆过滤器在Redis里只是一个超长字符串,如果Redis没有开启持久化,或者持久化策略没覆盖到这个大key,重发一次重启后,过滤器里的位全部归零。

后果取决于你的业务姿势。如果是防刷场景,过滤器归零后所有ID都会被判定为"不存在",全部请求被拦截,业务直接中断;如果是缓存穿透拦截场景,过滤器归零后所有查询都判定为"可能不存在"或者"可能已存在",取决于代码里取反逻辑,数据库可能在几分钟内被打爆。

应对手段有三个层级:

  • 对包含布隆过滤器的Redis实例开启AOF持久化,并且确认恢复策略能够保留最近写入的位数据。
  • 核心场景下,把布隆过滤器的key和其他临时数据分开部署实例,降低被FLUSHALL或内存淘汰误伤的概率。
  • 应用侧做降级:一旦过滤器查询异常或计数为零,快速触发一次后台重建任务,而不是把所有流量直接放给数据库。

6.5 正确认识误判率:它不精确,要用对场景

最后一条其实不算坑,是语义边界。布隆过滤器回答的是"一定不存在"和"可能存在"这两个命题,它做不到精确的"一定存在"。如果你的业务要求"没有漏网之鱼",布隆过滤器就不合适,应该用Redis Set或者数据库唯一索引;如果你的业务能容忍少量误判——比如防刷拦截漏掉几个真实用户、缓存穿透拦截偶尔多打一次数据库——那它才是利器。

我在实际使用中最深刻的体会是:把布隆过滤器用在"黑白名单判断"时要谨慎,用在"挡住明显不存在的请求"时最舒服。前者会被误判问题反复纠缠,后者天然容忍误判,因为多放一个"不存在"的请求进去,顶多是多查一次数据库,而布隆过滤器拦截掉的那批显然不存在的请求,已经帮数据库挡掉了绝大多数流量。

7. 最后分享一个小技巧:删除大key时用UNLINK,别用DEL

布隆过滤器这个key在数据量大时是个不折不扣的大key,1000万条数据对应的key可能有十几MB,如果初始化时参数算错了需要重建,用DEL bloom:user:2024删除它,Redis主线程要真正释放这十几MB的内存,可能造成几十毫秒的阻塞。正确姿势是用UNLINK,它在非阻塞模式下完成内存释放,对线上影响小得多:

UNLINK bloom:user:2024

Spring Data Redis对应的操作是stringRedisTemplate.unlink("bloom:user:2024")。

另外一个小习惯:不要给布隆过滤器key设置过期时间。这个key是长期有效的公共状态,一旦设置了EXPIRE,过期后业务会自动退化到重建模式,而重建期间过滤器的"记忆"是空的,数据库风险就暴露了。需要重建时用专门的脚本任务做,而不是依赖过期机制。

我后来每次接布隆过滤器的需求,都会先把参数算明白、把batchSize定好、把脚本预热写进启动任务,最后再在部署验收时跑一遍初始化脚本核对耗时和置位次数。这套流程固定下来之后,布隆过滤器就不再是"上线时出了状况调半天"的麻烦事,而是一个稳定可靠的前置过滤组件了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询