news 2026/9/25 13:13:59

高并发下缓存穿透与击穿的防御实践:基于Redis的封装方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
高并发下缓存穿透与击穿的防御实践:基于Redis的封装方案

做了这么多年后端,缓存穿透和缓存击穿这个问题我几乎在每个高并发项目里都要重新讲一遍。最近我把这两类问题的防御逻辑统一封装成了一个可复用的工具包,基于Redis实现,核心围绕布隆过滤器、分布式锁、本地缓存和空值缓存这套组合拳。这篇就是这次封装的完整复盘。它会讲清楚为什么一定要封装、API怎么设计、代码怎么落地,以及我在排障时踩到的真实坑。适合正在做缓存治理,或者想给团队沉淀一套统一缓存保护组件的后端开发同学。

1. 需求拆解:穿透和击穿为什么值得单独封装

1.1 穿透与击穿,是两类完全不同的问题

很多人一说缓存问题就笼统喊“穿透击穿雪崩”,但穿透和击穿的处理手段其实完全不能互相替代,所以我做封装的第一件事,就是把这两类问题当成两个独立模块来设计。

先看缓存穿透。穿透的本质是请求了一个在DB里也根本不存在的数据,比如某个订单ID是伪造的、某个用户ID被人恶意遍历。这种请求Redis里永远查不到,于是每一发都会打到数据库。一个正常业务里这种请求可能不多,但遇到接口暴露、刷子脚本甚至攻击,流量一上来DB就直接崩。针对穿透,常用思路有缓存空值、布隆过滤器、接口入参合法性校验。

再看缓存击穿。击穿针对的是一个确实存在、但刚好在某个瞬间过期的热点key。比如一个做秒杀的商品详情,缓存的TTL到了,同一时刻上千个请求同时回源DB。正常请求本来是有缓存的,问题只出在过期那一刻的并发重建窗口。击穿的处理手段是加锁重建、本地缓存兜底、逻辑过期异步刷新。

维度缓存穿透缓存击穿
本质DB里没有这个数据,永远查不到DB里有数据,只是缓存刚好过期
流量特征分散、随机、可能恶意集中在同一个热点key
主要危害大量无效请求压垮DB瞬间高并发回源,DB慢查询/连接池耗尽
防御核心拦截“不存在”的请求控制“回源重建”的并发
常用手段布隆过滤器、空值缓存、参数校验分布式锁、本地缓存、逻辑过期

如果直接在业务代码里各写各的,今天在订单服务写一段空值缓存,明天在商品服务写一段分布式锁,套路不统一不说,漏配一个就出事故。所以这个工具封装的核心目标,就是让业务方只需要声明“这个key要防穿透”“这个key要防击穿”,底层逻辑全部收敛到一个组件里。

1.2 不封装时业务代码会失控到什么程度

我见过一个真实场景:某个系统里同样的“缓存穿透保护”逻辑,被不同开发用四种方式各实现了一遍。有人用if (value == null) redis.set(key, "", 60秒),有人用布隆过滤器但没跟缓存联动,还有人只在Controller层对ID做了非空判断。更要命的是,缓存空值的TTL各不相同,有的项目根本没有空值缓存,导致每次热点refresh都要打DB。

这种散装代码的直接后果有三个:一是维护成本高,新来的同学不知道该按哪份代码的风格来写;二是缺少统一监控,出了问题要翻半天日志才能定位哪个环节漏了;三是策略无法升级,比如今天你想在原来“只防穿透”的基础上增加“防击穿锁”,需要把所有调用方全部改一遍。

我说的“封装”,不是简单抽一个工具类,而是把整个防穿透防击穿的完整链路做成一个固定流程。调用方进来只需要走我的入口,后续的过滤器检查、Redis查询、DB回源、锁竞争、空值处理、监控上报,全由封装层接管。这样业务团队不需要理解底层细节,只需要关注自己的数据加载函数。

1.3 界定封装边界:策略与存储分离

