
1. 项目缘起一个差点搞垮服务的缓存穿透事故去年我们团队接手了一个用户画像查询服务核心逻辑很简单前端传入一个用户ID后端去查这个用户的标签数据比如年龄、兴趣、城市这些。为了扛住高并发我们很自然地用Redis做了缓存查询时先查缓存缓存没有再去查数据库然后回写到Redis。这套方案运行了小半年一直风平浪静。直到一次大促活动我们上线了一个新的营销功能允许运营通过一个接口批量查询大量用户其中很多是历史僵尸用户或无效ID的画像。活动开始后几分钟监控告警就炸了数据库CPU直接飙到100%连接数打满整个服务响应时间从几十毫秒飙升到十几秒差点引发雪崩。紧急回滚功能后我们复盘发现问题就出在那个“先查缓存缓存没有再查库”的逻辑上。当海量的、数据库中根本不存在的用户ID比如-1 0 或者一些乱编的长数字瞬间涌向这个接口时每一个这样的请求都会因为缓存查不到Cache Miss而直接打到数据库上。数据库拼了老命也查不到数据只能返回空而这个“空”又因为其对应的Key用户ID是无限的我们没法、也不敢把它们都缓存起来缓存一个key: null的值且设置一个很短的过期时间理论上可行但在这种海量无效Key攻击下Redis内存会迅速被这些无意义的空值占满。这就是典型的缓存穿透问题查询一个根本不存在的数据导致请求穿透缓存直接压垮底层存储。当时我们临时用了一个“空对象缓存”的方案来止血给查不到的数据也设置一个短时间的空值标记。但这终究是治标不治本对于恶意攻击或者大量无效的随机KeyRedis内存依然面临风险。这次事故后我们决定引入一个更专业的武器来从根源上防御这类问题布隆过滤器Bloom Filter。而Redisson作为Redis的Java客户端提供了开箱即用的分布式布隆过滤器实现与Spring Boot集成起来非常顺畅。今天我就把这个从踩坑到完美解决方案的实战过程包括原理、选型、集成、踩坑和进阶用法完整地分享出来。2. 布隆过滤器用很小的代价判断“一定不存在”在深入代码之前我们必须先吃透布隆过滤器这个核心武器的工作原理。它不是一个精确的数据结构而是一个概率性的数据结构核心能力就一句话告诉你某个元素“一定不存在”或者“可能存在”于一个集合中。2.1 核心原理与工作流程你可以把布隆过滤器想象成一个很长的二进制向量bit array和一系列随机映射函数。假设我们有一个长度为m的比特数组初始所有位都是0还有k个不同的哈希函数。写入过程添加元素当我们要添加一个元素比如用户ID123456到过滤器时我们把这个元素分别用k个哈希函数计算得到k个哈希值。将这k个哈希值对数组长度m取模得到k个数组位置。将这k个位置上的比特位都设置为1。查询过程检查元素是否存在当我们要查询一个元素比如用户ID999999是否存在时同样用那k个哈希函数计算得到k个哈希值并取模得到k个数组位置。去检查这k个位置上的比特位。如果其中有任何一个位置是0那么我们可以百分之百确定这个元素绝对没有被添加到过过滤器中。如果这k个位置全部是1那么我们认为这个元素可能存在于过滤器中。这里的关键在于“可能”二字。为什么是可能因为存在哈希冲突。不同的元素经过哈希计算后可能会映射到比特数组的相同位置。当元素越来越多越来越多的比特位被置为1某个新元素即使从未被添加它对应的k个位置也可能碰巧都被其他元素置为了1这就导致了误判False Positive。布隆过滤器永远不会出现“假阴性”即元素实际存在却判断为不存在但会有一定概率的“假阳性”。2.2 为什么它能解决缓存穿透结合我们遇到的场景思路就清晰了系统初始化时将数据库中所有有效的用户ID全量初始化到布隆过滤器中。请求查询时在查询缓存和数据库之前先问布隆过滤器“这个用户ID存在吗”如果布隆过滤器说“一定不存在”那我们就可以直接返回空结果或错误根本不需要去查缓存和数据库。请求在此被拦截数据库得到了保护。如果布隆过滤器说“可能存在”那我们才继续走原有的“查缓存 - 查数据库 - 回写缓存”流程。这样一来那些海量的、数据库里根本不存在的无效ID在第一步就会被布隆过滤器拦截掉从根本上避免了它们对数据库的冲击。2.3 关键参数容量、误判率与内存估算使用布隆过滤器前有三个参数必须心里有数预期插入数量n你预计会往过滤器里放多少个元素。比如我们系统有1亿个用户n就是1亿。可容忍的误判率fpp你能接受多高的“假阳性”概率。比如0.01%万分之一或者0.1%千分之一。这个值越小过滤器的准确度越高但所需的内存空间也越大。比特数组长度m和哈希函数个数k这两个值可以由n和fpp计算出来。有一个经典的公式来估算所需比特数组大小m和哈希函数个数km - (n * ln(p)) / (ln(2))^2 k (m / n) * ln(2)其中p是可容忍的误判率fpp。我举个例子帮你算一下假设我们有1亿个用户n100,000,000期望误判率是0.1%p0.001。计算mm - (100000000 * ln(0.001)) / (ln(2))^2 ≈ - (100000000 * -6.9078) / 0.4805 ≈ 1.437e9比特。换算成字节1.437e9 / 8 ≈ 179.6 MB。计算kk (1.437e9 / 1e8) * 0.693 ≈ 9.96 约等于10个哈希函数。也就是说用大约180MB的内存空间配合10个哈希函数我们就可以构建一个能容纳1亿元素、误判率仅千分之一的布隆过滤器。这个内存开销对于现代服务器和Redis来说是完全可接受的。相比之下如果我们要缓存1亿个key: null的短时值开销远不止于此。注意布隆过滤器有一个重要限制——不支持删除。因为删除一个元素将其对应的k个比特位置0可能会影响其他也映射到这些位置上的元素导致它们被误判为不存在。如果业务有删除需求需要考虑变种结构如“计数布隆过滤器”但那个结构更复杂内存开销也更大。在我们的场景中用户ID一旦创建通常不会删除所以标准布隆过滤器完全适用。3. 技术选型为什么是RedissonJava里实现布隆过滤器自己从头写不是不行但要考虑分布式环境下的同步、持久化、高可用那就复杂了。常见的选型有Guava和Redisson。Guava BloomFilterGoogle Guava库提供的本地内存布隆过滤器。优点是性能极高使用简单。但致命缺点是它是单机的。在微服务集群环境下每个服务实例都需要维护自己的一份过滤器数据初始化全量数据时会对数据库造成巨大压力而且各实例之间的数据无法同步。这显然不适合我们的分布式缓存场景。Redisson BloomFilterRedisson在Redis的基础上实现了分布式的布隆过滤器。它的所有数据那个比特数组都存储在Redis中。这意味着数据共享整个微服务集群的所有实例都访问同一个布隆过滤器数据状态是统一的。持久化与高可用依托于Redis自身的持久化RDB/AOF和集群模式数据不会丢失并且具备高可用性。开箱即用Redisson提供了非常简洁的APIRBloomFilter接口创建、添加、查询一气呵成无需关心底层比特位操作和分布式同步问题。动态扩容有限Redisson的布隆过滤器在创建时需要指定预期容量和误判率。如果后期数据量远超预期虽然不能直接修改已存在的过滤器但可以通过业务逻辑设计例如使用多个过滤器来变通解决。对于我们这个需要跨多个服务实例、共同防御缓存穿透的场景Redisson的分布式布隆过滤器是唯一靠谱的选择。它把复杂的分布式一致性问题交给了Redis让我们可以专注于业务逻辑。4. Spring Boot集成Redisson布隆过滤器全流程理论铺垫完毕接下来就是实战环节。我会从环境搭建、配置、初始化到业务集成一步步拆解。4.1 环境准备与依赖引入首先确保你有一个可用的Redis服务器单机、哨兵或集群模式均可。这里以Spring Boot 3.x为例。在你的pom.xml中添加Redisson的Spring Boot Starter依赖。这个starter会自动配置RedissonClient比手动配置省心很多。dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.0/version !-- 请使用与你的Spring Boot版本兼容的最新版 -- /dependency4.2 Redisson连接配置在application.yml中配置Redis连接信息。这里展示单节点和集群两种最常用的配置。单节点Redis配置spring: data: redis: # 这些是Spring Data Redis的配置Redisson Starter也会读取部分 host: localhost port: 6379 password: yourpassword # 如果没有密码则删除此行 database: 0 # Redisson专属配置优先级更高更推荐 redisson: config: | singleServerConfig: address: redis://localhost:6379 password: yourpassword # 可选 database: 0 connectionPoolSize: 64 # 连接池大小 connectionMinimumIdleSize: 24 # 最小空闲连接数 idleConnectionTimeout: 10000 # 连接空闲超时时间 connectTimeout: 10000 # 连接超时时间 timeout: 3000 # 命令等待超时时间使用redisson.config属性你可以直接写一个Redisson的JSON或YAML格式的配置字符串这种方式最灵活可以配置所有Redisson支持的参数。Redis集群配置如果你的Redis是集群模式配置如下redisson: config: | clusterServersConfig: nodeAddresses: - redis://192.168.1.101:7001 - redis://192.168.1.102:7002 - redis://192.168.1.103:7003 password: yourpassword # 集群密码如果所有节点密码一致 scanInterval: 5000 # 集群状态扫描间隔时间(ms) # 其他连接池参数与单节点类似 masterConnectionPoolSize: 64 slaveConnectionPoolSize: 64配置完成后Spring Boot会自动帮你创建一个RedissonClientBean你可以在任何需要的地方Autowired注入它。4.3 布隆过滤器Bean的创建与初始化这是最关键的一步。我们创建一个配置类来初始化我们业务所需的布隆过滤器。这里以“用户ID过滤器”为例。import org.redisson.api.RBloomFilter; import org.redisson.api.RedissonClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class BloomFilterConfig { /** * 用户ID布隆过滤器 * 预期插入数据量1亿 * 可容忍的误判率0.1% */ Bean(userIdBloomFilter) public RBloomFilterLong userIdBloomFilter(RedissonClient redissonClient) { // 通过RedissonClient获取一个布隆过滤器实例并指定一个唯一的名称 RBloomFilterLong bloomFilter redissonClient.getBloomFilter(USER_ID_BLOOM_FILTER); // 初始化布隆过滤器 // 参数1: expectedInsertions - 预期插入的元素数量 // 参数2: falseProbability - 可容忍的误判率 boolean isInitialized bloomFilter.tryInit(100_000_000L, 0.001); if (isInitialized) { System.out.println(用户ID布隆过滤器初始化成功。); } else { // 如果过滤器已经存在例如服务重启tryInit会返回false System.out.println(用户ID布隆过滤器已存在直接使用。); } // 重要这里只完成了过滤器的创建和初始化并没有加载数据。 // 实际的有效ID数据需要在服务启动后从数据库加载并添加到过滤器中。 return bloomFilter; } }关键点解析redissonClient.getBloomFilter(USER_ID_BLOOM_FILTER)这个name就是存储在Redis中的key。所有服务实例通过这个相同的name访问的都是同一个布隆过滤器。tryInit(100_000_000L, 0.001)这个方法用于初始化过滤器。它接收两个参数预期插入量1亿和误判率0.001。这个操作在过滤器的生命周期中理论上只应执行一次。如果Redis中已经存在同名的、初始化过的布隆过滤器再次调用tryInit会返回false并且不会修改已有的参数。这保证了服务重启时不会覆盖已有的数据。数据加载是独立的tryInit只创建了“空壳”。我们必须另写逻辑将数据库中已有的有效用户ID遍历并调用bloomFilter.add(userId)添加到过滤器中。这个过程通常放在应用启动后执行可以是一个PostConstruct方法或者一个独立的初始化服务。4.4 数据预热启动时加载全量数据我们需要在应用启动后将数据库里的有效ID“预热”到布隆过滤器中。这里要注意性能如果数据量极大上亿需要分批处理。import jakarta.annotation.PostConstruct; import org.redisson.api.RBloomFilter; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; import java.util.List; Component public class BloomFilterDataLoader { private final RBloomFilterLong userIdBloomFilter; private final UserService userService; // 假设有一个获取所有有效用户ID的服务 public BloomFilterDataLoader(Qualifier(userIdBloomFilter) RBloomFilterLong userIdBloomFilter, UserService userService) { this.userIdBloomFilter userIdBloomFilter; this.userService userService; } /** * 应用启动后加载数据到布隆过滤器 * 注意对于超大数据集需要分页/分批加载避免内存溢出和长时间阻塞启动。 */ PostConstruct public void initBloomFilterData() { System.out.println(开始预热用户ID布隆过滤器数据...); long startTime System.currentTimeMillis(); int pageSize 10000; long lastId 0L; boolean hasMore true; while (hasMore) { // 分页从数据库获取用户ID列表这里假设UserService有一个分页查询方法 ListLong userIdBatch userService.getValidUserIdsPage(lastId, pageSize); if (userIdBatch.isEmpty()) { hasMore false; break; } // 批量添加到布隆过滤器 for (Long userId : userIdBatch) { userIdBloomFilter.add(userId); } // 更新最后一个ID用于下一次分页 lastId userIdBatch.get(userIdBatch.size() - 1); System.out.println(已加载一批数据最后ID: lastId); } long cost System.currentTimeMillis() - startTime; System.out.println(用户ID布隆过滤器数据预热完成耗时: cost ms); System.out.println(当前过滤器预估元素数量: userIdBloomFilter.count()); // 这是一个估算值 } }踩坑提示1初始化时机与并发务必确保tryInit在数据加载add之前完成并且只执行一次。在集群部署时多个实例可能同时启动。虽然tryInit是幂等的但数据加载add操作可能会被多个实例重复执行。虽然重复add同一个元素是安全的幂等但会带来不必要的性能开销。更严谨的做法是借助分布式锁Redisson也提供了RLock或者将数据预热做成一个独立的、只执行一次的任务比如在启动的第一个实例上执行。踩坑提示2大数据量加载优化对于亿级数据循环add可能会比较慢。Redisson的RBloomFilter没有提供原生的批量addAll接口。在实际生产中我们可能会使用管道pipeline技术来减少网络往返但Redisson的高级接口对此支持不直接。考虑在服务低峰期进行预热。如果数据来源于其他系统如数仓可以探索能否在数据源头直接生成布隆过滤器的比特位图然后通过Redis命令直接写入但这属于更高级的用法。5. 业务逻辑集成与防穿透实战现在布隆过滤器已经准备就绪我们可以改造之前的业务查询逻辑了。5.1 改造后的查询流程以下是集成布隆过滤器后的用户查询服务核心逻辑import org.redisson.api.RBloomFilter; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import java.util.concurrent.TimeUnit; Service public class UserProfileService { private final RBloomFilterLong userIdBloomFilter; private final UserProfileRepository userProfileRepository; // 假设是数据库访问层 private final RedisTemplateString, Object redisTemplate; // Spring Data Redis 模板 // 缓存Key的前缀 private static final String CACHE_KEY_PREFIX user:profile:; public UserProfileService(Qualifier(userIdBloomFilter) RBloomFilterLong userIdBloomFilter, UserProfileRepository userProfileRepository, RedisTemplateString, Object redisTemplate) { this.userIdBloomFilter userIdBloomFilter; this.userProfileRepository userProfileRepository; this.redisTemplate redisTemplate; } /** * 查询用户画像集成布隆过滤器版本 */ public UserProfile getUserProfile(Long userId) { // 1. 参数基础校验 if (userId null || userId 0) { // 对于明显无效的ID可以直接快速失败连布隆过滤器都不用查 return null; } // 2. 布隆过滤器校验 if (!userIdBloomFilter.contains(userId)) { // 布隆过滤器断定“一定不存在” log.warn(布隆过滤器拦截无效用户ID: {}, userId); // 这里可以返回null或者抛出一个自定义的业务异常如UserNotFoundException return null; } // 3. 布隆过滤器判断“可能存在”继续走缓存查询 String cacheKey CACHE_KEY_PREFIX userId; UserProfile profile (UserProfile) redisTemplate.opsForValue().get(cacheKey); if (profile ! null) { // 缓存命中 return profile; } // 4. 缓存未命中查询数据库 profile userProfileRepository.findById(userId).orElse(null); if (profile null) { // 数据库也不存在注意这里说明发生了“误判”False Positive。 // 布隆过滤器说“可能存在”但数据库没有。这是允许的小概率事件。 log.info(发生布隆过滤器误判用户ID在DB中不存在: {}, userId); // 可选操作将这个不存在的ID也缓存一个短时间的空值防止同一ID频繁误判击穿DB。 // 但需谨慎评估内存和业务影响因为误判率本身就很低。 // redisTemplate.opsForValue().set(cacheKey, new NullValue(), 30, TimeUnit.SECONDS); return null; } // 5. 数据库存在回写缓存 redisTemplate.opsForValue().set(cacheKey, profile, 1, TimeUnit.HOURS); // 缓存1小时 return profile; } }5.2 流程分析与关键决策点前置校验在进入布隆过滤器之前先做一层简单的业务校验如ID非空、大于0。这可以过滤掉一部分明显不合法的请求减轻过滤器的压力。过滤器拦截!userIdBloomFilter.contains(userId)为true是黄金时刻意味着请求被完美拦截后续的缓存和数据库操作都省了性能开销极低。处理误判当过滤器返回true可能存在但数据库查不到时这就是那千分之一根据我们设定的fpp的误判发生了。代码中对此进行了日志记录。这里有一个重要的设计抉择是否要将这个不存在的key也缓存起来缓存空值可以避免同一个不存在的ID在短时间内因多次误判而反复查询数据库。但需要设置一个较短的TTL如30秒并且要意识到这会让你的缓存层重新面临大量无效Key占内存的风险尽管概率低了很多。不缓存空值接受极低概率的数据库查询。对于误判率0.1%的场景如果QPS是1000那么误判打到数据库的请求也就1个/秒数据库完全能承受。我个人更倾向于不缓存空值保持逻辑的简洁并依赖极低的误判率来保障数据库安全。除非你的业务对“误判导致的单次数据库查询”都零容忍。缓存更新当新增一个用户时除了写入数据库务必记得将其ID添加到布隆过滤器中 (userIdBloomFilter.add(newUserId))。否则新用户将永远被过滤器拦截导致业务故障。这是一个容易遗漏的坑必须通过代码规范或AOP等手段来保证。6. 生产环境进阶考量与避坑指南把Demo跑通只是第一步要上生产环境还有一堆坑等着你。6.1 过滤器参数误用与重建难题坑描述在项目初期你预估用户量是1000万设置了tryInit(10_000_000, 0.001)。结果业务发展迅猛用户量很快突破5000万。这时你会发现误判率急剧上升因为实际插入数量远超预期容量。根因分析布隆过滤器的比特数组大小m和哈希函数个数k在初始化时就固定了。当插入的元素数量n超过预期的expectedInsertions时实际的误判率p会快速趋近于1过滤器几乎失效。解决方案预留缓冲初始化时expectedInsertions参数要留有足够余量比如预估最大容量的1.5到2倍。监控与告警监控布隆过滤器的count()方法返回预估的已插入元素数量。当它接近初始化容量时触发告警。动态迁移方案这是最复杂但最根本的解决之道。需要设计一个平滑的迁移流程创建一个新的、容量更大的布隆过滤器例如USER_ID_BLOOM_FILTER_V2。双写所有add操作同时写入旧过滤器和新过滤器。数据迁移编写一个离线任务将旧过滤器对应的所有有效ID需要从源数据如数据库重新获取批量添加到新过滤器中。切换查询在某个低峰期将业务代码中的过滤器Bean指向新的V2过滤器。清理旧数据确认切换无误后删除旧的过滤器。6.2 分布式环境下的初始化竞态条件坑描述在Kubernetes中滚动更新或同时启动多个服务实例时每个实例的PostConstruct初始化方法都会执行tryInit和数据加载add。tryInit是幂等的没问题但数据加载会被重复执行多次虽然结果正确但造成了大量的网络和Redis IO浪费。根因分析没有对数据预热过程做分布式协调。解决方案使用分布式锁确保全局只执行一次数据预热。import org.redisson.api.RLock; import org.redisson.api.RedissonClient; Component public class BloomFilterDataLoader { // ... 其他注入 ... private final RedissonClient redissonClient; PostConstruct public void initBloomFilterData() { String lockKey lock:bloomfilter:init:userid; RLock lock redissonClient.getLock(lockKey); // 尝试获取锁等待10秒锁持有30分钟根据数据量调整 boolean isLocked false; try { isLocked lock.tryLock(10, 30, TimeUnit.MINUTES); if (isLocked) { // 获取锁成功执行初始化逻辑 System.out.println(成功获取分布式锁开始初始化布隆过滤器数据...); // ... 原有的分页加载数据逻辑 ... } else { // 获取锁失败说明其他实例正在初始化或已完成 System.out.println(未获取到锁跳过数据初始化。); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); System.out.println(锁等待被中断。); } finally { if (isLocked lock.isHeldByCurrentThread()) { lock.unlock(); } } } }6.3 内存与性能监控布隆过滤器是放在Redis里的它占用的是Redis的内存。你需要监控这个特定KeyUSER_ID_BLOOM_FILTER的内存使用情况。可以使用Redis命令MEMORY USAGE key来查看。性能方面contains和add操作都是O(k)的时间复杂度k是哈希函数个数我们之前算出来是10即只需要几次哈希计算和内存访问速度非常快通常能在1毫秒内完成对接口性能的影响微乎其微。6.4 数据一致性删除与更新怎么办这是布隆过滤器的天然缺陷。如果你删除了数据库中的某个用户你无法从布隆过滤器中删除这个用户的ID。这会导致“数据已删除但过滤器仍认为可能存在”的情况。对于这个问题的处理需要结合业务逻辑删除如果业务是逻辑删除标记is_deleted1那么布隆过滤器无需变动。在业务查询时即使缓存穿透了查到数据库的逻辑删除状态也可以按“不存在”处理并返回空注意此时不要缓存这个空结果因为ID是有效的只是状态是删除。物理删除如果必须物理删除那么布隆过滤器就会产生“脏数据”。一种补偿方案是引入一个“删除清单”例如另一个Redis Set里面存放已删除的ID。在contains检查通过后再去查一下这个“删除清单”如果存在则按不存在处理。但这增加了复杂度和一次查询开销。所以对于需要删除元素的场景布隆过滤器需要谨慎评估。7. 不只是防穿透布隆过滤器的其他妙用掌握了防缓存穿透这个核心场景后布隆过滤器在其他地方也能大放异彩。场景一防止重复提交幂等性校验用户提交一个表单前端生成一个唯一请求IDUUID。服务端在处理前先查布隆过滤器这个ID是否存在。如果不存在则处理请求并将ID加入过滤器设置一个合理的过期时间可以通过Redisson的RClusteredBloomFilter或自行封装TTL逻辑实现。如果存在则认为是重复提交直接返回。这比去数据库或缓存查唯一索引要轻量得多。场景二推荐系统去重在新闻、视频、商品推荐流中需要过滤掉用户已经看过的内容。可以将用户看过的内容ID加入一个布隆过滤器。在生成推荐列表时快速过滤掉那些“可能已读”的内容。因为存在误判用户可能会看到极少量的重复内容但这在推荐系统的体验容忍范围内却换来了巨大的性能提升。场景三爬虫URL去重网络爬虫需要判断一个URL是否已经抓取过。将已抓取的URL放入布隆过滤器在抓取新URL前先进行判断可以避免大量的重复抓取和存储开销。布隆过滤器以其极小的空间占用和常数级的查询时间在“存在性判断”且允许小概率误判的场景下是一个无可替代的神器。它与Spring Boot、Redisson的结合为分布式系统解决缓存穿透问题提供了一套优雅、高效、可靠的标准化方案。从那次事故后这套方案已经成为我们所有高并发查询服务的标配防护组件。