高并发场景下缓存穿透和缓存击穿是每一套基于Redis的业务系统都绕不开的两个经典敌人。面试八股文里它们是高频考题生产环境里它们更是实打实出过事故的。我经历过一次凌晨大促时热点商品ID被攻击者批量刷不存在数据DB连接池被打满核心接口超时率飙升当时我们的缓存层几乎没有防御能力纯靠数据库硬扛。那之后我们团队花了三个迭代把缓存防护逻辑从散落的业务代码里抽出来封装成一套通用工具覆盖布隆过滤器拦截、分布式锁重建、本地缓存兜底三条链路。这篇文章就把这套封装方案的完整思路、代码细节、参数计算和踩坑记录整理出来给同样在治理缓存问题的同学一个可落地的参考。这套封装方案适合正在维护高并发服务的后端开发者尤其适合那些已经接入了Redis、但还在用最原始的check-then-act方式处理缓存并且被穿透和击穿过几次、想系统性解决这个问题的团队。内容不要求你有多深的分布式基础只要会用Spring Boot、写过RedisTemplate就能跟上我会把每一步的原理和取舍逻辑都讲明白。1. 穿透和击穿先分清敌人再动手很多团队一上来就想着写布隆过滤器、写分布式锁但连自己面对的是穿透还是击穿都还没分清。两种问题表现相似都是“缓存里查不到请求打到DB”但根因、攻击特征和解决方案完全不同混在一起治理必然事倍功半。1.1 两种问题的核心差异缓存穿透查的是数据库中压根不存在的数据。缓存里没有数据库里也没有于是每一次请求都老老实实打到DB查了个寂寞。这通常是恶意攻击者用随机ID、自增负数、不存在的手机号等批量刷接口瞬间流量全部穿透缓存直达存储层。更麻烦的是因为DB里没有数据正常逻辑下你不会写缓存所以穿透是“每一次都发生”的不像击穿只是窗口期那几秒。缓存击穿针对的是某个热点key。这个key平时被大量线程并发读取比如秒杀商品的库存、爆款视频的播放量、明星动态的热度值Redis里明明有缓存但缓存突然过期了。在过期的那一瞬间所有并发请求同时发现缓存miss于是一股脑冲进DB查同一行数据数据库单key的QPS瞬间飙升。注意击穿的场景是“数据存在、缓存短暂失效”而穿透是“数据本身不存在”。顺便说一句缓存雪崩大量key同时过期导致大面积DB压力是另一个问题它的核心是“批量失效”而不是“单点失效”治理手段也偏重在过期时间上打散抖动。这篇文章的互斥锁方案对雪崩帮不上什么忙但会在TTL策略里顺带提到一点免得大家混淆。1.2 为什么必须封装而不是每个接口自己写我在很多项目里看到过这样的代码每个Service层都有一段“先查缓存没有就查库再放缓存”的逻辑各自为政。你今天在这个接口加了布隆过滤器判断明天那个接口漏了这个接口重建缓存时抢锁了那个接口直接裸奔空值缓存有的做了、有的没做TTL还各不相同。封装的核心价值不在于省几行代码而在于把防御行为统一化。业务方只需要调用工具类的一个方法传入key和加载函数工具内部自动完成布隆过滤、缓存查询、锁保护、空值兜底、本地缓存降级这一整套动作。业务代码里不再出现任何关于锁、过滤器、TTL的细节改动也只需动工具层一处全链路生效。我们封装之后新接口接入缓存防护的成本从半天降到十分钟而且不会再出现“忘了加防护”这种低级事故。2. 方案选型三层防护的组合逻辑单独用任何一种方案都有明显短板我们的最终形态是布隆过滤器、分布式锁、本地缓存三层配合各管一段。2.1 三层防护各自解决的问题第一层是布隆过滤器挡在查询链路的最前端用极小的内存代价拦截掉绝大多数“不存在key”的查询这是对抗穿透的主力。它的特点是“宁可错杀一千不可放过一个”也就是说它只会误判“可能存在”但从来不会漏判“一定不存在”所以直接命中DB的查询量被压到极低。第二层是分布式锁专门处理热点key过期瞬间的并发重建问题。锁保护下同一时刻只有一个线程去查DB并回填缓存其他线程等缓存就绪后直接读这是对抗击穿的主力。第三层是本地缓存Caffeine放在应用进程内作为极端情况下的最后一道兜底。当Redis不可用或者刚发生缓存重建时本地缓存还能提供一份旧数据避免所有请求瞬间压到DB。这一层我们的定位是“容灾”不追求强一致只求降级时用户还能看到数据。这三层不是叠加出来的是从实际故障场景里反推出来的。最早我们只有分布式锁后来发现攻击者用随机ID刷穿透时锁根本没用——因为key都不一样锁粒度根本不收敛必须靠布隆过滤器在前面把大部分流量挡掉。后来又发现DB连接池被打满时即使Redis恢复了短期内流量涌入也会让服务抖动本地缓存才补上来。2.2 为什么不只做空值缓存很多人问缓存穿透最简单的方法不就是把空结果也缓存起来吗确实对“固定ID不存在”的场景空值缓存很有效查询DB发现没数据就把null塞进Redis设个一两分钟TTL下次同key查询直接命中空值。但请注意恶意穿透的攻击特征是“随机key”今天刷A明天刷B每个key都是新的空值缓存根本拦不住——因为每来一个新key你都得先查一遍DB才知道它是空的然后才把它缓存起来。攻击者只要保持每秒几万个新key的速率DB依然被打穿而且Redis里还会堆积大量无意义的空值缓存白白消耗内存。布隆过滤器则完全不同。它用固定的位数组存储所有“可能存在”的key指纹占用空间是固定的跟已经查询了多少不存在的key无关。查询时用哈希计算在位数组里找标记标记不存在就直接返回连DB都不碰。所以它对“随机key批量穿透”这类攻击有天然的过滤能力这是空值缓存做不到的。我们的实际做法是两者结合布隆过滤器作为第一道闸过滤掉绝大多数不存在的key对于漏网之鱼布隆过滤器误判的少量key以及业务上合法但当前无数据的key再用空值缓存做第二道兜底。空值TTL设置得很短比如90秒既能挡住短时间内的重复查询又不会让无效数据长期占用内存。2.3 锁方案为什么选Redisson而不是手写setnx缓存击穿的互斥锁最朴素的做法是Redis的SETNX抢到锁的线程查DB回填其他线程自旋等待。但手写SETNX有一堆细节要处理锁要设置过期时间防止持有锁的线程宕机导致死锁过期时间设多长很难拿捏设短了线程还没查完DB锁就释放了设长了锁故障时恢复慢还要考虑重入、锁续期、释放时误删别人的锁。这些边角问题在真实故障里全是坑。Redisson的RLock把这些都解决了通过看门狗机制自动续期默认每10秒检查一次只要线程还在执行就不断续期避免锁因业务执行时间过长而提前释放支持可重入同一线程可以重复获取锁释放锁时通过Lua脚本保证原子性只有持有者才能释放。我们用Redisson后锁相关的故障基本绝迹了这也是我强烈不建议手写锁的原因。2.4 为什么不用现成框架而选择自研封装市面上确实有现成的缓存框架比如JetCache、Spring Cache的Redis实现等它们大多提供了Cached注解和统一的缓存访问入口。但我们的场景有几个特殊需求第一需要和布隆过滤器深度集成大部分框架只是简单的key-value存取过滤器要单独在业务代码里手动调用没法统一第二需要热点key的动态识别与续期框架层面很少内置这种策略第三我们的调用形态非常灵活有些缓存是一段计算结果而不是简单的DB行记录需要传入Supplier函数让工具按需加载。自研封装并不意味着从零造轮子。Redisson、Caffeine、布隆过滤器的实现都直接复用成熟组件我们只做组合和编排相当于把零件组装成一台专用机器这个成本比改造一个通用框架要低得多也更贴合团队的业务习惯。3. 核心实现布隆过滤器拦截缓存穿透这块是整套封装里技术含量最高的部分布隆过滤器用最小的内存挡住了最大量的无效请求。我尽量把原理、参数和代码一次讲透。3.1 布隆过滤器原理与参数计算布隆过滤器的核心是一个m位的位数组和k个哈希函数。插入一个key时用k个哈希函数分别计算得到k个下标把位数组对应位置置1。查询一个key时同样计算k个下标如果发现任何一个位置是0说明这个key一定不存在如果k个位置全是1说明可能存在也可能是因为多个不同key哈希重叠导致的误判。它的优势是空间效率极高缺点是两个一是不能删除元素删掉一个key后无法把对应位清零因为那一位可能被其他key共用二是存在误判率p随着插入数据量逼近容量上界误判率会上升。参数计算有两个核心公式我实际使用中每次都靠它们算容量位数组大小m - (n * ln(p)) / (ln(2))^2哈希函数个数k (m / n) * ln(2)举个例子假设我们要存储1000万个合法key希望误判率控制在1%计算得到m约等于9585万bit换算成内存大概是11.4MBk约等于7。这是一个非常划算的代价——11MB内存换来拦截99%的不存在key查询而如果用空值缓存1000万个key的存储成本远不止这个数。通过占位符可以快速验证参数我写了个简单的在线计算脚本把n和p输入进去直接出m和k避免人工计算出错。3.2 基于Redisson布隆过滤器的初始化实现Redisson提供了现成的RBloomFilter实现配置方式很简单。我们把它封装在BloomFilterManager里启动时自动初始化Component public class BloomFilterManager { private static final String BLOOM_FILTER_KEY bloom:user:id; private final RedissonClient redissonClient; public BloomFilterManager(RedissonClient redissonClient) { this.redissonClient redissonClient; } public RBloomFilterString getUserBloomFilter() { RBloomFilterString bloomFilter redissonClient.getBloomFilter(BLOOM_FILTER_KEY); // 这里传入预期元素量和误判率Redisson会自行计算位数组大小和哈希函数个数 bloomFilter.tryInit(10_000_000L, 0.01); return bloomFilter; } }注意tryInit只会初始化一次第二次调用时如果参数一致就直接返回已有实例如果参数变了它会重新初始化这时候已插入的数据会丢失。所以这个参数一旦定下来尽量不要在生产环境修改。我踩过一次这个坑因为预估数据量翻倍了我把expectedInsertions改大了结果布隆过滤器被清空重建导致整条链路上所有穿透请求全部压到DB好在当时是低峰期几分钟后数据重新加载完才恢复。3.3 合法数据集的加载策略布隆过滤器本身只是存储指纹它不会平白知道哪些key是合法的。我们需要把合法的用户ID、商品ID等预加载进去。这里有一个关键选择全量加载还是异步增量加载。全量加载适合ID总量可控的场景比如用户表几百万行启动时从DB查一次全量ID批量填充到过滤器里。但要注意两点第一批量加载时DB的IO压力陡增建议分批查询每批一万条用线程池并发填充第二全量加载耗时可能较长这期间过滤器是不完整的如果服务已经对外提供服务漏掉的部分会导致合法请求也被拦截。我们实际采用的是“全量加载 增量写入”的组合。启动时加载一次存量数据业务上每次新增合法ID时同步调用过滤器add方法补上。这要求所有写入口共享同一个过滤管理器否则增量容易漏。还有一个容易被忽视的点如果业务上有删除操作比如删除用户、下架商品布隆过滤器无法删除对应指纹这些ID会一直残留在过滤器里。对策是在过滤器判断“可能存在”后业务查询DB仍可能返回空这时用空值缓存兜底避免每次都穿透到DB。3.4 查询链路上的拦截逻辑布隆过滤器拦截逻辑在封装好的CacheService中统一实现业务方感知不到。核心代码如下public T T getWithBloomFilter(String key, String bloomName, SupplierT dbLoader, Duration ttl) { RBloomFilterString bloomFilter getBloomFilter(bloomName); // 说明布隆过滤器判断不存在直接返回null连Redis都不查 if (!bloomFilter.contains(key)) { return null; } // 布隆过滤器判断可能存在继续走缓存查询链路 T value redisTemplate.opsForValue().get(key); if (value ! null) { return value; } // 尝试加锁重建这一段在第四章详细展开 return loadFromDbWithLock(key, dbLoader, ttl); }这里有一个性能细节contains判断之前应该先拼好完整key并把业务前缀带上。比如用户ID是10001布隆过滤器的key应该是user:10001而不是裸的10001。因为不同业务可能共用同一个过滤器如果都用同一个那必须带前缀区分也方便排查时直接看到key归属哪个模块。我们在实践中统一约定key的命名规则业务域:业务类型:ID布隆过滤器名称也用这个前缀保证业务之间互不干扰。4. 核心实现分布式锁与热点缓存击穿治理解决了穿透接下来是击穿。热点key在过期瞬间的并发重建是另一个必须用锁才能压住的场景。4.1 互斥锁重建的完整流程当Redis缓存miss后我们不直接放所有线程进DB而是先抢分布式锁。抢到锁的线程才去查DB并回填缓存没抢到锁的线程等待一段时间后重查缓存。流程上有一个必须注意的关键点抢到锁的线程在查DB之前要二次检查缓存防止其他线程已经重建完成这叫double check。我给出一个完整的循环版本避免用递归写法导致栈溢出public T T loadFromDbWithLock(String key, SupplierT dbLoader, Duration ttl) { String lockKey lock: key; RLock lock redissonClient.getLock(lockKey); try { // 等待锁最多3秒leaseTime设为-1表示交给看门狗自动续期 boolean locked lock.tryLock(0, -1, TimeUnit.SECONDS); if (locked) { try { // double check可能其他线程已经在锁内重建好了 T cached redisTemplate.opsForValue().get(key); if (cached ! null) { return cached; } T value dbLoader.get(); if (value ! null) { redisTemplate.opsForValue().set(key, value, ttl); } else { // 空值也缓存防止穿透 redisTemplate.opsForValue().set(key, (T) NULL_PLACEHOLDER, Duration.ofSeconds(90)); } return value; } finally { lock.unlock(); } } else { // 没抢到锁小睡一会儿再查缓存最多重试3次 for (int i 0; i 3; i) { Thread.sleep(50L * (i 1)); T cached redisTemplate.opsForValue().get(key); if (cached ! null !NULL_PLACEHOLDER.equals(cached)) { return cached; } } // 重试3次仍未拿到数据说明DB很慢或锁等待很久降级返回null由上层决定 return dbLoader.get(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); return dbLoader.get(); } }这段代码里有几个细节值得展开。tryLock第一个参数waitTime设成0意思是抢不到锁立刻返回false不阻塞等待。正因为waitTime为0抢锁失败的线程会走for循环自己去重查缓存。这个策略比让所有线程阻塞在锁上更高效因为Redis重建通常是毫秒级等50毫秒再查基本都能拿到数据。重试3次后仍然没拿到说明可能出现了极端情况比如DB查询时间特别长。此时不能再无限等下去直接放行去查DB是降级策略同时上游要做好限流避免这少量请求把DB打崩。我建议这里记录一条WARN日志方便后续排查为什么锁内重建这么慢。4.2 锁的粒度和锁内耗时控制锁的粒度是击穿治理里最容易拍脑袋的地方。我们一开始偷懒把所有缓存重建操作都放到同一把锁上锁的字符串是lock:cache_rebuild。结果压测时发现一个热点key在重建时其他不相关的key查询也被迫等待因为锁冲突是全量的。这个方案在并发高的时候性能极差。正确的粒度是锁的key必须包含业务keylock: 完整key让每个业务key有一把独立的锁。不同key之间互不阻塞同一个key的并发请求才互斥这才是分布式锁在缓存重建里的正确用法。锁粒度越小并发度越高但也不能太小如果key本身包含用户的个性化参数导致几乎每个请求都不同锁就形同虚设了。所以锁粒度应该落在“热点数据的集合”这个粒度上。锁内耗时要严格控制。锁内做了三件事查DB、序列化结果、写入Redis。查DB是最不可控的一环如果SQL很慢锁会一直持有其他线程等待也就越久。两个优化方向一是SQL本身加索引、限流、降级尽量保证单查询在几十毫秒内返回二是给查询加一个超时控制比如调用DB的SocketTimeout超过500毫秒直接抛异常让失败快速暴露不要吊死在慢SQL上。我们曾经遇到过一个热点key关联的SQL因为多表联查在大促时超过3秒导致锁内大量线程堆积直到我们把查询拆成两步缓存之后才好转。4.3 热点key的逻辑过期与主动续期互斥锁解决了并发重建但还有一个场景它解决不了热点key过期后的那几百毫秒内虽然只有重建的线程在查DB但其他线程都在自旋等待体验上是有短暂卡顿的。更进一步如果这个热点key被高频访问每次过期都触发一次重建DB压力依然不小。业界常用的做法是逻辑过期。物理上我们不删除key也不设置Redis的天然TTL而是让key永久存在但在value里包一层带逻辑过期时间的包装类。查询时读到包装类发现逻辑过期了不直接删除缓存而是返回旧值给调用方同时触发异步线程去刷新DB数据并更新缓存。这样做的好处是数据永远是有的DB压力从“每次过期瞬间的并发冲击”变成了“后台定时刷新”用户体验无感知。public class CacheWrapperT { private T value; private long logicalExpireTime; }代码里实现也不复杂查询发现逻辑过期后先返回旧值再提交一个异步任务去刷新。这里的核心取舍是数据的一致性和实时性旧值会存在一段时间如果业务对数据实时性要求不高比如商品详情、排行榜、配置信息这种方案非常合适但如果要求强一致比如库存扣减后的实时剩余量就不能用旧值还是得走互斥锁同步重建。我们团队的做法是把两种策略都做成注解配置业务方按自己的数据属性声明。异步刷新也有一个细节多个线程同时触发刷新会产生重复查DB所以刷新任务内部也要加分布式锁锁内先double check当前缓存是否已经被其他线程更新过。4.4 本地缓存Caffeine兜底第三层是本地缓存我们引入Caffeine作为JVM内的一级缓存。查询链路变成先查Caffeinemiss后查RedisRedis miss后走互斥锁重建DB。Caffeine的配置如下Bean public CacheString, Object caffeineCache() { return Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofSeconds(30)) .recordStats() .build(); }maximumSize设置的是条目数量不是内存大小。如果单个缓存value很大比如几十KB的JSON10万条对堆内存的压力也不小所以要根据业务数据大小调。expireAfterWrite设成30秒本地缓存主要作为Redis不可用或者重建窗口期的临时兜底不需要缓存太久越短一致性越好。有一个容易忽略的坑本地缓存和Redis如果都缓存了同一份数据两者过期时间不一致会导致短暂的数据不一致。比如Redis的TTL是10分钟Caffeine的过期时间是30秒那在第31秒到第10分钟之间Redis里可能还是新数据但Caffeine已经过期重新从Redis拉取了新的这个没问题。反向的情况才有问题Redis过期了但Caffeine还没过期查询命中本地旧数据。所以Caffeine的过期时间必须小于Redis的TTL这样它最多只能读到比Redis稍旧的数据而Redis永远提供更新的数据。我们在配置中心里加了注释禁止Caffeine过期时间大于Redis TTL防止后人改错。5. 封装实践工程落地与参数调优前面讲了方案和核心逻辑这一章说落地工程时踩到的具体坑和参数调优经验。这一章对于一个真正要上线这套方案的人来说是关键参考。5.1 工程结构与依赖我们用的是Spring Boot项目缓存模块按独立包维护目录结构大概是这样com.example.cache ├── CacheService.java // 门面类业务方唯一入口 ├── BloomFilterManager.java // 布隆过滤器管理 ├── HotKeyManager.java // 热点key识别与续期 ├── CacheWrapper.java // 逻辑过期包装类 ├── CacheProperties.java // 配置属性绑定 └── config ├── RedissonConfig.java ├── CaffeineConfig.java └── RedisTemplateConfig.javaMaven依赖核心是这几个dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.23.5/version /dependency dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId version3.1.8/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependencyRedisson的spring-boot-starter会自动装配RedissonClient省去手动建连接池的麻烦也天然支持看门狗。Caffeine是纯JVM内存缓存没有额外依赖。5.2 RedisTemplate序列化配置的坑缓存工具能不能正常工作序列化方式影响很大。Spring Boot默认的RedisTemplate用的是JdkSerializationRedisSerializer序列化后的key会带一串\xac\xed\x00\x05t\x00...的前缀value是二进制格式可读性差而且有反序列化性能损耗。我们统一改成StringRedisSerializer做key序列化value用GenericJackson2JsonRedisSerializer做JSON序列化。这样Redis里的key是明文用redis-cli排查问题的时候能直接看懂。代码配置Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; }还有一个细节RedisTemplate在存储NULL_PLACEHOLDER这类空值标记时GenericJackson2JsonRedisSerializer反序列化时如果泛型信息丢失可能解不回来。这会导致空值缓存的判断逻辑失效。我们的做法是空值标记用固定字符串常量__NULL__查询时先判断字符串值是否等于这个常量再决定是否返回null。这个判断在工具内部做业务方完全无感。5.3 布隆过滤器参数调优与初始化时机布隆过滤器的参数只能在初始化时定一次所以预估数据量很关键。预估少了数据量逼近容量上界后误判率会迅速劣化预估多了位数组占用内存偏大但也能接受。建议按业务未来一年的增长量来估比如当前用户300万年增长30%直接按500万估留出余量。误判率p取1%是比较平衡的低于0.1%时内存占用会显著上升收益却不明显。我用表格列几个常用档位供参考预期数据量n误判率p位数组大小m内存占用哈希函数个数k100万1%958万bit约1.14MB71000万1%9585万bit约11.4MB71000万0.1%1438万bit约17.1MB101亿1%9.58亿bit约114MB7初始化时机也很关键。我们经历过一次发布时忘记触发初始化布隆过滤器空置所有查询全走DB重建好在当时流量不大。后来把初始化放到ApplicationRunner里应用启动完成后强行跑一次加载任务加载期间如果检测到过滤器是空的就返回一个开关标记工具层可以直接放行因为过滤器不可用时拦截会误伤所有请求降级为不拦截更安全并打印严重告警。这个开关逻辑很重要过滤器不可用时宁可退回到裸查询也不能把合法请求全拦截了。5.4 热点key识别与动态续期击穿治理依赖锁但如果我们不知道哪些key是热点就只能对所有miss的key都加锁。这有点浪费因为绝大多数key的并发度很低锁的开销虽然小但不是零。我们对热点key做识别命中热点的才走加权保护逻辑。热点识别最简单的方案是访问计数。用一个本地ConcurrentHashMap维护每个key最近一分钟的访问次数或者直接用Redis的ZSet结构做滑动窗口计数。本地计数的优势是零额外网络开销劣势是集群多节点时各自统计阈值要按单节点估算Redis ZSet的优势是全局统计准确劣势是多了一次Redis网络调用。我们用的折中方案是本地计数每10秒将计数结果批量上报到Redis工具层根据上报结果更新热点名单。热点名单维护在HotKeyManager中核心数据结构就是一个Set加上过期时间。当某个key在1分钟内被访问超过1000次阈值可配置就把这个key加入热点名单并对这个key做两件事一是把物理过期时间改长禁用Redis原生TTL改用逻辑过期时间二是启动一个后台线程定时刷新它的缓存值。这样热点key几乎不会真正过期击穿场景被前置消除互斥锁只作为逻辑过期后的兜底手段。后台刷新任务的频率要控制好。刷新太频繁DB压力大刷新太慢数据实时性变差。我们对不同类型的数据配置了不同的刷新间隔价格、库存这类实时性要求高的30秒刷新一次商品描述、标题这类允许滞后的5分钟刷新一次。这里注意刷新任务要均匀错峰不要整点一起跑否则容易造成对DB的周期冲击。6. 常见问题与排查技巧实录这一章记录的是我们上线这套封装工具后遇到过的真实故障和排查过程每一条都是花时间踩出来的拿出来分享希望帮大家避坑。6.1 布隆过滤器“误伤”合法请求布隆过滤器出现过一次严重误伤大促新增了一批商品但增量写入的逻辑漏了导致部分新商品ID在过滤器里完全不存在所有查询都被拦成null前端页面显示“商品不存在”客服被问爆。排查时先看日志发现这些ID全部走到“bloom miss”分支然后检查增量写入代码发现商品创建成功了但缓存模块的一个事件监听器因为消息队列积压被丢弃导致add操作没执行。教训有两点一是增量写入必须做成同步失败重试业务写成功后立刻同步调用过滤器的add方法不依赖异步链路二是启动时和每天凌晨加一个全量校准任务把DB全量ID与过滤器对比发现缺失的批量补上防止漏写导致长期带病运行。6.2 锁内慢SQL导致线程堆积有次压测发现热点key的P99飙到2秒排查发现锁内查DB的SQL是一个带子查询的复杂语句大促数据量上来后执行要400毫秒。虽然是互斥的只有少数线程在查但所有请求都在等锁外的自旋重试而重试3次都拿不到缓存只能降级再去查DB相当于锁根本保护了DB一次但等待期间的降级请求又把DB打了一次。优化方案是拆SQL把复杂的子查询拆成两个简单查询分别缓存中间结果热点key的缓存值只依赖第一个结果第二个结果用来做补充展示。拆完以后锁内耗时降到50毫秒以内P99回到正常区间。这件事提醒我互斥锁只能控制并发不能掩盖DB性能差锁内查询耗时是击穿治理的生命线必须先解决。6.3 逻辑过期导致缓存更新迟迟不生效我们上线逻辑过期方案后测试同学反馈手动在后台改了商品标题前端页面5分钟还不刷新。定位发现逻辑过期时间被设置成了10分钟但后台刷新任务每5分钟才检查一次有一个检查窗口正好落在过期前导致数据白白等待5分钟。后来把逻辑过期时间设定为刷新频率的两倍以上保证任意时刻都有过期检查的机会。实际配置是刷新间隔5分钟逻辑过期时间设为12分钟这样即便错过一次检查下一次也会在6分钟内触发更新。6.4 空值缓存与布隆过滤器组合时的TTL不一致同时启用空值缓存和布隆过滤器时出现过一次短期脏数据某个不存在ID在布隆过滤器里误判为可能存在误判率只有1%但架不住每天几亿次查询于是走了缓存查询链路缓存里存的空值TTL是90秒结果布隆过滤器本身的判断是永久的导致这个ID的查询在过滤器生命周期内永远在缓存里命中空值。解决方案是布隆过滤器判断存在后缓存空值命中时也做二次校验如果DB返回了数据说明可能是过滤器误判造成的空值缓存命中了真实数据此时强制把空值替换为真实值。简单说空值缓存不应该被当成“真实状态”它只是一个临时缓解措施一旦DB中出现了数据必须以DB为准。6.5 多节点本地缓存的一致性问题Caffeine本地缓存上线后多节点的数据一致性出现过困惑同一时刻不同节点返回的数据可能不相同因为每个节点缓存刷新的时间点是漂移的。比如节点A刚更新完缓存节点B还是旧值用户负载均衡打到不同节点就看到不同数据。这个现象对大多数读多写少的业务场景是可接受的比如商品详情、活动页但对强一致要求的场景不能接受。我们的处理方式是在配置里按数据维度拆分强一致数据不启用Caffeine这一层只走Redis和锁弱一致数据启用Caffeine且过期时间统一随机化错峰。上线前需要和产品对齐“容忍多少秒的数据延迟”大部分场景30秒以内都能接受。6.6 压测时的一个隐藏陷阱压测时容易忽略布隆过滤器和热点名单的预热。压测脚本上来就并发打热点此时热点名单还没建立所有请求走的都是基础的锁重试逻辑测出来的数值不代表真实上线水平。正确做法是压测前先跑一个预热脚本模拟真实访问几次让热点名单和本地缓存都填充起来再开始压正式场景。另外压测数据的数据量如果远小于生产布隆过滤器的误判率会显得非常低压测结果偏乐观要意识到这个差异。7. 从这套封装中沉淀的经验代码方案讲完了最后说几个我对封装这件事本身的看法。我自己最大的体会是封装工具层不是把复杂度藏起来就完事了恰恰相反它把复杂度从业务代码集中到了那么几个类里所以这几个类的正确性和可观测性比什么都重要。我们给工具层加了大量日志和指标任何一个分支命中都要能追踪到是哪一层拦的、哪个环节慢的这样线上出了问题才不用靠猜。另外一点缓存治理没有一劳永逸的方案。布隆过滤器对存量数据有效但增量写入、删除残留都需要持续维护互斥锁能保护瞬时冲击但DB本身慢还是慢本地缓存能扛抖动但一致性永远是相对的不是绝对的。这套工具箱能覆盖大部分场景但每接入一个业务还是要回到业务本身去看它的数据属性、一致性要求、访问特征挑选适合的策略组合。如果你们团队的缓存防护还处在手写判断的阶段我的建议是先别急着照抄代码。第一步先想清楚你们目前最痛的是穿透还是击穿——是经常被攻击者刷无效请求还是大促时热点key过期导致DB抖动。把这个核心矛盾定下来再决定三层防护里优先落地哪层。大多数团队第一步做锁就够了穿透是高危时再上布隆过滤器。工具封装是手段让线上服务在极端流量下还能稳住才是我们最终要的东西。