封装最大的坑在于“过度设计”。我一开始也想把缓存组件做成一个万能框架,结果发现既要处理本地缓存一致性,又要适配多种序列化方式,还要兼容不同的锁实现,最终导致组件上线时间一拖再拖。

后来我重新划定了边界,就两条:策略归策略,存储归存储。存储层只用Redis,不自己实现存储引擎;锁用Redisson提供的基础能力,不自己造分布式锁;布隆过滤器优先用Redisson自带的RBloomFilter,避免重复写哈希函数和位数组逻辑。策略层则完全由我们的工具控制,包括什么时候检查布隆、什么时候缓存空值、锁竞争失败后的降级方式、本地缓存要不要启用。

这样的边界让组件足够轻,也足够灵活。后续如果团队要切换Redis客户端,只动存储适配层;如果要对某些key禁用布隆过滤器,只改策略配置。依赖方向永远单向向内,底层组件不反向依赖业务。

2. 核心设计与API

2.1 三层保护链路:过滤、缓存、回源

整套工具的查询链路我设计成了三层。第一层是布隆过滤器,用于快速拦截明显不存在的key,这一层可以减少大量无效Redis访问;第二层是Redis缓存加可选本地缓存,用于承接绝大多数正常请求;第三层是分布式锁保护下的DB回源,只有真正需要加载数据时才放行。


整个查询流程大致是这样:请求进来后,先看布隆过滤器是否包含这个key,如果不包含,直接返回空结果;如果包含,继续查本地缓存和Redis,命中就返回;如果都没有,则尝试获取分布式锁。拿到锁的线程负责查DB并回填缓存,拿不到锁的线程进入降级策略,可以选择短暂等待后重新读缓存,也可以直接返回旧值或默认值。

这个链路看起来不复杂,但每个环节之间是有关联的。比如布隆过滤器判定key不存在,就直接跳过了Redis和DB,此时事务性要求较高的场景要注意:布隆过滤器刚初始化或刚重建时内部并不包含已有数据,需要有一个预热过程。这块我放在后面的实操部分细讲。

2.2 对外API设计:一个方法解决两类问题

API设计我坚持一个原则:调用方写的代码必须像一个“正常查询”,而不是暴露一堆底层概念。最后沉淀出的核心接口只有一个方法,签名简化后是这样:

public <T> T query(String key, long ttl, TimeUnit unit, CacheLoader<T> loader, Class<T> resultType)

调用方只需要传缓存key、过期时间、数据加载函数和返回类型。工具内部根据配置,自动决定是否启用布隆过滤器、是否缓存空值、是否加分布式锁。

这里我给了一个可选的CacheLoader函数式接口,业务方写Lambda或方法引用就行:

Product product = cacheGuard.query("product:" + id, 300, TimeUnit.SECONDS, id -> productMapper.selectById(id), Product.class);

从调用方视角看,这就是一行代码。但从工具内部看,它完成了布隆过滤、多级缓存查询、锁保护回源、空值处理、命中监控等全链路逻辑。对业务方来说,不用关心自己的方法是不是被并发击穿了,也不用操心要不要给不存在的商品缓存空值,这些统一由封装层兜住。

2.3 关键参数和默认值

参数配置是这类工具最容易失控的地方。我把参数分为两大类,一类是全局参数,放在配置对象里统一管理;一类是方法级参数,由调用方在调用时覆盖。全局参数包含如下核心项。

参数名默认值说明
bloom.enabledtrue是否开启布隆过滤器拦截
bloom.expectedInsertions1000_0000布隆过滤器预估数据量
bloom.falseProbability0.01可接受的误判率
emptyCache.enabledtrueDB未命中时是否缓存空值
emptyCache.ttlSeconds30空值缓存有效期
lock.enabledtrue是否开启分布式锁
lock.waitMillis50获取锁最大等待时间
lock.leaseMillis10000锁自动释放时间
local.cacheEnabledfalse是否启用本地Caffeine缓存
local.maxSize10000本地缓存最大条目数
local.expireSeconds5本地缓存过期时间

