缓存三大问题(击穿、穿透、雪崩)原理及解决方案是什么?

摘要:缓存的击穿、穿透、雪崩是后端开发中高频出现、极易引发服务雪崩的核心问题,三者看似相似但原理和解决方案完全不同。本文用「原理+场景+落地方案+代码」的形式,一次性讲透,看完就能直接落地到生
缓存的击穿、穿透、雪崩是后端开发中高频出现、极易引发服务雪崩的核心问题,三者看似相似但原理和解决方案完全不同。本文用「原理+场景+落地方案+代码」的形式,一次性讲透,看完就能直接落地到生产环境。 先分清:三大问题核心区别(一张表看懂) 问题类型 核心定义 典型场景 直接后果 缓存穿透 请求根本不存在的key,缓存和DB都查不到 恶意攻击(批量查不存在的用户ID)、业务bug(传错参数) DB被大量无效请求打满,连接耗尽 缓存击穿 热点key突然失效(过期/被删除),大量请求瞬间打到DB 秒杀商品key过期、热门榜单key被删除 DB单点压力骤增,瞬间超时/宕机 缓存雪崩 大量缓存key同时失效,或缓存服务整体宕机 缓存key集中设置相同过期时间、Redis集群宕机 DB被海量请求压垮,整个服务不可用 一、缓存穿透:查「不存在的key」把DB打崩 1. 原理 请求的key在缓存中不存在,且在数据库中也不存在,导致每次请求都「穿透」缓存直接打在DB上。如果是恶意高频请求(比如每秒上万次查不存在的用户ID),DB很快会被压垮。 2. 全套解决方案(按优先级排序) 方案1:参数校验(最基础,成本最低) 在请求到达缓存/DB前,先做合法性校验,过滤掉明显无效的请求: 比如用户ID是正整数,直接拦截负数/0/超长字符串; 比如商品ID有固定格式,拦截不符合格式的请求。 代码示例(Java): public User getUserById(Long userId) { // 第一步:参数合法性校验,直接拦截无效请求 if (userId == null || userId <= 0 || userId > 10000000) { return null; } // 后续缓存/DB查询逻辑... } 方案2:空值缓存(核心方案) 对查询结果为空的key,也写入缓存(设置极短的过期时间,比如1-5分钟),避免后续请求重复打DB。 代码示例(Java + Redis): public User getUserById(Long userId) { String cacheKey = "user:" + userId; // 1. 查缓存 String userJson = redisTemplate.opsForValue().get(cacheKey); // 空值标记(避免JSON解析问题,用特定字符串表示空) if ("NULL".equals(userJson)) { return null; } if (userJson != null) { return JSON.parseObject(userJson, User.class); } // 2. 缓存未命中,查DB User user = userMapper.selectById(userId); if (user == null) { // 3. 空值写入缓存,设置短过期时间(防止恶意攻击占满缓存) redisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES); return null; } // 4. 正常数据写入缓存 redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES); return user; } 方案3:布隆过滤器(高并发/海量key场景) 如果无效key的范围极大(比如电商场景查不存在的商品ID),用布隆过滤器提前判断key是否存在,不存在则直接返回,完全拦截无效请求。 核心逻辑: 启动时将DB中所有有效key加载到布隆过滤器; 请求过来先过布隆过滤器,不存在则直接返回; 存在则走正常缓存/DB流程。 代码示例(Guava布隆过滤器): import com.google.common.hash.BloomFilter; import com.google.common.hash.Funnels; // 初始化布隆过滤器(预计100万key,误判率0.01) private static BloomFilter<Long> userBloomFilter = BloomFilter.create( Funnels.longFunnel(), 1000000, 0.01 ); // 启动时加载所有有效用户ID到过滤器 @PostConstruct public void initBloomFilter() { List<Long> allUserId = userMapper.selectAllUserId(); for (Long userId : allUserId) { userBloomFilter.put(userId); } } public User getUserById(Long userId) { // 第一步:布隆过滤器拦截无效key if (!userBloomFilter.mightContain(userId)) { return null; } // 后续缓存/DB查询逻辑... } 方案4:限流/熔断(兜底方案) 用Sentinel/Hystrix对接口做限流,当QPS超过阈值时直接拒绝;或监控DB压力,达到阈值时熔断缓存穿透的请求,避免DB被打垮。 缓存穿透方案总结 基础:参数校验 核心:空值缓存(中小规模)、布隆过滤器(大规模) 兜底:限流/熔断 二、缓存击穿:热点key失效导致DB单点压力暴增 1. 原理 某个高频访问的「热点key」(比如秒杀商品、热门榜单)突然过期/被删除,大量请求瞬间绕过缓存直接访问DB,导致DB单点压力骤增,甚至宕机。 2. 全套解决方案(按优先级排序) 方案1:热点key永不过期(最简单) 对核心热点key,不设置过期时间,由业务代码主动更新/删除,避免被动过期。 注意:需在代码中保证热点key的更新逻辑(比如商品价格修改时,主动更新缓存),防止缓存脏数据。 方案2:互斥锁(分布式锁)(最通用) 当缓存失效时,不是所有请求都去查DB,而是只有一个请求获取锁后去查DB并更新缓存,其他请求等待锁释放后直接查缓存。 代码示例(Redis分布式锁): public User getHotUserById(Long userId) { String cacheKey = "hot_user:" + userId; String lockKey = "lock:hot_user:" + userId; // 1. 查缓存 String userJson = redisTemplate.opsForValue().get(cacheKey); if (userJson != null) { return JSON.parseObject(userJson, User.class); } // 2. 缓存失效,尝试获取分布式锁 Boolean lockSuccess = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS); if (lockSuccess) { try { // 3. 获取锁成功,查DB并更新缓存 User user = userMapper.selectById(userId); if (user != null) { redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 60, TimeUnit.MINUTES); } return user; } finally { // 4. 释放锁 redisTemplate.delete(lockKey); } } else { // 5. 未获取锁,等待50ms后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 递归重试 return getHotUserById(userId); } } 方案3:提前预热(主动更新) 在热点key过期前,主动查询DB并更新缓存,避免过期瞬间的请求冲击。 比如定时任务:在key过期前10分钟,主动加载最新数据到缓存; 比如监控热点key的过期时间,临近过期时触发更新。 方案4:本地缓存(多级缓存) 对极致热点key,增加「本地缓存」(比如Caffeine/Guava Cache),请求先查本地缓存,再查分布式缓存,最后查DB,进一步降低分布式缓存失效的影响。 缓存击穿方案总结 简单版:热点key永不过期 通用版:分布式互斥锁 进阶版:提前预热 + 多级缓存 三、缓存雪崩:大量key同时失效/缓存宕机导致服务雪崩 1. 原理 两种场景会引发缓存雪崩: 大量缓存key在同一时间段集中过期,海量请求瞬间打向DB; 缓存服务(如Redis集群)整体宕机,所有请求直接穿透到DB。 最终导致DB压力暴增,服务不可用,甚至整个系统雪崩。 2. 全套解决方案(分场景应对) 场景1:大量key集中过期 → 打散过期时间 核心思路:给每个key的过期时间增加随机值,避免所有key同时过期。 代码示例(Java): // 基础过期时间30分钟,随机增加0-10分钟 int baseExpire = 30; int randomExpire = new Random().nextInt(10); redisTemplate.opsForValue().set(cacheKey, value, baseExpire + randomExpire, TimeUnit.MINUTES); 场景2:缓存服务宕机 → 提高缓存可用性 缓存集群化:使用Redis主从+哨兵/Redis Cluster,避免单点故障; 熔断降级:用Sentinel/Hystrix监控缓存服务状态,当缓存不可用时,暂时熔断请求(返回默认值/提示),避免DB被打垮; 限流:对所有请求做限流,即使缓存宕机,DB也只接收可控的请求量; 多级缓存:增加本地缓存(Caffeine),即使分布式缓存宕机,本地缓存仍能承接部分请求; 缓存降级:缓存宕机时,返回兜底数据(如商品默认价格、空列表),保证服务不挂。 代码示例(熔断降级伪代码): public User getUserById(Long userId) { try { // 先查分布式缓存 String userJson = redisTemplate.opsForValue().get("user:" + userId); if (userJson != null) { return JSON.parseObject(userJson, User.class); } } catch (Exception e) { // 缓存异常,触发降级,返回本地缓存/兜底数据 log.error("缓存异常,触发降级", e); return getLocalCacheUser(userId); // 本地缓存兜底 } // 缓存异常/未命中,查DB(同时限流) return userMapper.selectById(userId); } 场景3:终极兜底 → DB层防护 即使缓存完全不可用,也要保证DB不被打垮: DB读写分离/分库分表,提高DB处理能力; DB加连接池限流,避免连接数耗尽; 对DB查询做缓存(比如MyBatis一级/二级缓存)。 缓存雪崩方案总结 预防集中过期:过期时间加随机值 提高缓存可用性:集群化 + 熔断降级 + 多级缓存 终极兜底:DB层限流 + 读写分离 四、三大问题核心方案对比表 问题类型 核心原因 首选方案 兜底方案 缓存穿透 查不存在的key 空值缓存(中小规模)、布隆过滤器(大规模) 参数校验 + 限流 缓存击穿 热点key失效 分布式互斥锁 热点key永不过期 缓存雪崩 大量key过期/缓存宕机 过期时间随机化 + 缓存集群化 熔断降级 + DB限流 总结 缓存穿透:核心是拦截无效请求,用空值缓存/布隆过滤器挡住不存在的key; 缓存击穿:核心是保护热点key,用分布式锁避免并发查DB,或直接设置永不过期; 缓存雪崩:核心是提高可用性 + 打散风险,过期时间随机化防集中失效,集群化+熔断降级防缓存宕机; 所有方案都需配合限流/熔断作为兜底,确保极端场景下服务不雪崩。 如果需要某类方案的完整生产级代码(比如Redis分布式锁、布隆过滤器落地、Sentinel限流配置),可以告诉我,我会补充对应的可直接运行的代码。