SpringBoot整合Redis布隆过滤器:Lua脚本批量插入优化实践
发布时间:2026/10/13 5:22:09 锦皓数字建站

1. 为什么需要批量插入单条写入方案到底慢在哪先还原一下我当时的现场。某个业务要上线一个防刷接口规则很简单把80万条存量用户ID先灌进布隆过滤器之后请求进来先过过滤器不在名单里就直接挡掉在名单里才继续走后面的业务逻辑。我第一版实现非常简单粗暴——用循环逐条执行SETBIT80万条数据跑了将近一分半钟上线那天的固化参数调整场景下反复重跑好几次整个人都不好了。这也是大多数人第一次接触往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.5ms80万次往返就是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脚本遍历位置数组逐个SETBIT2.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的murmur3import 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以及用到的Guavadependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId /dependencyyml配置按常规来即可spring: data: redis: host: 127.0.0.1 port: 6379 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 44.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封装批量插入逻辑。注意DefaultRedisScriptLong是线程安全的可以在初始化时创建一次重复使用。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 DefaultRedisScriptLong batchInsertScript; public BloomFilterInitService(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate stringRedisTemplate; this.batchInsertScript new DefaultRedisScript(BloomFilterLuaScripts.BATCH_INSERT, Long.class); } public int batchInsert(ListString values) { if (values null || values.isEmpty()) { return 0; } ListString 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) { ListString 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_NUM3插入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位数组长度mbit哈希函数个数k实际占用内存10万1%958,5067约0.12MB100万1%9,585,0587约1.14MB1000万1%95,850,5847约11.43MB100万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在数据量大时是个不折不扣的大key1000万条数据对应的key可能有十几MB如果初始化时参数算错了需要重建用DEL bloom:user:2024删除它Redis主线程要真正释放这十几MB的内存可能造成几十毫秒的阻塞。正确姿势是用UNLINK它在非阻塞模式下完成内存释放对线上影响小得多UNLINK bloom:user:2024Spring Data Redis对应的操作是stringRedisTemplate.unlink(bloom:user:2024)。另外一个小习惯不要给布隆过滤器key设置过期时间。这个key是长期有效的公共状态一旦设置了EXPIRE过期后业务会自动退化到重建模式而重建期间过滤器的记忆是空的数据库风险就暴露了。需要重建时用专门的脚本任务做而不是依赖过期机制。我后来每次接布隆过滤器的需求都会先把参数算明白、把batchSize定好、把脚本预热写进启动任务最后再在部署验收时跑一遍初始化脚本核对耗时和置位次数。这套流程固定下来之后布隆过滤器就不再是上线时出了状况调半天的麻烦事而是一个稳定可靠的前置过滤组件了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。