Redis布隆过滤器批量插入优化:SpringBoot集成Lua脚本实战
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)^2k (m / n) × ln 2举个例子。假设预估元素数量 n 100 万误判率要求 p 1%0.01代入第一个公式ln 0.01 ≈ -4.605(ln 2)^2 ≈ 0.4805m -(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 逐条调用的问题在哪第一种做法最直观从数据库查出所有 IDfor 循环一个一个调 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。引入的依赖很简单就一个 starterdependency groupIdorg.springframework.boot/groupId artifactIdspring-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) { BloomFilterString 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, ListString ids, long bitSize, int numHashFunctions) { DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText(LUA_ADD_SCRIPT); script.setResultType(Long.class); ListObject 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)); } } ListString 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 报错。第二多个哈希函数的实现方式我这里是基于同一个哈希函数加不同的 seedi来得到不同结果这个思路取自 Guava 源码实测碰撞率在可接受范围内。还有一点业务里真正的 ID 是字符串上面代码用id.hashCode()作为基础值实际生产环境我会把 id 和业务类型拼接后整体做哈希避免不同业务复用同一个过滤器时产生规律性碰撞。计算这一层不要偷懒哈希输入越无规律最终位分布越均匀。3.5 批量初始化流程与测试验证初始化流程是这样的从数据库查出所有需要进过滤器的 ID按每批 5000 条切分循环调用 batchAdd。因为整个初始化过程比较耗时我放到一个异步方法里执行通过消息通知触发。public void initBloomFilter() { ListString allIds productMapper.selectAllIds(); long bitSize bloomFilterProperties.expectedInsertions; // 实际应从参数计算而来 int numHashFunctions 7; int batchSize 5000; for (int i 0; i allIds.size(); i batchSize) { ListString 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[] 或 nullJava 端反序列化时会报 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 秒左右。数据量逐条 setbitLua 脚本每批500010 万32s2.8s50 万170s11.2s100 万约 350s21.5s中间的差距原因很清晰逐条方案的时间几乎完全消耗在网络往返和命令解析上服务端执行时间占比很小Lua 方案把网络往返降到每批一次耗时主要花在脚本循环执行 setbit 上而且 Redis 执行 setbit 本身是 O(1) 操作非常快。如果你还想进一步压榨性能可以考虑把一次脚本内循环的 setbit 换成 bitfield 批量设置位。但 bitfield 命令更底层语义也更复杂如果单纯为了性能没必要等真正遇到性能瓶颈时再优化不迟。4.3 业务落地时的几条经验布隆过滤器初始化完成之后后续所有新增数据都要同步写入过滤器。这里很容易出现漏网之鱼。我的做法是在写入数据库的业务事务里同步调用新增接口只在事务提交成功后再写布隆过滤器。如果写过滤器失败记录日志并放进补偿队列由定时任务重试。还有一点容易被忽视就是布隆过滤器是只增不减的结构。如果一个商品被删除它的位会永久留在过滤器里导致查询这个商品是否存在的判断永远是“可能存在”。对大多数业务来说这个没问题但如果你的系统对已删除数据的查询有严格有效性要求那就需要设计更复杂的带时间戳的布隆过滤器变体或者定期对全量数据重建过滤器。我个人的习惯是让配置中心动态管理过滤器的 key 后缀比如bloom:product:202501每个月用新 key 重建一次。这样既不需要删旧 key又能保证数据时效性。配合定时任务在业务低峰期全量重建是实现成本最低效果又最稳的办法。最后想说的是布隆过滤器永远是一个概率性方案它解决的是缓存穿透问题而不是精确定位问题。用了布隆过滤器之后仍然要保留数据库兜底和限流措施不能因为它能挡住大多数无效请求就放松对异常流量的监控。根据我的实际运维数据启用布隆过滤器后数据库无效查询量至少下降了 97%而误判率一直维持在 1% 以内这个收益是非常明显的。