Redis 缓存的穿透、击穿、雪崩 是生产环境中最常见的三大性能与可用性问题,三者的核心差异在于触发场景、影响范围、解决方案 ,但本质都是缓存未命中后,请求直接冲击数据库,导致数据库压力陡增甚至宕机。
| 问题类型 | 核心定义 | 影响范围 | 缓存状态 | 数据库压力 |
|---|---|---|---|---|
| 缓存穿透 | 请求不存在的 key,缓存和数据库均无数据,请求持续穿透到数据库 | 单个无效 key 的高频请求 | 缓存未命中(永久) | 持续低至中等压力 |
| 缓存击穿 | 热点 key 突然失效,大量请求同时穿透到数据库 | 单个热点 key 的瞬时请求 | 缓存命中 → 突然失效 | 瞬时极高压力 |
| 缓存雪崩 | 大量 key 同时失效 或 缓存服务整体宕机,导致大量请求穿透到数据库 | 整个缓存集群的所有请求 | 大面积缓存失效 / 缓存不可用 | 整体极高压力,可能直接压垮数据库 |
一、缓存穿透(Cache Penetration)
1、定义
客户端请求一个缓存和数据库中都不存在的 key ,由于缓存中没有数据,请求会直接穿透到数据库;而数据库中也没有对应数据,无法将结果写入缓存。最终导致所有此类请求都会直接访问数据库,造成数据库的无效负载。
2、触发原因
- 业务逻辑问题:用户查询不存在的业务数据(如查询一个不存在的用户 ID、订单号)。
- 恶意攻击 :黑客构造大量无效的 key 发起请求(如批量请求
user:-1、order:abc等),专门用于穿透缓存,攻击数据库。
3、典型场景
- 电商平台中,黑客批量请求不存在的商品 ID,导致商品查询接口的数据库压力陡增。
- 接口未做参数校验,攻击者传入非法参数(如负数、超长字符串),触发大量无效查询。
4、解决方案
(1)参数校验 + 业务过滤(第一道防线)
在请求到达缓存之前,先对参数进行合法性校验,过滤掉明显无效的请求。
java
// 用户查询接口,先校验用户ID的合法性
public User getUserById(Long userId) {
// 过滤无效参数:ID必须大于0
if (userId == null || userId <= 0) {
return null;
}
// 后续缓存查询逻辑
String key = "user:" + userId;
User user = redisTemplate.opsForValue().get(key);
if (user == null) {
user = userMapper.selectById(userId);
if (user != null) {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
}
}
return user;
}
(2)空值缓存(核心解决方案)
对于数据库中不存在的 key,将其空结果写入缓存 ,并设置一个较短的过期时间(如 5 分钟)。这样,后续相同的无效请求会被缓存拦截,不会穿透到数据库。注意:
- 过期时间不能太长,否则会导致缓存中积累大量空值,浪费内存。
- 可以为空值设置独立的缓存前缀(如
null:user:-1),方便后续批量清理。
java
public User getUserById(Long userId) {
if (userId == null || userId <= 0) {
return null;
}
String key = "user:" + userId;
User user = redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// 数据库查询
user = userMapper.selectById(userId);
if (user != null) {
// 有数据,缓存30分钟
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
} else {
// 无数据,缓存空值5分钟,防止穿透
redisTemplate.opsForValue().set(key, null, 5, TimeUnit.MINUTES);
}
return user;
}
(3)布隆过滤器(终极解决方案,适用于海量数据场景)
布隆过滤器是一种空间效率极高的概率型数据结构,可以用来判断一个元素是否在一个集合中。其核心特点是:
- 不存在的元素:一定能被准确判断为「不存在」,从而直接拦截请求。
- 存在的元素:可能会被判断为「存在」(存在一定的误判率,可通过调整参数控制)。
适用场景 :数据量极大(如亿级用户 ID、商品 ID),空值缓存会占用大量内存的场景。实现步骤:
- 系统启动时,将数据库中所有有效的 key 加载到布隆过滤器中。
- 接收到请求时,先通过布隆过滤器判断 key 是否存在:
- 若不存在,直接返回 null,不访问缓存和数据库。
- 若存在,再走正常的缓存 → 数据库流程。
java
// 初始化布隆过滤器,预计存放1000万条数据,误判率为0.01
private static BloomFilter<Long> userBloomFilter = BloomFilter.create(
Funnels.longFunnel(),
10_000_000,
0.01
);
// 系统启动时,加载所有用户ID到布隆过滤器
@PostConstruct
public void initBloomFilter() {
List<Long> allUserId = userMapper.selectAllUserId();
for (Long userId : allUserId) {
userBloomFilter.put(userId);
}
}
public User getUserById(Long userId) {
if (userId == null || userId <= 0) {
return null;
}
// 布隆过滤器判断:若不存在,直接返回
if (!userBloomFilter.mightContain(userId)) {
return null;
}
// 后续缓存 → 数据库流程
String key = "user:" + userId;
User user = redisTemplate.opsForValue().get(key);
if (user == null) {
user = userMapper.selectById(userId);
if (user != null) {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
} else {
// 布隆过滤器误判,缓存空值
redisTemplate.opsForValue().set(key, null, 5, TimeUnit.MINUTES);
}
}
return user;
}
5. 优缺点
- 参数校验:优点是实现简单,开销小;缺点是只能过滤明显无效的请求,无法处理合法但不存在的 key。
- 空值缓存:优点是实现简单,效果好;缺点是会占用一定的缓存空间,且存在缓存更新的问题。
- 布隆过滤器:优点是空间效率极高,能有效拦截海量无效请求;缺点是存在误判率,且需要提前加载数据,不支持动态删除。
二、缓存击穿(Cache Breakdown)
1、定义
一个热点 key (如秒杀商品、热门新闻、首页缓存)在缓存中突然失效 (过期或被淘汰),此时大量的请求同时访问这个 key,由于缓存未命中,这些请求会瞬间全部穿透到数据库,导致数据库在短时间内承受巨大的压力,甚至被压垮。
2、核心区别于穿透
- 穿透:key 永远不存在,缓存和数据库都没有。
- 击穿:key 曾经存在 ,缓存中突然失效,数据库中仍然存在。
3、触发原因
- 热点 key 过期:为热点 key 设置了过期时间,且过期时间集中在同一时刻。
- 缓存淘汰:热点 key 被 LRU、LFU 等缓存淘汰策略清理掉。
- 主动删除:业务代码主动删除了热点 key。
4、典型场景
- 电商秒杀活动中,某个热门商品的缓存过期,瞬间有 10 万用户同时查询该商品,所有请求都穿透到数据库。
- 首页的缓存数据(如轮播图、推荐商品)过期,大量用户访问首页,导致数据库压力陡增。
5、解决方案
(1)热点 key 永不过期(最简单有效)
对于真正的热点 key,不设置过期时间 ,由业务代码主动更新缓存,而不是依赖缓存的过期机制。注意:
- 必须保证业务代码能主动更新缓存,否则会导致缓存数据不一致。
- 适用于数据更新频率低的热点 key(如首页静态数据、热门商品的基本信息)。
java
public Product getHotProduct(Long productId) {
String key = "hot:product:" + productId;
Product product = redisTemplate.opsForValue().get(key);
if (product == null) {
// 数据库查询,加锁防止并发穿透
synchronized (this) {
product = redisTemplate.opsForValue().get(key);
if (product == null) {
product = productMapper.selectById(productId);
if (product != null) {
// 永不过期,主动更新
redisTemplate.opsForValue().set(key, product);
}
}
}
}
return product;
}
// 主动更新缓存的方法,由定时任务或业务触发
public void updateHotProductCache(Long productId) {
String key = "hot:product:" + productId;
Product product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(key, product);
}
}
(2)互斥锁(分布式锁,最通用)
当缓存未命中时,只有一个请求能获得锁 ,并去数据库查询数据,其他请求则等待锁释放 ,然后从缓存中获取数据。这样可以保证同一时刻只有一个请求穿透到数据库 ,有效解决击穿问题。实现方式:
- 本地锁(synchronized、ReentrantLock):适用于单节点应用。
- 分布式锁(Redis Redlock、ZooKeeper):适用于分布式集群应用。
java
public Product getProductById(Long productId) {
String key = "product:" + productId;
String lockKey = "lock:product:" + productId;
Product product = redisTemplate.opsForValue().get(key);
if (product == null) {
// 尝试获取分布式锁,超时时间3秒,过期时间5秒
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
if (locked != null && locked) {
try {
// 获得锁,查询数据库
product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
} else {
// 空值缓存,防止穿透
redisTemplate.opsForValue().set(key, null, 5, TimeUnit.MINUTES);
}
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 未获得锁,等待50毫秒后重试
Thread.sleep(50);
return getProductById(productId);
}
}
return product;
}
(3)过期时间随机化(预防策略)
对于必须设置过期时间的 key,在基础过期时间上增加一个随机值(如 0~60 秒),避免大量 key 在同一时刻过期。
java
public void setCacheWithRandomExpire(String key, Object value, int baseExpireSeconds) {
// 随机增加0~60秒的过期时间
int randomExpire = new Random().nextInt(60);
int totalExpire = baseExpireSeconds + randomExpire;
redisTemplate.opsForValue().set(key, value, totalExpire, TimeUnit.SECONDS);
}
6、优缺点
- 永不过期:优点是实现简单,效果好;缺点是需要主动更新缓存,可能导致数据不一致,且占用缓存空间。
- 互斥锁:优点是通用,适用于所有场景,能有效防止并发穿透;缺点是实现稍复杂,存在锁竞争的开销,可能导致请求延迟。
- 随机过期时间:优点是实现简单,能有效预防击穿;缺点是只能预防,不能解决已经发生的击穿问题。
三、缓存雪崩(Cache Avalanche)
1、定义
大量的 key 在同一时刻同时失效 ,或者整个缓存服务突然宕机 (如 Redis 集群崩溃、网络故障),导致所有的请求都穿透到数据库,数据库在短时间内承受巨大的压力,甚至被压垮,从而引发整个系统的雪崩效应。
2、核心区别于击穿
- 击穿:单个热点 key 失效,影响范围小。
- 雪崩:大量 key 同时失效 或 缓存服务整体宕机,影响范围大,是击穿的升级版。
3、触发原因
(1)缓存层原因
- 大量 key 同时过期:系统初始化时,为大量 key 设置了相同的过期时间;或者某个批量操作,为大量 key 设置了相同的过期时间。
- 缓存服务整体宕机:Redis 集群崩溃、网络故障、硬件故障等,导致缓存服务不可用。
- 缓存淘汰:大量 key 被 LRU、LFU 等缓存淘汰策略批量清理。
(2)应用层原因
- 大量请求同时涌入,导致缓存层压力过大,最终崩溃。
4、典型场景
- 电商平台的大促活动中,大量商品的缓存都设置了 24 小时的过期时间,活动结束后,这些缓存同时过期,导致大量请求穿透到数据库。
- Redis 集群的主节点宕机,从节点没有及时切换,导致整个缓存服务不可用,所有请求都穿透到数据库。
5、解决方案
缓存雪崩的影响范围极大,因此需要采用分层防御策略 ,从缓存层、数据库层、应用层三个层面进行防护。
(1)缓存层防护(核心)
- 过期时间随机化:为每个 key 的过期时间增加一个随机值,避免大量 key 在同一时刻过期。(同击穿的预防策略)
- 缓存集群高可用 :搭建 Redis 集群,采用主从复制 + 哨兵模式 或Redis Cluster,保证缓存服务的高可用。即使某个节点宕机,其他节点仍能提供服务。
- 多级缓存 :引入本地缓存(如 Caffeine、Guava Cache) + 分布式缓存(Redis) 的多级缓存架构。当分布式缓存宕机时,本地缓存可以作为兜底,拦截一部分请求。
(2)数据库层防护(兜底)
- 数据库读写分离:搭建数据库的读写分离架构,将读请求分散到多个从库,提高数据库的处理能力。
- 数据库分库分表:对数据库进行分库分表,分散单表的压力。
- 限流降级:在数据库层设置限流策略,当请求量超过阈值时,拒绝部分请求,或者返回降级数据(如缓存的旧数据、默认数据)。
(3)应用层防护(前置)
- 限流:在应用层设置限流策略(如使用 Sentinel、Hystrix),限制每秒的请求数,避免大量请求同时涌入。
- 降级:当缓存服务宕机时,应用层自动降级,返回兜底数据(如本地缓存的旧数据、默认数据),而不是直接访问数据库。
- 熔断:当数据库的压力超过阈值时,应用层自动熔断,停止访问数据库,返回兜底数据,避免数据库被压垮。
6、典型实现(多级缓存 + 限流降级)
java
// 本地缓存,Caffeine,过期时间5分钟
private static final Cache<Long, Product> localCache = Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES)
.maximumSize(1000)
.build();
// 分布式缓存,Redis
public Product getProductById(Long productId) {
// 1. 先查本地缓存
Product product = localCache.getIfPresent(productId);
if (product != null) {
return product;
}
// 2. 再查分布式缓存
String key = "product:" + productId;
product = redisTemplate.opsForValue().get(key);
if (product != null) {
// 写入本地缓存
localCache.put(productId, product);
return product;
}
// 3. 最后查数据库,加分布式锁
String lockKey = "lock:product:" + productId;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
if (locked != null && locked) {
try {
product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
localCache.put(productId, product);
} else {
redisTemplate.opsForValue().set(key, null, 5, TimeUnit.MINUTES);
}
} finally {
redisTemplate.delete(lockKey);
}
} else {
Thread.sleep(50);
return getProductById(productId);
}
return product;
}