一. 什么是缓存击穿 简单来说,缓存击穿描述的是这样一种场景:一个被高频访问的热点数据(我们称之为热点key),恰好在缓存中过期失效的那一刻,海量的请求瞬间绕过了缓存,直接涌向了后端的数据库。这就像一道原本坚固的堤坝突然出现了一个缺口,所有洪水都从这个缺口冲向下游,结果就是数据库的瞬时压力急剧飙升,
简单来说,缓存击穿描述的是这样一种场景:一个被高频访问的热点数据(我们称之为热点key),恰好在缓存中过期失效的那一刻,海量的请求瞬间绕过了缓存,直接涌向了后端的数据库。这就像一道原本坚固的堤坝突然出现了一个缺口,所有洪水都从这个缺口冲向下游,结果就是数据库的瞬时压力急剧飙升,甚至可能被直接压垮。
这里有个关键点:为什么数据库会扛不住?因为当请求在缓存中查不到数据,转而查询数据库时,这个查询过程往往不是简单的单表查询。它可能涉及多表关联、复杂计算或数据汇总,本身就需要较长的处理时间。在这个“漫长”的查询过程中,成千上万的请求还在源源不断地涌来,数据库的资源(CPU、内存、IO)很快就会被耗尽,导致响应超时、报错,乃至服务彻底宕机。
长期稳定更新的攒劲资源: >>>点此立即查看<<<
举个典型的例子:电商平台上的“爆款商品详情页”。这个页面的数据通常会在Redis里缓存1小时。在缓存有效期内,所有用户请求都由Redis轻松处理,数据库高枕无忧。然而,当这个缓存key在晚上8点的流量高峰时刻过期,假设恰好有1000个用户同时点击这个商品,缓存瞬间失效,这1000个请求就会齐刷刷地砸向数据库。数据库很可能在几秒钟内就宣告崩溃。
说到这里,有必要区分两个容易混淆的概念:
缓存穿透:请求的key在缓存和数据库中根本不存在。每次请求都会穿透缓存直达数据库,通常由恶意攻击或错误查询引起。
缓存击穿:请求的key在数据库中是存在的,只是缓存刚好过期了。这是由热点数据的失效时机引发的瞬时风暴。
这是击穿的前提。如果某个key访问频率很低,即便它过期了,也只有零星几个请求会打到数据库,掀不起什么风浪。只有当这个key是真正的“热点”,承载着巨大的瞬时流量时,它的失效才会成为一个致命的时间点。
Redis等缓存系统为key设置过期时间是标准操作,目的是及时清理无效数据,释放宝贵的内存空间。但问题在于,如果热点key的过期时间设置得不够合理,或者“运气不好”正好在流量洪峰时过期,击穿的风险就大大增加了。
比如,将爆款商品的缓存时间机械地设为1小时,而没有考虑业务高峰时段,就很容易在晚高峰时“准时”触发击穿。
这是最直接的导火索。当缓存失效后,如果系统设计上没有任何缓冲或保护措施——比如限流、降级或请求排队——那么所有请求就会像脱缰野马一样毫无阻拦地冲向数据库。要知道,数据库的并发处理能力与Redis这类内存缓存根本不在一个数量级,这种毫无保护的直接冲击,后果可想而知。
数据库压力骤增:瞬时的大量并发查询会瞬间拉高数据库的CPU、内存和磁盘IO使用率,导致其响应时间呈指数级增长,所有依赖该数据库的接口都会变慢。
系统响应超时:前端应用在等待数据库响应时,会因为超时而出现加载失败、页面白屏或错误提示,用户体验急剧下降。
数据库宕机:如果请求量完全超出了数据库的承载极限,最坏的情况就是数据库进程崩溃,服务完全不可用。
连锁反应(雪崩):一个核心数据库的宕机,往往会引发连锁反应。即使缓存服务正常,后续请求也无法得到处理。更严重的是,依赖该数据库的其他微服务或业务模块也会随之出现故障,导致整个系统瘫痪。
这个思路的核心是“串行化”重建过程。当大量请求并发访问时:
这种方案保证了数据的强一致性,因为在缓存重建期间,所有请求要么读到旧数据,要么等待新数据。但它的缺点也很明显:性能有损耗。在获取锁和等待缓存重建的这段时间里,大量请求实际上处于阻塞或重试状态,系统吞吐量会受到影响。

// 1. 注入RedisTemplate(SpringBoot环境) @Autowired private RedisTemplateredisTemplate; // 2. 互斥锁核心方法(获取锁+查询数据库+更新缓存) public Object getValueByMutexLock(String key) { // 第一步:查询缓存 Object value = redisTemplate.opsForValue().get(key); if (value != null) { return value; // 缓存存在,直接返回 } // 第二步:缓存不存在,尝试获取分布式锁 String lockKey = "lock:" + key; // 锁key,与业务key绑定,避免锁冲突 String lockValue = UUID.randomUUID().toString(); // 唯一值,用于释放锁 boolean isLock = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 3, TimeUnit.SECONDS); // 锁过期时间3秒(根据数据库查询耗时调整) if (isLock) { try { // 第三步:获取锁成功,查询数据库 value = queryDatabase(key); // 自定义方法,查询数据库数据 // 第四步:将数据库数据写入缓存,设置过期时间(避免再次击穿) redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES); return value; } finally { // 第五步:释放锁(必须在finally中,避免死锁) // 对比value确保是自己的锁,避免误释放他人的锁 if (lockValue.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } } else { // 第六步:获取锁失败,重试(间隔100ms,避免频繁重试) try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getValueByMutexLock(key); // 递归重试,也可使用循环 } } // 模拟数据库查询方法 private Object queryDatabase(String key) { // 实际业务中替换为真实数据库查询逻辑(如MyBatis查询) return "数据库查询到的" + key + "对应数据"; }
这个方案换了一种思路,它不再依赖Redis的物理过期时间,而是将过期逻辑放在缓存数据的值里面。我们封装一个数据结构,里面既包含业务数据,也包含一个逻辑过期时间戳。
当请求到来时:
这种方案的优势在于高可用和高性能。它牺牲了数据的绝对强一致性(在异步更新完成前,用户读到的是稍旧的数据),但保证了服务的永远可用和极速响应。对于许多读多写少、对短暂数据延迟不敏感的场景(如商品描述、文章内容),这是一个非常经典的权衡选择。

// 1. 定义缓存数据封装类(封装业务数据+逻辑过期时间) @Data public class CacheData{ // 业务数据 private T data; // 逻辑过期时间(时间戳,单位:毫秒) private Long expireTime; } // 2. 注入依赖(SpringBoot环境) @Autowired private RedisTemplate redisTemplate; // 异步线程池(用于逻辑过期后异步更新缓存) @Autowired private ThreadPoolTaskExecutor asyncTaskExecutor; // 3. 逻辑过期核心方法 public Object getValueByLogicalExpire(String key) { // 第一步:查询Redis缓存(获取封装后的CacheData对象) CacheData
面对缓存击穿,没有一种“银弹”方案。互斥锁方案通过强制串行化来保证强一致性,适用于对数据实时性要求极高的金融、交易类场景。而逻辑过期方案则通过异步更新和返回旧数据,优先保障了系统的高可用与高性能,更适合资讯、商品详情等容忍短期数据延迟的场景。在实际架构设计中,需要根据具体的业务特性和数据一致性要求,做出最合适的选择。
您可能感兴趣的文章:
侠游戏发布此文仅为了传递信息,不代表侠游戏网站认同其观点或证实其描述