☰
Redis缓存穿透与击穿:布隆过滤器+分布式锁+Caffeine三层防护实践
2026/9/26 7:07:33 网站建设 项目流程

高并发场景下,缓存穿透和缓存击穿是每一套基于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.4MB;k约等于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 RBloomFilter<String> getUserBloomFilter() { RBloomFilter<String> 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, Supplier<T> dbLoader, Duration ttl) { RBloomFilter<String> 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, Supplier<T> 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必须包含业务key,lock:+ 完整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 CacheWrapper<T> { private T value; private long logicalExpireTime; }

代码里实现也不复杂:查询发现逻辑过期后,先返回旧值,再提交一个异步任务去刷新。这里的核心取舍是数据的一致性和实时性:旧值会存在一段时间,如果业务对数据实时性要求不高(比如商品详情、排行榜、配置信息),这种方案非常合适;但如果要求强一致(比如库存扣减后的实时剩余量),就不能用旧值,还是得走互斥锁同步重建。我们团队的做法是,把两种策略都做成注解配置,业务方按自己的数据属性声明。

异步刷新也有一个细节:多个线程同时触发刷新会产生重复查DB,所以刷新任务内部也要加分布式锁,锁内先double check当前缓存是否已经被其他线程更新过。

4.4 本地缓存Caffeine兜底

第三层是本地缓存,我们引入Caffeine作为JVM内的一级缓存。查询链路变成:先查Caffeine,miss后查Redis,Redis miss后走互斥锁重建DB。Caffeine的配置如下:

@Bean public Cache<String, Object> caffeineCache() { return Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofSeconds(30)) .recordStats() .build(); }

maximumSize设置的是条目数量,不是内存大小。如果单个缓存value很大(比如几十KB的JSON),10万条对堆内存的压力也不小,所以要根据业务数据大小调。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.java

Maven依赖核心是这几个:

<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.23.5</version> </dependency> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>3.1.8</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>

Redisson的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 RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, 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内存占用哈希函数个数k
100万1%958万bit约1.14MB7
1000万1%9585万bit约11.4MB7
1000万0.1%1438万bit约17.1MB10
1亿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抖动。把这个核心矛盾定下来,再决定三层防护里优先落地哪层。大多数团队第一步做锁就够了,穿透是高危时再上布隆过滤器。工具封装是手段,让线上服务在极端流量下还能稳住,才是我们最终要的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询