这里尤其要解释两个值。布隆过滤器的预估数据量和误判率直接影响它的内存占用。根据公式m = -(n × ln(p)) / (ln2)^2估算,当n=1000万、p=0.01时,位数组长度约为9600万bit,换算过来大概是11.4MB,哈希函数个数k = (m/n) × ln2约等于7。这个内存占用在单机场景完全可以接受。如果业务量更大,记住一个规律:误判率每降低一个量级,内存大约增加50%,要按业务成本取舍。

锁的waitMillis和leaseMillis是一对需要联动调试的参数。waitMillis太小,大量线程拿不到锁直接走降级,会造成一定的缓存命中率下降;waitMillis太大,一旦DB回源变慢,线程会成批阻塞在锁上。leaseMillis更是如此,它必须大于预估的DB执行时间,否则锁提前过期,后面的线程就会进入重复回源。我后面排查经验部分会专门讲这个。

3. 实操落地:代码级实现

3.1 布隆过滤器的初始化和重建方案

我用Redisson的RBloomFilter实现布隆过滤器,因为它天然支持Redis持久化,多个应用节点共享同一个过滤器,不用自己维护位数组。配置类写法如下:

@Configuration public class CacheGuardConfig { @Bean public RBloomFilter<String> cacheGuardBloomFilter(RedissonClient redissonClient) { RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("cache:guard:bloom"); bloomFilter.tryInit(10_000_000L, 0.01); return bloomFilter; } }

这里有个非常关键的细节:tryInit只在过滤器不存在时执行初始化,如果Redis里已经有了这个过滤器,参数传了也不会生效。所以第一次上线前要确认预估的数据量是否准确;如果业务量涨了10倍需要重建,不能直接改参数了事,要换一个Redis key或者先删除旧key再重新加载。

另外,业务里已有的存量数据不会自动进布隆过滤器。我建议在工具启动后加一个预热任务,把DB里的存量主键批量刷入过滤器。如果存量数据太大,就分批异步刷,避免启动过程卡住。

3.2 核心查询方法完整实现

我贴一个核心实现,这里把主链路列出来,生产环境可在这个基础上补监控和异常处理:

