前言
在上一篇文章中,我们聊到了 Redis 的基础数据结构与常用命令。但在实际项目里,Redis 更多是作为缓存层挡在数据库前面,扛住高并发流量。然而,”缓存好用,坑也不少”——缓存穿透、缓存击穿、缓存雪崩,这三个问题如果没有处理好,轻则接口响应变慢,重则数据库被打垮导致整个服务雪崩。
本文将从问题成因 → 解决思路 → 编码落地三个层面,逐一拆解这三大经典缓存问题。

一、先理解缓存的基本工作模式
在讲问题之前,先回顾一下缓存的正常读写流程:
1 2 3 4 5 6 7 8 9
| 客户端请求 → 查 Redis 缓存 ↓ 缓存命中?──是──→ 直接返回数据 ↓否 查 MySQL 数据库 ↓ 将结果写入 Redis(设置过期时间) ↓ 返回数据给客户端
|
这个流程看起来完美,但问题就出在缓存未命中的这几种特殊情况上。
二、缓存更新策略与一致性
在深入三个问题之前,有必要先理清缓存的更新策略,因为策略选错了,本身就是问题的根源。
2.1 三种主流更新策略
| 策略 |
做法 |
优点 |
缺点 |
| 先删缓存,再更新数据库 |
写操作时先删除 Redis,再写 MySQL |
简单直接 |
并发时可能读到旧数据写回缓存 |
| 先更新数据库,再删缓存 |
写操作时先写 MySQL,再删除 Redis |
数据一致性更好 |
删除失败会导致脏数据 |
| 双写(更新数据库 + 更新缓存) |
同时更新两边的数据 |
缓存始终有数据 |
并发写容易产生不一致 |
2.2 推荐方案:延迟双删
1 2 3 4
| ① 删除缓存 ② 更新数据库 ③ 休眠一小段时间(比如 200ms) ④ 再次删除缓存
|
这样即使有并发读在步骤②期间把旧数据写回了缓存,步骤④也会把它清理掉。虽然不能做到绝对的强一致性,但对于大多数业务场景来说,最终一致性已经足够了。
重要认知:缓存和数据库的一致性是一个 trade-off。CAP 理论告诉我们,在分布式系统中,一致性和可用性往往不可兼得。使用缓存,本质上就是用”一定程度的最终一致性”换取”极大的性能提升”。
三、缓存穿透(Cache Penetration)
3.1 什么是缓存穿透?
缓存穿透是指查询一个根本不存在的数据——这个数据在缓存中没有,在数据库中也没有。
1 2 3 4 5 6 7
| 用户查询 id = -1 的数据 ↓ 查 Redis → 没有(因为根本不存在) ↓ 查 MySQL → 也没有 ↓ 返回 null,但下次请求还是会查一遍
|
由于这个数据不存在,缓存永远不会被写入,每次请求都会直达数据库。如果有恶意攻击者不断用不存在的 id 发起请求,数据库就会被打垮。
3.2 解决方案
方案一:缓存空对象
最直接的思路:既然数据不存在,那就把”不存在”这个结果也缓存起来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| public Goods queryById(Long id) { String key = "goods:" + id; String json = redisTemplate.opsForValue().get(key); if (json != null) { if ("".equals(json)) { return null; } return JSON.parseObject(json, Goods.class); } Goods goods = goodsMapper.selectById(id); if (goods != null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(goods), 30, TimeUnit.MINUTES); } else { redisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES); } return goods; }
|
要点:空对象的过期时间要设得短一些(比如 2 分钟),否则大量不存在的 key 会撑爆 Redis 内存。
方案二:布隆过滤器(Bloom Filter)
缓存空对象虽然简单,但如果攻击者不断换不同的不存在的 id,还是会产生大量空缓存。这时候需要在缓存前面再加一道防线。
布隆过滤器的原理很巧妙:
1 2 3 4
| 布隆过滤器是一个 bit 数组 + 多个哈希函数 添加元素时:计算多个哈希值,将对应 bit 位置为 1 查询元素时:计算哈希值,如果所有 bit 位都是 1 → 可能存在 如果有一个是 0 → 一定不存在
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| BloomFilter<String> filter = BloomFilter.create( Funnels.stringFunnel(Charset.defaultCharset()), 1000000, 0.01 );
List<Long> allIds = goodsMapper.selectAllIds(); for (Long id : allIds) { filter.put("goods:" + id); }
public Goods queryByIdWithBloom(Long id) { String key = "goods:" + id; if (!filter.mightContain(key)) { return null; } }
|
1 2 3 4 5 6 7 8
| 请求进来 ↓ 布隆过滤器:这个 key 可能存在吗? ↓否 → 直接返回 null(拦截掉大部分无效请求) ↓是(可能存在) 查 Redis 缓存 ↓ ...
|
布隆过滤器的特点:
- ✅ 空间效率极高(一个 bit 存一个元素)
- ✅ 查询速度极快(O(k),k 为哈希函数个数)
- ⚠️ 有误判率(会误判不存在的为存在,但不会漏判)
- ⚠️ 不能删除元素(删除会影响到其他元素)
四、缓存击穿(Cache Breakdown)
4.1 什么是缓存击穿?
缓存击穿是指一个热点 key 在过期的一瞬间,大量并发请求同时打到数据库。
1 2 3 4 5 6 7
| 热点 key "goods:100" 过期了 ↓ 1000 个并发请求同时发现缓存没命中 ↓ 1000 个请求全部打到 MySQL ↓ 数据库压力瞬间飙升,可能直接宕机
|
和缓存穿透的区别:
- 穿透:查的是不存在的数据,一直穿透
- 击穿:查的是存在的数据,只是刚好 key 过期了,并发穿透
4.2 解决方案
方案一:互斥锁(Mutex Lock)
核心思路:只让一个线程去查数据库并重建缓存,其他线程等待。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| public Goods queryByIdWithMutex(Long id) { String key = "goods:" + id; String lockKey = "lock:goods:" + id; String json = redisTemplate.opsForValue().get(key); if (json != null) { return JSON.parseObject(json, Goods.class); } try { Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { Goods goods = goodsMapper.selectById(id); if (goods != null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(goods), 30, TimeUnit.MINUTES); } return goods; } else { Thread.sleep(50); return queryByIdWithMutex(id); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); return null; } finally { redisTemplate.delete(lockKey); } }
|
流程图:
1 2 3 4 5 6 7
| 1000 个并发请求发现缓存过期 ↓ 1000 个请求同时竞争互斥锁 ↓ 只有 1 个线程抢到锁 → 查 MySQL,重建缓存 ↓ 其余 999 个线程休眠重试 → 缓存已重建 → 直接命中缓存
|
互斥锁的优缺点:
- ✅ 保证了只有一个线程查数据库
- ✅ 实现简单,容易理解
- ⚠️ 其余线程要等待,有一定延迟
- ⚠️ 可能出现死锁(需要设置锁的过期时间)
方案二:逻辑过期(Logical Expiration)
互斥锁的缺点是:拿到锁的线程要同步查数据库,其他线程需要等待。如果数据库查询本身比较慢,用户体验会受影响。
逻辑过期的思路则是:不设真正的过期时间,而是异步更新缓存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| @Data public class RedisData { private LocalDateTime expireTime; private Object data; }
public Goods queryByIdWithLogicalExpire(Long id) { String key = "goods:" + id; String lockKey = "lock:goods:" + id; String json = redisTemplate.opsForValue().get(key); if (json == null) { return null; } RedisData redisData = JSON.parseObject(json, RedisData.class); Goods goods = JSON.parseObject((String) redisData.getData(), Goods.class); LocalDateTime expireTime = redisData.getExpireTime(); if (expireTime.isAfter(LocalDateTime.now())) { return goods; } Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { threadPoolExecutor.submit(() -> { try { Goods newGoods = goodsMapper.selectById(id); RedisData newRedisData = new RedisData(); newRedisData.setData(JSON.toJSONString(newGoods)); newRedisData.setExpireTime(LocalDateTime.now().plusMinutes(30)); redisTemplate.opsForValue().set(key, JSON.toJSONString(newRedisData)); } finally { redisTemplate.delete(lockKey); } }); } return goods; }
|
逻辑过期方案流程图:
1 2 3 4 5 6 7 8
| 请求发现逻辑过期 ↓ 直接返回旧数据(用户无感知) ↓ 尝试获取互斥锁 ↓ 拿到锁 → 开启新线程异步查 MySQL 重建缓存 没拿到 → 说明已经有线程在重建了,不用管
|
逻辑过期的优缺点:
- ✅ 用户不会阻塞等待,体验更好
- ✅ 旧数据仍然可用,保证高可用
- ⚠️ 实现稍复杂,需要线程池
- ⚠️ 返回的是旧数据(短暂的最终一致性)
- ⚠️ 需要提前预热热点 key
4.3 两种方案对比
| 维度 |
互斥锁 |
逻辑过期 |
| 一致性 |
强一致 |
最终一致(短暂旧数据) |
| 可用性 |
需等待 |
无需等待 |
| 实现复杂度 |
简单 |
中等 |
| 适用场景 |
一致性要求高 |
可用性要求高 |
| 额外开销 |
线程阻塞等待 |
需要线程池异步更新 |
选型建议:如果数据是商品详情、文章内容等对实时性要求不高的场景,推荐逻辑过期;如果是库存扣减、秒杀等对一致性要求极高的场景,推荐互斥锁。
五、缓存雪崩(Cache Avalanche)
5.1 什么是缓存雪崩?
缓存雪崩是指大量 key 同时过期,或者 Redis 服务宕机,导致所有请求瞬间压到数据库。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 场景一:同一时刻大量 key 过期 凌晨 0 点,所有今日推荐商品的缓存同时过期 ↓ 下一个请求到来,发现全部 miss ↓ 所有请求全部打到数据库 ↓ 数据库扛不住,崩了
场景二:Redis 宕机 Redis 服务挂了 ↓ 所有请求直接打到数据库 ↓ 数据库也被打崩 ↓ 服务不可用
|
5.2 解决方案
方案一:过期时间加随机值
不让 key 在同一时刻过期,给每个 key 的过期时间加上一个随机偏移量。
1 2 3 4 5 6
| int baseExpire = 30; int randomOffset = new Random().nextInt(10); int actualExpire = baseExpire + randomOffset;
redisTemplate.opsForValue().set(key, value, actualExpire, TimeUnit.MINUTES);
|
这样即使同一批写入的缓存,过期时间也会分散在 30~40 分钟内,避免了集中过期。
方案二:Redis 高可用集群
单机 Redis 宕机是雪崩的最严重原因。需要通过高可用架构来保障:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| ┌──────────────┐ │ 客户端 │ └──────┬───────┘ │ ┌──────▼───────┐ │ Sentinel │ ← 哨兵监控 + 自动主从切换 └──────┬───────┘ │ ┌─────────────┼─────────────┐ │ │ │ ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ │ Master │ │ Slave 1 │ │ Slave 2 │ │ (读写) │ │ (只读) │ │ (只读) │ └─────────┘ └─────────┘ └─────────┘ │ Master 挂了 → Sentinel 自动选举 Slave 晋升
|
或者使用 Redis Cluster 分片集群,将数据分散在多个节点上。
方案三:多级缓存 + 服务降级
在 Redis 之前再加一层本地缓存(如 Caffeine),即使 Redis 挂了,本地缓存还能扛一阵:
1 2
| 请求 → 本地缓存(Caffeine) → Redis → MySQL 命中则直接返回 命中则返回 最后一道防线
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| public Goods queryWithMultiLevelCache(Long id) { String key = "goods:" + id; Goods goods = caffeineCache.getIfPresent(key); if (goods != null) { return goods; } try { String json = redisTemplate.opsForValue().get(key); if (json != null) { goods = JSON.parseObject(json, Goods.class); caffeineCache.put(key, goods); return goods; } } catch (Exception e) { log.error("Redis 不可用,执行降级策略", e); return getFallbackGoods(id); } goods = goodsMapper.selectById(id); if (goods != null) { try { redisTemplate.opsForValue().set(key, JSON.toJSONString(goods), 30 + new Random().nextInt(10), TimeUnit.MINUTES); } catch (Exception ignored) {} caffeineCache.put(key, goods); } return goods; }
private Goods getFallbackGoods(Long id) { Goods goods = new Goods(); goods.setId(id); goods.setName("商品加载中..."); goods.setDescription("系统繁忙,请稍后再试"); return goods; }
|
六、三大问题总结对比
| 维度 |
缓存穿透 |
缓存击穿 |
缓存雪崩 |
| 问题根源 |
查询不存在的数据 |
热点 key 过期 |
大量 key 同时过期 / Redis 宕机 |
| 现象 |
每次请求都穿透到 DB |
瞬间大量并发打到 DB |
所有请求同时打到 DB |
| 缓存状态 |
缓存中没有,DB 中也没有 |
缓存过期,DB 中有 |
大量缓存同时过期 |
| 核心方案 |
布隆过滤器 / 缓存空对象 |
互斥锁 / 逻辑过期 |
随机过期时间 / 高可用集群 |
| 难度 |
⭐⭐ |
⭐⭐⭐ |
⭐⭐⭐⭐ |
问题关系图
1 2 3 4 5 6 7 8 9 10
| 缓存问题全景 │ ┌───────────────┼───────────────┐ │ │ │ 缓存穿透 缓存击穿 缓存雪崩 (不存在的数据) (热点key过期) (大量key同时过期) │ │ │ 布隆过滤器 互斥锁 随机过期时间 缓存空对象 逻辑过期 Redis高可用 多级缓存+降级
|
七、实战中的综合建议
在实际项目中,这三种问题往往不会孤立出现。以下是我在实践中总结的几条综合建议:
7.1 缓存设计四原则
- 永远设置过期时间:不要设永不过期的 key,否则内存会不断增长
- 过期时间加随机偏移:把
+ random(0, 600) 当作习惯
- 热点数据提前预热:上线前或凌晨定时把热点数据加载到缓存
- 对 DB 做限流保护:即使缓存全部失效,DB 层也要有最后的兜底限流
7.2 监控与告警
1 2 3 4 5 6 7 8 9 10 11 12 13
| public class CacheMonitor { private AtomicLong hitCount = new AtomicLong(0); private AtomicLong missCount = new AtomicLong(0); public void recordHit() { hitCount.incrementAndGet(); } public void recordMiss() { missCount.incrementAndGet(); } public double getHitRate() { long total = hitCount.get() + missCount.get(); return total == 0 ? 0 : (double) hitCount.get() / total; } }
|
关注这几个指标:
- 缓存命中率:低于 90% 就要排查
- 数据库 QPS:突然飙升说明缓存出了问题
- Redis 内存使用率:过高可能导致 OOM
7.3 一个”比较完整”的缓存查询模板
把以上所有方案整合在一起,一个生产级别的缓存查询方法大概是这样的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| public Goods queryById(Long id) { String key = "goods:" + id; String lockKey = "lock:goods:" + id; if (!bloomFilter.mightContain(key)) { return null; } String json = redisTemplate.opsForValue().get(key); if (json != null && !"".equals(json)) { return JSON.parseObject(json, Goods.class); } if ("".equals(json)) { return null; } Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { json = redisTemplate.opsForValue().get(key); if (json != null) { return "".equals(json) ? null : JSON.parseObject(json, Goods.class); } Goods goods = goodsMapper.selectById(id); int expire = 30 + new Random().nextInt(10); if (goods != null) { redisTemplate.opsForValue().set(key, JSON.toJSONString(goods), expire, TimeUnit.MINUTES); } else { redisTemplate.opsForValue().set(key, "", 2, TimeUnit.MINUTES); } return goods; } finally { redisTemplate.delete(lockKey); } } else { try { Thread.sleep(50); } catch (InterruptedException ignored) {} return queryById(id); } }
|
这个模板整合了布隆过滤器(防穿透)+ 缓存空对象(防穿透)+ 互斥锁(防击穿)+ 随机过期(防雪崩),可以作为大多数业务场景的起手式。
八、总结
Redis 缓存的三大问题,本质上都是在处理”缓存未命中时,如何保护数据库”这件事:
- 缓存穿透:数据根本不存在 → 用布隆过滤器在源头拦截,用空对象兜底
- 缓存击穿:热点数据刚好过期 → 用互斥锁控制并发,用逻辑过期提升可用性
- 缓存雪崩:大量数据同时过期或 Redis 宕机 → 用随机过期分散压力,用高可用集群保证服务不挂
这三个问题的学习路径我用了大约 28 个小时,从理论到编码,从互斥锁到逻辑过期,一路踩了不少坑。希望这篇文章能帮你少走一些弯路。
记住一句口诀:
穿透用布隆,击穿用锁控,雪崩加随机,高可用保底。
学习参考:Redis 缓存三大问题实战
核心知识点:布隆过滤器 | 互斥锁 | 逻辑过期 | 缓存空对象 | 随机过期 | Redis 高可用
适用场景:一切使用 Redis 做缓存的高并发业务场景