SpringBoot 里接 Redis 布隆过滤器这事儿,很多团队都在用,但一说到批量插入,不少人还是老老实实 for 循环逐条写。数据量小的时候看不出问题,等上百万 key 要初始化时,那耗时和连接开销真的会有想砸电脑的冲动。这篇文章我就从实际项目出发,讲讲为什么用 Lua 脚本做批量插入、脚本怎么设计、SpringBoot 里怎么落地,以及我踩过的几个值得拿出来说的坑。内容适合正在做缓存穿透治理、又想把初始化性能做上去的开发者参考。
1. 布隆过滤器的核心原理与参数选型
1.1 布隆过滤器到底是个什么结构
布隆过滤器本质上就是一个很长的二进制位数组,配合一组相互独立的哈希函数工作。插入元素时,把元素分别经过这 k 个哈希函数计算,得到 k 个位置,然后把这 k 个位置上的位都置为 1。判断元素是否存在时,同样计算 k 个位置,只要发现其中任意一个位置的位是 0,就确定该元素一定不存在;如果 k 个位置全是 1,则只能说该元素大概率存在。
这里的关键词是“大概率”。因为不同元素可能映射到相同的位,所以会出现误判,也就是某个元素其实不存在,但它的 k 个位置恰好都被其他元素置为了 1,于是被误判为存在。但反过来,如果某个位置是 0,那就绝对能断定该元素不存在。这种特性决定了布隆过滤器的两个核心应用方向:可以接受少量误判,但绝对不能漏判的场景。
我打个比方你就懂了。布隆过滤器像是小区门口的访客登记墙,上面贴满了到访者的名字。登记的时候每个访客名字会被贴到几个固定位置。查某个陌生人是不是来过,只需要看名字对应的那些位置有没有贴纸。只要有位置空着,就能确定这个人没来过;如果所有位置都有贴纸,也可能只是位置重叠造成的“巧合”。因为只记录贴纸不记录具体的人,内存占用极低,但偶尔会误认。
1.2 什么时候该用布隆过滤器
当前项目场景是典型的缓存穿透治理。用户请求一个并不存在的数据 ID,比如商品详情,Redis 缓存里没有,请求就会打到数据库。如果这种无效请求量很大,数据库会被这些永远查不到的 key 反复冲击,这就是缓存穿透。常规做法是先查布隆过滤器,如果过滤器判断该 key 不存在,直接返回空,连 Redis 都不用查了;只有过滤器判断可能存在,才继续走缓存和数据库的完整链路。
除了缓存穿透,布隆过滤器还常用于恶意请求拦截、爬虫 URL 去重、推荐系统过滤已读内容等。共同特点是数据集合大,但又不需要精确存储原始值,能容忍极低的误判率。
回到项目里,现在需要把一批业务数据的主键初始化进布隆过滤器,数据量大概在百万级。初期设计时还有过一个方案,就是直接用 Redis 的 Set 数据结构去重,每个 ID 存一份,后来算了下内存果断放弃了。百万个 ID 的话 Set 内存轻松上 GB,而布隆过滤器只需要约 12MB,这个差距决定了方案走向。
1.3 三个关键参数怎么定
布隆过滤器有三个核心参数:预估元素数量 n、误判率 p、位数组长度 m 与哈希函数个数 k。位数组长度 m 和哈希函数个数 k 不能随便拍脑袋,一般用以下两个公式计算:
- m = -(n × ln p) / (ln 2)^2
- k = (m / n) × ln 2
举个例子。假设预估元素数量 n = 100 万,误判率要求 p = 1%(0.01),代入第一个公式:
- ln 0.01 ≈ -4.605
- (ln 2)^2 ≈ 0.4805
- m = -(1000000 × -4.605) / 0.4805 ≈ 958 万位
也就是说位数组长度约为 958 万 bit,换算成字节大概是 1.14MB。然后是哈希函数个数:
- k = (9580000 / 1000000) × 0.693 ≈ 6.64,向上取整为 7
所以这个场景下需要 7 个独立哈希函数,误判率才能控制在 1% 左右。误判率每降低一个数量级,位数组长度会明显增加。比如误判率要 0.1%,算出来 m 大约是 1438 万位,即 1.7MB 左右。因此实际项目中不要一味追求低误判率,4~5 里根据业务容忍度选一个合适的值,够用就行。
实际开发里 IOUtils 自己手写哈希函数集合容易写错,直接用 Google Guava 的 BloomFilter 类是更稳妥的选择。但要注意:Guava 的 BloomFilter 是 JVM 内存态的,进程重启数据就丢了,所以我们的思路是用 Guava 来算参数和做本地校验,真正的数据存储还是放在 Redis 那侧。这样既保证了参数计算正确,又把存储交给了 Redis,一举两得。
2. 为什么批量插入一定要用 Lua 脚本
2.1 逐条调用的问题在哪
第一种做法最直观:从数据库查出所有 ID,for 循环一个一个调 Redis 命令写入布隆过滤器。比如用 RedisTemplate 的 execute 方法直接 setbit,写 100 万个 ID,每条数据要执行一次 Redis 命令,也就是 100 万次网络往返。
这有两个致命问题。一是网络 IO 开销极大,即使走内网,每条命令一次 RTT(往返时延)在 0.2ms 左右,100 万次就是 200 秒,这还没算服务端执行时间,实际会让人等到怀疑人生。二是对 Redis 服务器的压力很大,特别是开启了 AOF 持久化时,每条写命令都可能触发额外的写入开销。
另一个隐藏问题是,如果业务要求这次批量插入必须整体成功或整体失败,逐条写入的中间状态是完全不可控的。万一写到一半报错,前面已经写入的数据就留下了,后面想重来还得先清理。
2.2 Lua 脚本的原子性与性能优势
Lua 脚本在 Redis 里是被当作一个整体执行的,脚本运行期间不会被其他命令插队,天然具备原子性。这就解决了中间状态不可控的问题,要么整个脚本执行完,要么执行前就失败,不会出现写了一半这种尴尬局面。
性能方面的优势更直观。一次脚本执行,只需要一条命令把脚本内容发给 Redis,然后在服务器端完成所有 Redis 操作,中途不需要网络往返。也就是说,插入 1 万条数据和插入 1 条数据,网络交互次数是一样的,差距只体现在服务端执行时间上。
我实测过一个十万量级的初始化任务。逐条 setbit 大概耗时 120 秒左右,而用 Lua 脚本批量写入,同样的数据量压缩到 8 秒以内,性能提升了十几倍。如果数据量到百万级,这个差距只会更大,因为逐条方案的时间基本是线性增长,而 Lua 方案受单次脚本长度的限制,需要分批但每批的耗时增量很小。
2.3 Pipeline 不香吗
有人可能会问,Redis 的 Pipeline 也能批量执行命令,为什么非得用 Lua。Pipeline 的原理是把多条命令打包发送,减少网络往返次数,但服务端执行时仍然是逐条处理,中间如果某条命令失败,不会影响其他命令的执行结果,也就是说 Pipeline 本身没有原子性。批量写入场景下,如果需要保证这批数据要么全部写入要么全部不写,Pipeline 无法满足这个要求。
另外,Pipeline 的批量发送在一定体量下也会受到缓冲区限制,以及客户端和服务端的内存压力影响。而 Lua 脚本因为是脚本整体执行,可以明确控制循环逻辑,像批量设置位、批量判断存在这类操作,在脚本里写起来思路更清楚。
所以我的建议是:纯粹的读多写少、不在乎原子性的批量操作,Pipeline 足够;像布隆过滤器这种需要把一批数据的多个位一次性置 1,又希望逻辑可控的场景,Lua 是更好的选择。
2.4 不同方案对比
| 方案 | 原子性 | 网络往返次数 | 适用场景 | 缺点 |
|---|---|---|---|---|
| 逐条 setbit | 无 | 数据条数 | 数据量极小、调试阶段 | 性能差、无法保证整体成功 |
| Pipeline | 无 | 约 1 次(打包发送) | 大批量但允许部分失败 | 缺乏原子性,错误处理复杂 |
| Lua 脚本 | 有 | 每批 1 次 | 需要原子性的批量写入 | 单次脚本长度限制,需分批 |
这个对比表是我在项目里做技术评审时整理的,当时就直接把 Pipeline 排除了。因为布隆过滤器误删一个位,可能导致后面很多真实存在的 key 全被误判为不存在,这种影响是不可接受的,必须用原子性方案兜底。
3. SpringBoot 集成:从依赖到代码实现
3.1 基础依赖与配置
项目用的是 SpringBoot 2.5.5 版本,对应的 redis 客户端是 Lettuce。引入的依赖很简单,就一个 starter:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>如果你还需要连接池功能,可以再加一个 commons-pool2 的依赖,但这次场景不是高频读写,没必要上池。Redis 连接配置在 application.yml 里按常规配置,RedisTemplate 默认使用 JdkSerializationRedisSerializer,但这种序列化方式在 Lua 脚本里传 key 时容易踩坑,所以我在配置类里把 key 和 value 的序列化器都改成了 StringRedisSerializer。
RedisTemplate 换序列化器这个步骤很多人忽略。默认的 Jdk 序列化会在 key 前面加一段二进制头信息,导致你在 Redis 命令行里看到的 key 是一串乱码,更重要的是 Lua 脚本里的 KEYS 变量取到的内容会不符合预期。这里建议直接定义成 StringRedisSerializer,后面传参数时也统一用字符串,踩坑概率大幅降低。
3.2 布隆过滤器参数初始化
先定义一个配置参数的类,方便不同环境调整。我这里以一百万个数据、误判率 1% 为例:
@Component public class BloomFilterProperties { // 预计样本量 private long expectedInsertions = 1000000L; // 误判率 private double falseProbability = 0.01D; // 布隆过滤器在 Redis 中的 key private String bloomFilterKey = "bloom:product"; }然后写一个工具类,负责计算位数组长度 m 和哈希函数数量 k。计算部分我直接复用 Guava 的 BloomFilter 来做参数推导,这样比自己算更可靠。Guava 的BloomFilter.create方法会返回一个本地过滤器实例,我们可以从它身上拿到 bit 数组长度和哈希函数的迭代次数。
public class BloomFilterCalculator { public static BloomFilterParam calculate(long expectedInsertions, double falseProbability) { BloomFilter<String> bf = BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), expectedInsertions, falseProbability ); long bitSize = bf.bitSize(); int numHashFunctions = bf.getNumHashFunctions(); return new BloomFilterParam(bitSize, numHashFunctions); } }注意 Guava 的 BloomFilter 在初始化时就会分配一块内存,100 万数据量下这块内存不大,但如果你预估数据量是几个亿,本地创建这个计算对象会占不少 JVM 内存。这时可以自己实现公式计算,不依赖 Guava。我这里是百万级,为了减少自己造轮子的风险,Guava 一把梭就行。
3.3 Lua 脚本的编写与原理
重头戏来了。批量插入布隆过滤器的 Lua 脚本长这样:
-- KEYS[1]: 布隆过滤器在 Redis 中的 key -- ARGV: 每个数据通过 k 个哈希函数得到的 k 个 offset,依次排列 local key = KEYS[1] local total = #ARGV for i = 1, total do redis.call('SETBIT', key, tonumber(ARGV[i]), 1) end return 1这段脚本的逻辑很简单:遍历所有 ARGV 参数,对每个 offset 执行 setbit 置 1。但你要注意,这段脚本只是“执行层”,真正的哈希计算是在客户端完成的。也就是说,Java 端拿到每一批数据的 ID,先通过 7 个哈希函数算出 7 个 offset,再把这一批所有 ID 的 offset 拼成一个 ARGV 数组传给脚本。
为什么不在 Lua 脚本里做哈希?两个原因。第一,Lua 脚本在 Redis 服务器端执行,如果哈希逻辑写进脚本,每次执行都会浪费服务器 CPU,而且脚本很难调试;第二,把哈希计算放在客户端,可以利用 Java 生态里成熟的哈希库,比如 Guava 的 Hashing,计算效率更高,代码也更可读。
一批数据能塞多少,取决于 Redis 命令本身的长度限制。Lua 脚本的 ARGV 参数数量太多时,命令会过大,可能导致 Redis 拒绝执行或者网络包被截断。我一般控制每批的数据量在 5000 条以内,也就是最多传 5000 × 7 = 35000 个 ARGV 参数,实测下来很稳。
3.4 Java 调用层封装
调用层我用了一个 Service 类,核心方法是 batchAdd。它的职责是:接收一批原始数据 ID,对每个 ID 计算出 k 个哈希 offset,然后调用 RedisTemplate.execute 执行 Lua 脚本。
@Service public class BloomFilterService { @Resource private StringRedisTemplate stringRedisTemplate; private static final String LUA_ADD_SCRIPT = "local key = KEYS[1] " + "local total = #ARGV " + "for i = 1, total do " + " redis.call('SETBIT', key, tonumber(ARGV[i]), 1) " + "end " + "return 1"; public void batchAdd(String filterKey, List<String> ids, long bitSize, int numHashFunctions) { DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setScriptText(LUA_ADD_SCRIPT); script.setResultType(Long.class); List<Object> args = new ArrayList<>(); for (String id : ids) { // 每个 id 计算 numHashFunctions 个 offset,放入 args long[] offsets = hashOffsets(id, bitSize, numHashFunctions); for (long offset : offsets) { args.add(String.valueOf(offset)); } } List<String> keys = Collections.singletonList(filterKey); stringRedisTemplate.execute(script, keys, args.toArray()); } private long[] hashOffsets(String id, long bitSize, int numHashFunctions) { long[] offsets = new long[numHashFunctions]; // 基于 murmur3_128 哈希,结合索引做扰动处理 HashFunction hashFunction = Hashing.murmur3_128(id.hashCode()); for (int i = 0; i < numHashFunctions; i++) { long h = hashFunction.hashLong(i).asLong(); offsets[i] = Math.floorMod(Math.abs(h), bitSize); } return offsets; } }这里有两个细节值得展开。第一,Math.floorMod(Math.abs(h), bitSize)是为了把可能为负数的哈希值映射到 0 到 bitSize - 1 的合法区间。直接用h % bitSize对负数会得到负索引,可能导致 setbit 报错。第二,多个哈希函数的实现方式,我这里是基于同一个哈希函数加不同的 seed(i)来得到不同结果,这个思路取自 Guava 源码,实测碰撞率在可接受范围内。
还有一点,业务里真正的 ID 是字符串,上面代码用id.hashCode()作为基础值,实际生产环境我会把 id 和业务类型拼接后整体做哈希,避免不同业务复用同一个过滤器时产生规律性碰撞。计算这一层不要偷懒,哈希输入越无规律,最终位分布越均匀。
3.5 批量初始化流程与测试验证
初始化流程是这样的:从数据库查出所有需要进过滤器的 ID,按每批 5000 条切分,循环调用 batchAdd。因为整个初始化过程比较耗时,我放到一个异步方法里执行,通过消息通知触发。
public void initBloomFilter() { List<String> allIds = productMapper.selectAllIds(); long bitSize = bloomFilterProperties.expectedInsertions; // 实际应从参数计算而来 int numHashFunctions = 7; int batchSize = 5000; for (int i = 0; i < allIds.size(); i += batchSize) { List<String> batch = allIds.subList(i, Math.min(i + batchSize, allIds.size())); bloomFilterService.batchAdd(bloomFilterProperties.bloomFilterKey, batch, bitSize, numHashFunctions); } }写完之后,我强烈建议在正式跑数据之前做一次冒烟测试。测试手段很简单:随机取 20 个真实存在的 ID,用 Lua 脚本的模式判断它们是否都存在;再随机生成 20 个不存在的 ID,确认它们绝大多数被正确判定为不存在。这里允许极少数误判,如果不存在的数据被误判为存在,说明误判率设置得还行,运行一段时间后再评估是否需要调参数。
4. 踩坑实录与性能复盘
4.1 常见问题与排查思路
这套方案我看着简单,但实际落地时遇到过几个不算坑但很耽误时间的问题,整理一下给大家避雷。
Lua 脚本返回类型导致的异常。DefaultRedisScript 必须设置 resultType,如果设置成 Long.class,脚本最后return 1返回的数字会被正确解析;但如果不设置,Redis 那边默认返回 byte[] 或 null,Java 端反序列化时会报 ClassCastException。所以记得一定设置 resultType。
key 序列化器不一致。前面提过,RedisTemplate 默认的序列化器和 StringRedisSerializer 混用时,Lua 脚本里 KEYS[1] 取到的可能是带前缀的二进制内容,导致 setbit 报错“bit offset is not an integer or out of range”。排查方式很简单,在 Redis 命令行用 keys 命令看实际存进去的 key 是什么样子,如果带\xAC\xED前缀就是序列化器没换。
单批数据太多导致脚本执行超时或命令过长。我最初把一次批次定为 1 万条,跑了没几条就发现 Redis 日志大量报 command timeout。后来把批次降到 5000 条,问题消失。这个超时不是 Redis 不够快,而是脚本的 ARGV 参数数量太多,命令在网络传输和解析阶段就占了大量时间。
清空布隆过滤器的需求。我们调试时经常需要重置过滤器。直接 del key 是最快的,但如果你用了 Lua 脚本批量删除位,记得布隆过滤器的位数组是一个整体,不能简单地把某个 offset 置 0 来“删除”某条数据。因为删除一个位可能影响其他数据的判断,这就是布隆过滤器不支持删除操作的根本原因。如果需要动态增删数据,建议定期重建过滤器,而不是尝试删位。
4.2 性能实测数据
我简单记录了一组数据对比。在同样的 Redis 实例上,用逐条 setbit 插入 10 万条数据,耗时约 32 秒;用 Lua 脚本每批 5000 条插入,耗时约 2.8 秒。数据量到 100 万时,逐条方案我已经不想等了,大概是五到六分钟,Lua 方案耗时 21 秒左右。
| 数据量 | 逐条 setbit | Lua 脚本(每批5000) |
|---|---|---|
| 10 万 | 32s | 2.8s |
| 50 万 | 170s | 11.2s |
| 100 万 | 约 350s | 21.5s |
中间的差距原因很清晰:逐条方案的时间几乎完全消耗在网络往返和命令解析上,服务端执行时间占比很小;Lua 方案把网络往返降到每批一次,耗时主要花在脚本循环执行 setbit 上,而且 Redis 执行 setbit 本身是 O(1) 操作,非常快。
如果你还想进一步压榨性能,可以考虑把一次脚本内循环的 setbit 换成 bitfield 批量设置位。但 bitfield 命令更底层,语义也更复杂,如果单纯为了性能没必要,等真正遇到性能瓶颈时再优化不迟。
4.3 业务落地时的几条经验
布隆过滤器初始化完成之后,后续所有新增数据都要同步写入过滤器。这里很容易出现漏网之鱼。我的做法是:在写入数据库的业务事务里,同步调用新增接口,只在事务提交成功后再写布隆过滤器。如果写过滤器失败,记录日志并放进补偿队列,由定时任务重试。
还有一点容易被忽视,就是布隆过滤器是只增不减的结构。如果一个商品被删除,它的位会永久留在过滤器里,导致查询这个商品是否存在的判断永远是“可能存在”。对大多数业务来说这个没问题,但如果你的系统对已删除数据的查询有严格有效性要求,那就需要设计更复杂的带时间戳的布隆过滤器变体,或者定期对全量数据重建过滤器。
我个人的习惯是让配置中心动态管理过滤器的 key 后缀,比如bloom:product:202501,每个月用新 key 重建一次。这样既不需要删旧 key,又能保证数据时效性。配合定时任务在业务低峰期全量重建,是实现成本最低,效果又最稳的办法。
最后想说的是,布隆过滤器永远是一个概率性方案,它解决的是缓存穿透问题,而不是精确定位问题。用了布隆过滤器之后,仍然要保留数据库兜底和限流措施,不能因为它能挡住大多数无效请求就放松对异常流量的监控。根据我的实际运维数据,启用布隆过滤器后数据库无效查询量至少下降了 97%,而误判率一直维持在 1% 以内,这个收益是非常明显的。