@Service public class CacheGuard { private final RBloomFilter<String> bloomFilter; private final StringRedisTemplate stringRedisTemplate; private final RedissonClient redissonClient; private final Cache<String, Object> localCache; private final CacheGuardMonitor monitor; private final CacheGuardProperties props; public <T> T query(String key, long ttl, TimeUnit unit, CacheLoader<T> loader, Class<T> resultType) { // 第一层:布隆过滤器拦截 if (props.isBloomEnabled() && !bloomFilter.contains(key)) { monitor.recordBloomReject(key); return null; } // 第二层:本地缓存 if (props.isLocalCacheEnabled()) { Object cached = localCache.getIfPresent(key); if (cached != null) { monitor.recordLocalHit(key); return (T) cached; } } // 第二层:Redis缓存 String json = stringRedisTemplate.opsForValue().get(key); if (StringUtils.hasText(json)) { T value = deserialize(json, resultType); if (props.isLocalCacheEnabled()) { localCache.put(key, value); } return value; } // 第三层:分布式锁保护下的DB回源 String lockKey = "lock:" + key; RLock lock = redissonClient.getLock(lockKey); boolean locked = false; try { locked = lock.tryLock(props.getLockWaitMillis(), props.getLockLeaseMillis(), TimeUnit.MILLISECONDS); if (!locked) { return fallback(key, resultType); } // 拿到锁后双检,防止第一个线程回源期间其他线程重复回源 json = stringRedisTemplate.opsForValue().get(key); if (StringUtils.hasText(json)) { return deserialize(json, resultType); } T value = loader.load(key.substring(redisPrefixLength(key))); if (value == null && props.isEmptyCacheEnabled()) { // 空值缓存,TTL一定要短 stringRedisTemplate.opsForValue() .set(key, "", props.getEmptyCacheTtlSeconds(), TimeUnit.SECONDS); return null; } if (value != null) { stringRedisTemplate.opsForValue() .set(key, serialize(value), ttl, unit); if (props.isLocalCacheEnabled()) { localCache.put(key, value); } } return value; } catch (Exception e) { // 建议记录日志后走降级,不要在缓存组件把业务异常吞掉 throw new CacheGuardException("cache query failed: " + key, e); } finally { if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } } } private <T> T fallback(String key, Class<T> resultType) { // 拿不到锁的时候,优先重新读一次Redis,避免缓存已生效却返回空 String json = stringRedisTemplate.opsForValue().get(key); if (StringUtils.hasText(json)) { return deserialize(json, resultType); } // 如果读不到,返回一个默认值或者null,由业务方决定要不要容忍 return null; } }

这个实现有几个细节值得特意说明。

第一,布隆过滤器检查放在最前面,是因为很多穿透流量的key根本不可能命中Redis,提前拦截可以省掉一次网络IO。但要注意,如果布隆过滤器判定key不存在,我这里是直接返回null的,这意味着调用方拿到的可能是null而不是业务默认值,所以业务方不要依赖这个结果去更新DB。要是遇到DB里确实新增了这个key但过滤器还没更新,那也只是多穿一次,不会导致数据错误。

第二,拿到分布式锁后的“双检”是必须的。不然一个线程在回源期间,另一个排队等锁的线程也会再次回源,锁就等于白加了。

第三,缓存的序列化方式要统一。我这里用StringRedisTemplate读出来的是JSON字符串,实际工程里建议所有value都统一JSON序列化,避免不同业务对象在缓存里互相污染。

3.3 注解式封装:AOP方式接入

工具方法直接调用已经很好用了,但在一些老项目里,业务方法里有大量逻辑,你不想手改成参数拼装。这时候注解式封装就更有价值。

我设计了一个@CacheGuard注解,标记在Service方法上,通过AOP自动拦截:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface CacheGuard { String key(); // 支持SpEL表达式 long ttl() default 300; boolean bloom() default true; boolean localCache() default false; }

切面里把方法参数解析成key,然后调用cacheGuard.query:

@Aspect @Component public class CacheGuardAspect { @Around("@annotation(anno)") public Object around(ProceedingJoinPoint joinPoint, CacheGuard anno) throws Throwable { String key = parseKey(anno.key(), joinPoint.getArgs()); return cacheGuard.query(key, anno.ttl(), TimeUnit.SECONDS, k -> proceed(joinPoint), Object.class); } }

这种注解式封装的优点在于,业务方法本身不需要感知缓存逻辑。比如一个商品查询方法,只要加一行注解,它从“每次查DB”变成“带防穿透防击穿的缓存查询”。一旦后续要改缓存策略,比如关掉布隆过滤器,只需要改注解参数或全局配置,不用动业务代码。

3.4 监控埋点:工具能不能用,看指标就知道

封装工具最容易忽略的是监控埋点。如果没有指标,你根本不知道布隆过滤器到底拦截了多少流量、锁竞争等了多久、空值缓存命中率是多少。我在工具内部加了一个CacheGuardMonitor,对外暴露以下计数器:

  • bloomRejectCount:布隆过滤器拦截次数
  • redisHitCount / redisMissCount:Redis命中与未命中
  • dbLoadCount:真实DB回源次数
  • lockWaitSuccessCount / lockWaitTimeoutCount:锁获取成功与超时次数
  • emptyCacheHitCount:空值缓存命中次数

这些指标全部基于Micrometer,可以无缝接入Prometheus或日志系统。我个人非常看重dbLoadCount,因为它最能反映“击穿防御是否生效”。正常情况下,服务承载10万QPS的缓存查询,DB回源只应该是几十次;如果dbLoadCount跟着请求量同步上涨,那说明锁或空值缓存失效了。

4. 常见问题与排查实录

4.1 布隆过滤器误判被放大的两个场景

布隆过滤器最大的特点是“宁错杀不放过”,它会把不存在的key误判为存在,但不会把存在的key误判为不存在。所以理论上它只会增加多余的DB查询,不会漏掉有效数据。

但实际工程里误判放大是很常见的。比如预期数据量是1000万,结果业务库里堆了5000万数据,误判率就不是0.01,而是会急剧上升。排查的时候可以先看内存占用和当前key数量,如果发现数据量远超预期,就需要重建过滤器。另外还有一种情况,就是误判后DB没查到数据,但因为空值缓存关闭,这批被误判的key会频繁回源。我有一次就是只开了布隆过滤器,忘了开空值缓存,结果误判请求全部穿透到DB,反倒把库里某个冷查询打慢了。所以布隆过滤器和空值缓存不是二选一,它们配合起来才稳。

4.2 锁过期导致重复回源

分布式锁的leaseMillis如果设置得太短,一旦DB慢查询超过锁的保持时间,锁就会提前释放。此时后面等待的线程会重新抢到锁,再次回源。最直观的现象是:DB回源次数不是锁数的1倍,而是锁数的3倍甚至5倍。

排查思路很简单,看监控里的dbLoadCount和锁创建数是否成比例。如果明显不成比例,先把leaseMillis调大,比如从5秒调到15秒,再看dbLoadCount是否回归正常。我不建议无限调大,因为锁一旦因为进程宕机无法释放,leaseMillis越久,阻塞越久。更好的做法是用Redisson的看门狗机制,让锁在业务执行过程中自动续期,避免锁提前释放。

4.3 等待线程堆积和旧数据延迟

另一个典型问题是,当热点key过期后回源时间较长时,大量请求同时卡在tryLock等待上。虽然只有少数线程回源DB,但剩余的线程都堵在组件里,可能影响整体吞吐。

我在遇到这种情况后会做两件事。第一,给等待线程设一个上限,超过等待时间就直接返回本地旧缓存或默认值,不要无限阻塞。第二,对极高热度的key开启本地缓存,也就是把热key副本放到Caffeine里,这样即使Redis过期,请求在本地就能拿到最多5秒前的旧值,根本不会进入锁竞争。Caffeine的数据一致性虽然弱,但热点key场景下短暂旧值的容忍度通常很高。

4.4 排查速查表

结合我自己的踩坑经历,整理一个速查表,排查时可以直接对照:

现象可能原因排查方法解决方案
大量无效key打进DB布隆过滤器未开启或数据量严重超预估看bloomRejectCount,确认过滤器是否生效;检查内存消耗重建过滤器,提高预估数据量,同时打开空值缓存
DB回源次数与请求量同步上涨分布式锁未生效,或锁提前过期对比dbLoadCount与锁创建数调大leaseMillis,开启看门狗,检查是否误删了锁key
接口响应变慢,大量线程堆积锁等待时间设置过长,或回源DB查询过慢看线程池活跃数,锁的waitTimeoutCount调小waitMillis,增加本地缓存兜底,优化DB SQL
缓存重建后出现短暂脏数据本地缓存TTL和Redis TTL不一致对比local.expireSeconds与业务TTL本地缓存TTL设置得明显短于Redis TTL
缓存延迟失效,热点key一直返回旧值空值缓存TTL过长,或本地缓存没有及时清理看emptyCacheHitCount和本地缓存命中率调短空值TTL,用逻辑过期主动刷新

这块给一个很重要的建议:有任何异常,先把监控指标拉出来,不靠猜。dbLoadCount是判断穿透和击穿是否被控制住的最核心指标,另外redisHitCount可以帮助判断缓存本身是否健康。先看数字再动代码,能省一半时间。

5. 效果验证与后续扩展

5.1 一次压测对比带来的直观感受

工具上线后我做了一轮简单的压测对比。模拟场景是:一个热点商品key的缓存过期,200个线程同时请求这个key。不开启任何保护时,200个请求全部回源DB,DB端产生约200次查询,接口P99会明显变差。开启分布式锁保护后,只有1个线程回源DB,其余线程等待后直接命中Redis,DB QPS压力几乎瞬间消失。再叠加本地缓存后,极端情况下只有极少数请求短暂拿到旧值,整体接口耗时依然稳定。

这个对比其实说明了一个现象:解决缓存击穿本质上不是提升单次查询速度,而是把并发压力从DB侧消解掉。回源次数从200降到1,对DB是200倍的削峰,效果远比调SQL跟缓存参数更直接。

5.2 扩展方向:逻辑过期和多级缓存

目前的封装已经能覆盖绝大多数场景,但有两个方向我建议后续继续演进。

第一个是逻辑过期。热点key正常不设置物理过期时间,只在缓存对象里放一个“更新时间”字段。请求到了以后发现数据已经超过某个时间点,就触发异步异步任务去更新缓存,同时当前请求继续返回旧值。这个方案能真正做到“击穿零感知”,缺点是需要业务方容忍一定时间内的数据延迟,并且要额外处理并发更新任务。

第二个是更细粒度的本地缓存。当前本地缓存是全局通用的,后续可以针对热点key单独配置,比如商品详情缓存5秒、用户信息缓存1分钟。这样能把不同业务的“可容忍延迟”和“热点程度”分开管理,避免一刀切的参数影响整体效果。

5.3 封装这件事,我在实际落地后的几个体会

最后聊点实在的。这次封装做完,我最大的感触是:工具的价值不在于代码本身写得多优雅,而在于它能不能让后续所有业务都默认获得正确的保护,而不是靠每个开发同学都理解布隆过滤器原理才不出事。

我在几个业务线落地后发现,大家调用cacheGuard.query时根本不会去思考穿透还是击穿,因为组件已经判断好了。原来那些散落在各服务里的“加锁-回源-判断空值”逻辑全部删掉,代码review的负担一下子轻了很多。真要说有什么遗憾,就是这类组件一定要在业务稳定期做沉淀,最好配合监控和压测一起推上线,不能等到线上已经出事故了再仓促封装,那种情况下往往只来得及打补丁,做不了体系化设计。

如果你也要做类似的缓存保护封装,我建议从一开始就把监控埋点和参数配置做进去,这两个东西后期再补会牵扯很多调用方,非常痛苦。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 13:13:48

Atlas 300V 24G部署YOLO实战:从硬件到模型转换与调优全指南

做了好几个月的边缘端目标检测项目&#xff0c;我一直想把这套完整的部署经验记录下来。正好最近看到有人问“atlas 300v 24g 是运算加速卡吗”&#xff0c;又有不少人在搜“atlas部署yolo”&#xff0c;就干脆把这块卡的定位、硬件细节、模型转换流程和实际踩坑经验全部整理成…

作者头像 李华
网站建设 2026/9/25 13:11:13

Agent Skills实战:从System Prompt膨胀到技能化编排

在AI Agent落地这件事上&#xff0c;我踩过最大的坑&#xff0c;就是“什么都想直接塞进System Prompt里”。早期做一个内部知识库问答Agent&#xff0c;功能越加越多&#xff0c;提示词从500字膨胀到3000字&#xff0c;最后模型开始答非所问&#xff0c;排错排到怀疑人生。后来…

作者头像 李华
网站建设 2026/9/25 13:06:48

AI音乐生成提示词模板库:12种风格四维框架与实战技巧

音乐生成工具用了一年多&#xff0c;从最早拿Suno瞎试&#xff0c;到后来帮朋友做短视频配乐、给播客做片头&#xff0c;踩过的坑基本能写一本小册子。最深的感受是&#xff1a;AI音乐生成的门槛不在工具&#xff0c;在提示词。同一段旋律动机&#xff0c;提示词写"轻快的…

作者头像 李华