Redis 缓存穿透、击穿、雪崩
缓存穿透
什么是缓存穿透?
请求的数据既不在缓存中,也不在数据库中,导致每次请求都会穿透缓存直接到达数据库。
正常请求: Client → Redis(命中) → 返回数据
穿透请求: Client → Redis(未命中) → MySQL(不存在) → 返回空 → 下次继续穿透产生原因
- 恶意攻击:使用不存在的 ID 大量请求
- 业务 Bug:请求了不存在的数据
解决方案
1. 缓存空值
java
/**
* 缓存空值方案
* @author yjhu
*/
public String getById(Long id) {
String key = "user:" + id;
String value = redis.get(key);
if (value != null) {
// 命中缓存(包括空值)
return "null".equals(value) ? null : value;
}
// 查询数据库
User user = userMapper.selectById(id);
if (user == null) {
// 缓存空值,设置较短过期时间
redis.setex(key, 60, "null");
return null;
}
redis.setex(key, 3600, JSON.toJSONString(user));
return JSON.toJSONString(user);
}2. 布隆过滤器
在缓存之前增加一层布隆过滤器,快速判断数据是否存在。
java
/**
* 布隆过滤器方案
* @author yjhu
*/
public String getById(Long id) {
// 先判断布隆过滤器
if (!bloomFilter.mightContain(id)) {
return null; // 一定不存在
}
String key = "user:" + id;
String value = redis.get(key);
if (value != null) {
return value;
}
User user = userMapper.selectById(id);
if (user != null) {
redis.setex(key, 3600, JSON.toJSONString(user));
}
return user != null ? JSON.toJSONString(user) : null;
}缓存击穿
什么是缓存击穿?
热点数据的缓存过期瞬间,大量并发请求直接打到数据库。
时间线:
----[缓存有效]----[缓存过期]----[重建缓存]----
↓
大量请求同时到达 MySQL解决方案
1. 互斥锁
只允许一个线程查询数据库并重建缓存,其他线程等待。
java
/**
* 互斥锁方案
* @author yjhu
*/
public String getById(Long id) {
String key = "user:" + id;
String value = redis.get(key);
if (value != null) {
return value;
}
String lockKey = "lock:" + key;
try {
// 尝试获取锁
boolean locked = redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
// 获取锁成功,查询数据库
User user = userMapper.selectById(id);
redis.setex(key, 3600, JSON.toJSONString(user));
return JSON.toJSONString(user);
} else {
// 获取锁失败,休眠后重试
Thread.sleep(50);
return getById(id);
}
} finally {
redis.del(lockKey);
}
}2. 逻辑过期
不设置 TTL,而是在值中存储逻辑过期时间,由后台异步更新。
java
/**
* 逻辑过期方案
* @author yjhu
*/
@Data
public class CacheData {
private Object data;
private LocalDateTime expireTime;
}
public String getById(Long id) {
String key = "user:" + id;
String json = redis.get(key);
if (json == null) {
return null;
}
CacheData cacheData = JSON.parseObject(json, CacheData.class);
// 缓存未过期
if (cacheData.getExpireTime().isAfter(LocalDateTime.now())) {
return JSON.toJSONString(cacheData.getData());
}
// 缓存已过期,异步重建
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS)) {
// 开启异步线程重建缓存
executor.submit(() -> {
try {
User user = userMapper.selectById(id);
CacheData newData = new CacheData();
newData.setData(user);
newData.setExpireTime(LocalDateTime.now().plusHours(1));
redis.set(key, JSON.toJSONString(newData));
} finally {
redis.del(lockKey);
}
});
}
// 返回旧数据
return JSON.toJSONString(cacheData.getData());
}缓存雪崩
什么是缓存雪崩?
大量缓存同时过期,或者 Redis 服务宕机,导致请求全部打到数据库。
解决方案
1. 过期时间随机化
java
// 在基础过期时间上增加随机值
int baseExpire = 3600;
int randomExpire = new Random().nextInt(300); // 0-5 分钟随机
redis.setex(key, baseExpire + randomExpire, value);2. Redis 高可用
- 主从复制 + 哨兵模式
- Redis Cluster 集群模式
3. 多级缓存
Client → 本地缓存(Caffeine) → Redis → MySQLjava
/**
* 多级缓存方案
* @author yjhu
*/
@Autowired
private Cache<Long, User> localCache; // Caffeine
public User getById(Long id) {
// L1: 本地缓存
User user = localCache.getIfPresent(id);
if (user != null) {
return user;
}
// L2: Redis
String key = "user:" + id;
String json = redis.get(key);
if (json != null) {
user = JSON.parseObject(json, User.class);
localCache.put(id, user);
return user;
}
// L3: MySQL
user = userMapper.selectById(id);
if (user != null) {
redis.setex(key, 3600, JSON.toJSONString(user));
localCache.put(id, user);
}
return user;
}三种问题对比
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 缓存穿透 | 请求不存在的数据 | 缓存空值、布隆过滤器 |
| 缓存击穿 | 热点数据过期 | 互斥锁、逻辑过期 |
| 缓存雪崩 | 大量缓存同时过期 | 过期时间随机化、多级缓存、高可用 |
