新闻详情

新闻详情

首页 / 资讯中心 / 详情

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

发布时间:2026/9/25 13:14:03来源:尧图网络
高并发下缓存穿透与击穿的防御实践:基于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. 核心设计与API2.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, CacheLoaderT loader, ClassT 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估算当n1000万、p0.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 RBloomFilterString cacheGuardBloomFilter(RedissonClient redissonClient) { RBloomFilterString 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 RBloomFilterString bloomFilter; private final StringRedisTemplate stringRedisTemplate; private final RedissonClient redissonClient; private final CacheString, Object localCache; private final CacheGuardMonitor monitor; private final CacheGuardProperties props; public T T query(String key, long ttl, TimeUnit unit, CacheLoaderT loader, ClassT 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, ClassT 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.queryAspect 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 / redisMissCountRedis命中与未命中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个请求全部回源DBDB端产生约200次查询接口P99会明显变差。开启分布式锁保护后只有1个线程回源DB其余线程等待后直接命中RedisDB QPS压力几乎瞬间消失。再叠加本地缓存后极端情况下只有极少数请求短暂拿到旧值整体接口耗时依然稳定。这个对比其实说明了一个现象解决缓存击穿本质上不是提升单次查询速度而是把并发压力从DB侧消解掉。回源次数从200降到1对DB是200倍的削峰效果远比调SQL跟缓存参数更直接。5.2 扩展方向逻辑过期和多级缓存目前的封装已经能覆盖绝大多数场景但有两个方向我建议后续继续演进。第一个是逻辑过期。热点key正常不设置物理过期时间只在缓存对象里放一个“更新时间”字段。请求到了以后发现数据已经超过某个时间点就触发异步异步任务去更新缓存同时当前请求继续返回旧值。这个方案能真正做到“击穿零感知”缺点是需要业务方容忍一定时间内的数据延迟并且要额外处理并发更新任务。第二个是更细粒度的本地缓存。当前本地缓存是全局通用的后续可以针对热点key单独配置比如商品详情缓存5秒、用户信息缓存1分钟。这样能把不同业务的“可容忍延迟”和“热点程度”分开管理避免一刀切的参数影响整体效果。5.3 封装这件事我在实际落地后的几个体会最后聊点实在的。这次封装做完我最大的感触是工具的价值不在于代码本身写得多优雅而在于它能不能让后续所有业务都默认获得正确的保护而不是靠每个开发同学都理解布隆过滤器原理才不出事。我在几个业务线落地后发现大家调用cacheGuard.query时根本不会去思考穿透还是击穿因为组件已经判断好了。原来那些散落在各服务里的“加锁-回源-判断空值”逻辑全部删掉代码review的负担一下子轻了很多。真要说有什么遗憾就是这类组件一定要在业务稳定期做沉淀最好配合监控和压测一起推上线不能等到线上已经出事故了再仓促封装那种情况下往往只来得及打补丁做不了体系化设计。如果你也要做类似的缓存保护封装我建议从一开始就把监控埋点和参数配置做进去这两个东西后期再补会牵扯很多调用方非常痛苦。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

B站网页视频任意角度旋转:Console一行代码实现 2026/9/25 13:42:19

B站网页视频任意角度旋转:Console一行代码实现

1. 项目概述:为什么要在B站网页端手动旋转视频?B站网页版的视频播放器默认只支持0、90、180、270四个固定方向,且不提供UI按钮控制——这是绝大多数用户没意识到的“隐藏能力”。当你在看竖屏UP主投稿(比如手机实拍Vlog、ASMR、舞…

阅读更多 →
mongoose 报错 Cast to ObjectId failed for value:用 TaoToken 统一 Key 排查配置骨架 2026/9/25 13:41:40

mongoose 报错 Cast to ObjectId failed for value:用 TaoToken 统一 Key 排查配置骨架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
免费CRM与私人网站的区别:永久在线客户管理系统如何驱动销售闭环 2026/9/25 13:41:27

免费CRM与私人网站的区别:永久在线客户管理系统如何驱动销售闭环

1. 谈选型前,先把“免费CRM”和“私人网站”这两个概念掰开做销售管理这行超过十年,我见过太多团队在CRM选型上栽跟头。尤其是这两年,市面上冒出大量打着“永久在线”“免费”旗号的CRM网站,从蝉鸣、飞鱼到各种不知名的小平台&…

阅读更多 →
Semi Design Dropdown 下拉菜单组件实战指南:用法、API 与无障碍设计全解析 2026/9/25 13:41:27

Semi Design Dropdown 下拉菜单组件实战指南:用法、API 与无障碍设计全解析

前端UI组件设计系统 【免费下载链接】semi-design 🚀A modern, comprehensive, flexible design system and React UI library, AI-friendly built-in.🎨Provide 3000 Design Tokens, easy to build your design system. Make Semi Design to Any Design…

阅读更多 →
从桌面沟通场景切入的CRM设计与落地实践——以DeskcommCRM为例 2026/9/25 13:41:26

从桌面沟通场景切入的CRM设计与落地实践——以DeskcommCRM为例

做CRM项目这么多年,我见过太多团队一上来就怼着一套高大上的系统使劲折腾,最后发现销售根本不买账。原因很简单,客户管理系统如果脱离了业务一线人员的使用习惯,再强大的功能也只是一堆按钮。DeskcommCRM这个项目让我比较想聊的原…

阅读更多 →
Windows-universal-samples 中的 BasicFaceDetection 示例:使用 FaceDetector 在 UWP 应用中实现静态人脸检测 2026/9/25 13:41:26

Windows-universal-samples 中的 BasicFaceDetection 示例:使用 FaceDetector 在 UWP 应用中实现静态人脸检测

示例工程 【免费下载链接】Windows-universal-samples API samples for the Universal Windows Platform. 项目地址: https://gitcode.com/gh_mirrors/wi/Windows-universal-samples 点击查看 免费下载 本指南以 Windows-universal-samples 仓库中的 BasicFaceDete…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