拓冰建站拓冰建站
首页 / 资讯中心 / 正文

缓存雪崩、击穿、穿透:原理剖析与实战解决方案

1. 从一次线上事故说起缓存为何成了“风暴眼”那天凌晨监控大屏上突然一片飘红核心接口的响应时间从毫秒级飙升到秒级数据库连接池瞬间被打满告警短信像雪花一样涌来。团队紧急介入发现罪魁祸首并非代码Bug也不是流量洪峰而是我们依赖的Redis缓存集群。一个看似简单的缓存失效引发了一连串的连锁反应最终导致服务雪崩。这次事故让我深刻意识到缓存用得好是“银弹”用不好就是“风暴眼”。它不仅仅是提升性能的工具更是一个需要精心设计其失效与更新策略的复杂系统。在分布式系统中缓存尤其是像Redis这样的内存数据库几乎是提升性能、降低数据库负载的标准配置。然而很多开发者只记住了它的“快”却忽略了它引入的“险”。其中最经典、也最危险的三种风险便是缓存雪崩、缓存击穿和缓存穿透。这三个词听起来很唬人但本质上都是缓存失效或未命中时流量直接压向后端数据库如MySQL所引发的问题区别在于失效的“规模”和“原因”。理解并解决它们是从“会用缓存”到“精通缓存”的关键一步。今天我们就来彻底拆解这三大问题不仅讲清楚“是什么”和“怎么办”更要深挖“为什么”分享一些实战中总结出来的、教科书上不会写的避坑指南。2. 缓存雪崩当失效变成一场“雪崩”缓存雪崩是破坏力最大的一种情况。想象一下冬天山坡上的积雪如果某一处发生松动可能会带动整片雪层坍塌。缓存雪崩也是如此在某一时刻大量缓存数据集中过期失效或者缓存服务本身宕机导致所有请求瞬间绕过缓存直接涌向数据库。数据库在短时间内承受了远超其处理能力的请求轻则响应变慢重则连接耗尽、直接宕机进而导致整个系统不可用。2.1 雪崩的典型诱因与深层逻辑为什么会出现集中失效这往往不是偶然。设置统一的过期时间这是最常见、也最容易被忽略的坑。很多项目在初始化缓存数据时为了方便给一大批数据设置了相同的过期时间TTL比如都在凌晨2点过期。当这个时刻到来这些缓存键同时失效所有相关查询都会转向数据库。缓存服务宕机Redis集群如果发生主从切换失败、网络分区脑裂、或者遭遇物理机故障导致整个缓存层不可用所有流量会毫无缓冲地砸向数据库。热点数据依赖某些核心业务数据如全局配置、首页商品列表被大量服务依赖一旦其缓存失效会触发所有依赖服务的连锁查询。问题的核心在于系统缺乏对缓存失效的“削峰填谷”能力。缓存本应作为数据库的“防洪坝”但在集中失效的瞬间这个坝没了。2.2 解决方案从预防到熔断的多层防御解决雪崩思路必须是“防患于未然”加上“事发有对策”。方案一差异化过期时间引入随机因子这是成本最低、效果最显著的预防措施。绝对不要给批量数据设置相同的TTL。// 错误的做法统一过期时间 redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS); // 正确的做法基础时间 随机偏移量 int baseTime 3600; // 1小时 int randomTime new Random().nextInt(600); // 0-10分钟的随机数 int finalTTL baseTime randomTime; // 最终过期时间在1小时到1小时10分之间 redisTemplate.opsForValue().set(key, value, finalTTL, TimeUnit.SECONDS);通过给缓存过期时间加上一个随机值如±10%可以将大量键的失效时间点打散避免同时失效。这个随机范围需要根据业务流量和数据库抗压能力来权衡。方案二构建高可用缓存集群单点Redis是高风险架构。必须采用哨兵Sentinel模式或集群Cluster模式实现主从切换和分片存储确保即使个别节点宕机服务整体仍可用。同时要做好容量规划和监控避免内存爆满触发淘汰策略引发连锁反应。方案三缓存永不过期与异步更新对于极其关键、变化不频繁的数据如城市列表、分类信息可以采用“逻辑过期”策略。即在缓存中不设置物理TTL而是存储一个包含数据本体和过期时间戳的对象。业务线程读取时判断时间戳是否过期。如果已过期则发起一个异步更新任务去刷新缓存当前线程仍返回旧数据。public Data getData(String key) { CacheWrapper wrapper redisTemplate.opsForValue().get(key); if (wrapper null) { // 缓存未构建同步加载需防止击穿见下文 return loadDataFromDbAndSetCache(key); } if (wrapper.isExpired()) { // 判断逻辑过期时间 // 触发异步刷新不阻塞当前请求 refreshCacheAsync(key); } return wrapper.getData(); // 无论是否过期先返回现有数据 }这种方式保证了服务的可用性但牺牲了一定的数据实时性适用于允许短期数据不一致的场景。方案四服务降级与熔断机制当监测到数据库压力陡增或响应超时时应立即启动熔断器如Hystrix、Sentinel快速失败非核心请求或返回兜底数据如默认配置、静态页面保护数据库不被拖垮。同时要有完善的服务降级预案在缓存不可用时系统能以一种“阉割但可用”的模式运行。注意方案三和方案四通常结合使用。“逻辑过期”保证了有数据可返回为系统争取了时间熔断降级则在缓存重建期间或数据库真正扛不住时为系统提供最后的保护伞。3. 缓存击穿一根针捅破天如果说雪崩是面状的全面失效那么击穿就是点状的精准打击。缓存击穿指的是某个热点Key访问量巨大的数据在过期失效的瞬间持续的高并发请求穿透缓存直接去数据库查询。这个Key就像防洪堤上的一个蚁穴所有水流都集中从这里涌向数据库可能导致数据库连接被该热点查询耗尽影响其他查询。3.1 击穿与雪崩的核心区别很多人容易混淆击穿和雪崩关键在于“量级”和“目标”目标数量雪崩是大量Key失效击穿通常是一个或极少数热点Key失效。并发来源雪崩的并发请求来自对大量不同数据的查询击穿的并发请求几乎都是针对同一个热点数据。破坏形式雪崩可能直接压垮数据库击穿更可能打满数据库连接池中与该查询相关的连接造成资源挤占。3.2 解决方案互斥锁与逻辑过期解决击穿的核心思路是防止多个线程同时去数据库重建同一个缓存。方案一分布式互斥锁Mutex Lock这是最经典的解决方案。当发现缓存失效时不是所有线程都去查数据库而是让其中一个线程去查其他线程等待查完后写入缓存其他线程再从缓存中读取。public Data getDataWithLock(String key) { Data data redisTemplate.opsForValue().get(key); if (data ! null) { return data; } // 缓存未命中尝试获取锁 String lockKey lock: key; // 使用SETNX命令实现分布式锁并设置锁的过期时间防止死锁 Boolean isLock redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(isLock)) { try { // 获取锁成功再次检查缓存Double Check防止其他线程已经更新 data redisTemplate.opsForValue().get(key); if (data ! null) { return data; } // 查询数据库 data queryFromDatabase(key); // 写入缓存 redisTemplate.opsForValue().set(key, data, 30, TimeUnit.MINUTES); } finally { // 释放锁 redisTemplate.delete(lockKey); } } else { // 获取锁失败等待一段时间后重试或直接返回旧值/默认值 try { Thread.sleep(50); // 短暂等待 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 递归重试或返回兜底数据 return getDataWithLock(key); } return data; }关键细节与避坑锁的粒度锁的Key必须与业务缓存Key关联确保只锁住这个热点数据不影响其他数据。锁的过期时间必须设置否则持有锁的线程崩溃会导致锁永远无法释放死锁。过期时间应略大于数据库查询和设置缓存的时间。锁的价值锁的值最好设置为一个唯一标识如UUID在释放时检查是否是自己持有的锁避免误删其他线程的锁在极端并发下A线程的锁过期B线程获取锁此时A线程执行完来删除锁就可能删掉B的锁。更严谨的做法是使用Redisson等库它们实现了可重入锁、看门狗自动续期等复杂逻辑。性能权衡加锁会引入性能损耗和复杂度。如果热点Key重建非常快毫秒级且并发量不是天文数字这个方案是有效的。如果重建很慢如复杂计算大量线程阻塞等待可能得不偿失。方案二逻辑过期永不过期 异步更新这个方案在雪崩部分提过同样是应对击穿的利器。缓存不设物理TTL而是将过期时间作为一个字段存入缓存值中。当线程发现数据逻辑过期时它不直接去查库而是尝试获取一个“更新锁”。获取到锁的线程负责异步更新缓存其他线程继续使用旧的、但可用的数据。public Data getDataWithLogicalExpire(String key) { CacheWrapper wrapper redisTemplate.opsForValue().get(key); if (wrapper null) { // 缓存根本不存在需要初始化这里也可能需要锁防止并发初始化 return initCache(key); } if (wrapper.isExpired()) { // 数据已逻辑过期 String lockKey refresh_lock: key; Boolean isLock redisTemplate.opsForValue().setIfAbsent(lockKey, 1, 3, TimeUnit.SECONDS); if (Boolean.TRUE.equals(isLock)) { // 获取到刷新锁异步执行刷新任务 executorService.submit(() - { try { Data newData queryFromDatabase(key); CacheWrapper newWrapper new CacheWrapper(newData, calculateNewExpireTime()); redisTemplate.opsForValue().set(key, newWrapper); } finally { redisTemplate.delete(lockKey); } }); } // 无论是否拿到锁都返回旧的包装数据 } return wrapper.getData(); }这个方案的优点是所有请求都能立刻返回没有线程被阻塞用户体验好。缺点是会有一段时间的数据不一致旧数据并且实现复杂度更高。实战心得对于读多写少、允许短暂不一致的热点数据如明星的微博粉丝数、商品的累计销量方案二逻辑过期是首选。对于要求强一致性、写操作也频繁的数据如秒杀库存方案一互斥锁更可靠但要做好锁的性能监控。4. 缓存穿透查询一个“不存在”的数据缓存穿透比击穿更“诡异”。它是指查询一个数据库中根本不存在的数据导致这个查询每次都会绕过缓存去查数据库。比如请求查询一个ID为-1的商品或者一个不存在的用户。由于数据不存在自然不会写入缓存下次同样的请求又会来查数据库。如果有人恶意构造大量此类不存在的Key发起请求数据库就会持续受到无意义的查询压力。4.1 穿透的危害与误判穿透的危害在于资源浪费数据库执行了大量毫无结果的查询。攻击入口容易被利用作为DDoS攻击的一种手段成本极低。与击穿的混淆有时一个Key第一次被查询时缓存必然没有也会去查数据库。这与穿透的区别在于这个Key在数据库中是存在的查一次后就会填充缓存后续请求命中缓存。而穿透的Key在数据库中永远不存在。4.2 解决方案布隆过滤器与空值缓存方案一布隆过滤器Bloom Filter—— 前置屏障布隆过滤器是一种概率型数据结构用于快速判断一个元素是否绝对不存在于一个集合中。它的特点是空间效率极高使用一个很长的二进制向量和一系列哈希函数。判断结果如果布隆过滤器说“某个元素不存在”那么它一定不存在。如果布隆过滤器说“某个元素存在”那么它可能存在有极低的误判率。 我们可以将数据库中所有存在的Key如所有有效的商品ID预先加载到布隆过滤器中。查询流程变为1. 收到查询请求如商品ID10086。 2. 先问布隆过滤器ID10086存在吗 3. 如果过滤器说“不存在”直接返回空结果或错误不再查询缓存和数据库。 4. 如果过滤器说“存在”则继续走正常的“查缓存 - 查数据库”流程。// 使用Guava的布隆过滤器示例 BloomFilterLong bloomFilter BloomFilter.create(Funnels.longFunnel(), 1000000, 0.01); // 预期元素量100万误判率1% // 系统启动时初始化布隆过滤器 ListLong allValidIds dao.getAllValidIds(); for (Long id : allValidIds) { bloomFilter.put(id); } public Product getProduct(Long id) { // 1. 布隆过滤器拦截 if (!bloomFilter.mightContain(id)) { log.warn(BloomFilter blocked non-existent ID: {}, id); return null; // 直接返回空保护后端 } // 2. 正常缓存查询流程 Product product cache.get(id); if (product null) { product dao.getById(id); if (product ! null) { cache.set(id, product); } else { // 数据库也没有说明布隆过滤器误判了或者数据刚被删除。 // 可以选择将此ID加入一个“短期黑名单”或记录日志。 } } return product; }布隆过滤器的关键考量误判率需要根据业务容忍度设置。误判率越低需要的二进制向量空间越大。数据更新当数据库有新增数据时需要同步更新布隆过滤器调用put方法。当有删除数据时布隆过滤器无法直接删除这是它的局限性可能导致误判。对于删除频繁的场景可以考虑使用支持删除的变种如Counting Bloom Filter或定期全量重建过滤器。内存占用需要评估对于海量数据十亿级以上单个布隆过滤器的内存占用可能达到GB级别。方案二缓存空对象Null Object Caching这是一个更简单直接的方案即使数据库查不到也在缓存中存一个空值或特殊的标记对象并设置一个较短的过期时间如5分钟。public Data getData(String key) { Data data cache.get(key); if (data ! null) { // 判断是否是空值标记 if (data NULL_OBJECT) { return null; // 或返回业务意义上的空结果 } return data; } data dao.getByKey(key); if (data ! null) { cache.set(key, data, 30, TimeUnit.MINUTES); } else { // 数据库没有缓存一个空对象防止穿透 cache.set(key, NULL_OBJECT, 5, TimeUnit.MINUTES); // 设置较短TTL } return data; }空对象缓存的注意事项内存浪费如果恶意攻击者构造大量不同的、不存在Key缓存会被大量空值占满挤掉有效数据。因此必须设置较短的TTL如几分钟让这些垃圾数据尽快被淘汰。数据更新如果这个Key在数据库中被创建了需要同时清理掉缓存中的空值标记否则在标记过期前业务会一直读到空值。这要求对数据创建操作有缓存清理逻辑。业务兼容需要定义一个特殊的、业务逻辑能识别的“空对象”并与正常的null返回值区分开。实战建议布隆过滤器和空对象缓存通常结合使用。布隆过滤器作为第一道高效防线拦截绝大部分非法请求对于通过布隆过滤器但数据库确实没有的数据再用空对象缓存短期存储避免同一非法Key在短时间内重复穿透。同时必须在API网关或应用入口做好请求参数的合法性校验如ID必须为正整数、符合格式等这是成本最低、效果最好的防护。5. 进阶策略与架构层面的思考解决了单个问题后我们需要从更高维度审视缓存体系构建更健壮的防御。5.1 多级缓存架构化整为零不要把所有鸡蛋放在一个篮子里。引入多级缓存可以显著提升系统的抗风险能力。一个典型的架构是L1本地缓存Caffeine/Guava Cache存在于应用进程内速度极快容量小。适合存储极热的数据、或数据量小的只读配置。它可以作为Redis缓存的前置在Redis出现波动时提供一定缓冲。L2分布式缓存Redis Cluster存储大量的热点数据和共享状态。L3数据库数据持久化层。更新策略上可以采用推模式数据变更时主动更新/失效所有层级的缓存或拉模式缓存失效后由访问请求触发回源加载。多级缓存的关键在于缓存一致性的管理复杂度较高需要根据业务对一致性的要求来设计。5.2 热点Key探测与自动隔离对于缓存击穿我们是在问题发生后Key失效才用锁去应对。更优的做法是主动发现热点Key并提前进行保护。监控发现通过Redis的monitor命令谨慎生产使用、hotkeys参数Redis 4.0或通过客户端埋点统计Key的访问频次识别出热点Key。自动保护对于识别出的热点Key系统可以自动将其标记并采取特殊策略永不过期异步更新为其设置更长的TTL或逻辑过期策略。本地缓存备份在应用本地缓存中也存一份进一步减轻Redis压力和网络开销。请求限流与排队对该Key的查询请求在应用层进行简单的限流或队列化平滑地打到数据库。5.3 缓存数据的预热与降级预热在系统启动、或低峰期如凌晨提前将预计会成为热点的数据加载到缓存中。特别是在大促活动前必须对活动相关数据进行全量预热。降级当缓存集群完全不可用时需要有立刻切换的降级方案。例如切换到只读的备用缓存集群、直接使用本地缓存如果之前有、或者对非核心功能直接返回降级页面。降级开关必须配置在配置中心可以快速推送生效。5.4 监控与告警可观测性是生命线没有监控所有优化都是盲人摸象。必须建立完善的缓存监控体系核心指标监控Redis集群的内存使用率、连接数、QPS、命中率、慢查询、Key淘汰数量。业务指标监控核心接口的缓存命中率、数据库查询QPS的突增情况。告警设置缓存命中率低于阈值、Redis内存使用率超过80%、数据库QPS异常飙升、大量缓存穿透日志出现时必须立即告警。缓存不是银弹而是一把双刃剑。引入缓存的同时也引入了数据一致性、复杂度、以及雪崩击穿穿透等风险。理解这些问题的本质并采用差异化的过期时间、互斥锁、布隆过滤器、空值缓存等组合拳进行防御是每一位后端开发者的必修课。更重要的是要结合业务场景选择合适的策略没有最好的方案只有最合适的方案。在实际架构中这些方案往往是交织在一起的需要我们从编码规范如设置随机TTL、组件选择如使用Redisson实现分布式锁、到系统架构多级缓存、热点探测进行全链路的思考和设计。每一次线上故障都是最好的老师把缓存当作一个需要精心维护的“系统”来对待而不仅仅是一个get/set的工具才能让它在高并发系统中真正稳定、高效地发挥作用。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门