Redis 缓存三大问题与解决方案
缓存是提升系统性能的关键技术,但引入缓存也带来了缓存穿透、缓存击穿、缓存雪崩三大问题。本文深入剖析这三大问题的成因、危害,以及布隆过滤器、互斥锁、永不过期等解决方案。
一、缓存架构概述
1.1 缓存模式
Write-Behind
写请求
写缓存
异步写数据库
Write-Through
写请求
写缓存
写数据库
Cache-Aside (旁路缓存)
是
否
读请求
缓存命中?
返回缓存
查数据库
写入缓存
写请求
写数据库
删除缓存
1.2 三大问题概述
缓存雪崩
大量 key 同时过期
或 Redis 宕机
数据库压力过大
缓存击穿
热点 key 过期
大量并发请求
同时查询数据库
缓存穿透
查询不存在的数据
缓存没有,数据库也没有
每次请求都打到数据库
二、缓存穿透
2.1 问题成因
影响
数据库压力增大
响应时间变长
甚至数据库宕机
问题场景
恶意请求 / 爬虫
查询不存在的 ID
缓存无数据
数据库也无数据
返回空
未写入缓存
再次查询
2.2 解决方案:布隆过滤器
特点
空间效率高
查询效率 O(k)
不支持删除
有误判率(可接受)
布隆过滤器原理
是
否
添加元素
计算 k 个哈希函数
将 k 个位置设为 1
查询元素
计算 k 个哈希函数
检查 k 个位置
都为 1?
可能存在
一定不存在
2.3 布隆过滤器实现
java
// Guava 实现布隆过滤器
public class BloomFilterDemo {
private static BloomFilter<Long> bloomFilter =
BloomFilter.create(
Funnels.longFunnel(),
1000000, // 预期插入数量
0.01 // 误判率
);
// 初始化时加载所有存在的 ID
public void init() {
List<Long> allIds = userMapper.selectAllIds();
for (Long id : allIds) {
bloomFilter.put(id);
}
}
// 查询时先检查布隆过滤器
public User getUser(Long id) {
// 布隆过滤器判断
if (!bloomFilter.mightContain(id)) {
return null; // 一定不存在,直接返回
}
// 可能存在,查缓存
User user = cache.get(id);
if (user != null) {
return user;
}
// 查数据库
user = userMapper.selectById(id);
if (user != null) {
cache.put(id, user);
}
return user;
}
}
2.4 Redisson 实现布隆过滤器
java
// Redisson 布隆过滤器
@Service
public class UserService {
@Autowired
private RedissonClient redissonClient;
private RBloomFilter<Long> bloomFilter;
@PostConstruct
public void init() {
// 创建布隆过滤器
bloomFilter = redissonClient.getBloomFilter("user:bloom");
bloomFilter.tryInit(1000000, 0.01); // 100万数据,1%误判率
}
public User getUser(Long id) {
if (!bloomFilter.contains(id)) {
return null;
}
// ... 正常查询逻辑
return userMapper.selectById(id);
}
// 用户创建时添加到布隆过滤器
public void createUser(User user) {
userMapper.insert(user);
bloomFilter.add(user.getId());
}
}
2.5 其他穿透解决方案
解决方案
布隆过滤器
拦截不存在的数据
缓存空值
将空结果也缓存
设置短过期时间
参数校验
过滤非法参数
ID 必须为正整数
java
// 方案2:缓存空值
public User getUser(Long id) {
// 先查缓存
User user = cache.get("user:" + id);
if (user != null) {
if (user == NULL_USER) {
return null; // 空值缓存
}
return user;
}
// 查数据库
user = userMapper.selectById(id);
if (user == null) {
// 缓存空值,过期时间短一些
cache.put("user:" + id, NULL_USER, Duration.ofMinutes(5));
return null;
}
cache.put("user:" + id, user);
return user;
}
三、缓存击穿
3.1 问题成因
问题场景
热点 key 存在
缓存过期瞬间
大量并发请求
同时查询数据库
数据库压力剧增
3.2 解决方案:互斥锁
互斥锁流程
是
否
请求查询 key
获取互斥锁?
查数据库
等待后重试
再次查询缓存
写入缓存
释放锁
返回结果
java
// 互斥锁实现
public User getUserWithLock(Long id) {
String cacheKey = "user:" + id;
String lockKey = "lock:user:" + id;
// 先查缓存
User user = cache.get(cacheKey);
if (user != null) {
return user;
}
// 获取互斥锁
String token = UUID.randomUUID().toString();
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, token, Duration.ofSeconds(30));
if (!locked) {
// 获取锁失败,短暂等待后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getUserWithLock(id); // 递归重试
}
try {
// 获取锁成功,再查一次缓存
user = cache.get(cacheKey);
if (user != null) {
return user;
}
// 查数据库
user = userMapper.selectById(id);
// 写入缓存
if (user != null) {
cache.put(cacheKey, user, Duration.ofHours(1));
}
return user;
} finally {
// 释放锁(使用 Lua 脚本保证原子性)
releaseLock(lockKey, token);
}
}
3.3 解决方案:永不过期
实现逻辑
是
否
查询时检查是否逻辑过期
逻辑过期?
开启异步线程重建缓存
返回缓存数据
返回旧数据
永不过期方案
缓存 value 中存储过期时间
逻辑过期字段
后台线程定期刷新
java
// 永不过期方案
public class UserCache {
@Data
public static class CacheUser {
private User user;
private long expireTime; // 逻辑过期时间
}
public User getUser(Long id) {
String cacheKey = "user:" + id;
CacheUser cacheUser = cache.get(cacheKey);
if (cacheUser == null) {
// 缓存不存在,加载并写入
User user = loadFromDb(id);
CacheUser newCacheUser = new CacheUser();
newCacheUser.setUser(user);
newCacheUser.setExpireTime(System.currentTimeMillis() + 30 * 60 * 1000);
cache.put(cacheKey, newCacheUser);
return user;
}
// 逻辑过期检查
if (System.currentTimeMillis() > cacheUser.getExpireTime()) {
// 异步刷新缓存
threadPool.execute(() -> {
User user = loadFromDb(id);
CacheUser newCacheUser = new CacheUser();
newCacheUser.setUser(user);
newCacheUser.setExpireTime(System.currentTimeMillis() + 30 * 60 * 1000);
cache.put(cacheKey, newCacheUser);
});
}
return cacheUser.getUser();
}
}
3.4 Redisson 分布式锁
java
// 使用 Redisson 实现
public User getUser(Long id) {
String cacheKey = "user:" + id;
RLock lock = redissonClient.getLock("lock:user:" + id);
try {
lock.lock(10, TimeUnit.SECONDS);
// 查缓存
User user = cache.get(cacheKey);
if (user != null) {
return user;
}
// 查数据库
user = userMapper.selectById(id);
if (user != null) {
cache.put(cacheKey, user);
}
return user;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
四、缓存雪崩
4.1 问题成因
Redis宕机
Redis 故障
所有请求打到数据库
数据库崩溃
大面积雪崩
大量 key 同时过期
大量请求同时涌入
数据库压力过大
服务不可用
4.2 解决方案:过期时间随机化
解决方案
过期时间随机化
基础时间 + 随机偏移
避免同时过期
热点数据永不过期
逻辑过期
后台异步刷新
java
// 过期时间随机化
public void setCache(String key, Object value, long baseExpireSeconds) {
// 基础时间 + 随机偏移
long expireSeconds = baseExpireSeconds +
(long) (Math.random() * baseExpireSeconds);
cache.put(key, value, Duration.ofSeconds(expireSeconds));
}
// 示例
setCache("user:1", user, 3600); // 3600 + 0~3600 = 3600~7200 秒
4.3 解决方案:多级缓存
特点
本地缓存: 最快,无网络开销
Redis: 分布式共享
MySQL: 最终数据源
多级缓存架构
本地缓存 (Guava/Caffeine)
分布式缓存 (Redis)
数据库 (MySQL)
java
// 多级缓存实现
@Service
public class MultiLevelCache {
// 一级:本地缓存
private Cache<Long, User> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.build();
// 二级:分布式缓存
@Autowired
private RedisTemplate<String, User> redisTemplate;
public User getUser(Long id) {
// 先查本地缓存
User user = localCache.getIfPresent(id);
if (user != null) {
return user;
}
// 查分布式缓存
String key = "user:" + id;
user = redisTemplate.opsForValue().get(key);
if (user != null) {
// 写入本地缓存
localCache.put(id, user);
return user;
}
// 查数据库
user = userMapper.selectById(id);
if (user != null) {
// 写入两级缓存
localCache.put(id, user);
redisTemplate.opsForValue().set(key, user, Duration.ofHours(1));
}
return user;
}
}
4.4 解决方案:限流降级
降级策略
服务降级
返回默认值
返回友好提示
使用兜底数据
限流策略
计数器限流
滑动窗口
令牌桶
漏桶算法
java
// 限流注解
@Aspect
@Component
public class RateLimitAspect {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Around("@annotation(RateLimit)")
public Object rateLimit(ProceedingJoinPoint point, RateLimit rateLimit)
throws Throwable {
String key = "rate:" + rateLimit.value() + ":" + getUserId();
long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, Duration.ofSeconds(rateLimit.window()));
}
if (count > rateLimit.maxRequests()) {
throw new BusinessException("请求过于频繁,请稍后重试");
}
return point.proceed();
}
}
// 使用
@RateLimit(value = "user:get", maxRequests = 100, window = 60)
public User getUser(Long id) {
return userService.getUser(id);
}
4.5 解决方案:Redis 高可用
高可用架构
Redis Sentinel
自动故障转移
一主多从
Redis Cluster
数据分片
多主多从
五、综合解决方案
5.1 完整方案设计
各层职责
过滤非法请求
拦截不存在数据
热点数据本地缓存
重建缓存互斥
保护数据库
防护层次
第一层:参数校验
第二层:布隆过滤器
第三层:多级缓存
第四层:互斥锁
第五层:限流降级
5.2 代码实现
java
@Service
public class UserService {
// 布隆过滤器
@Autowired
private RBloomFilter<Long> bloomFilter;
// 本地缓存
private Cache<Long, User> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(30, TimeUnit.SECONDS)
.build();
// 分布式缓存
@Autowired
private RedissonClient redissonClient;
@Autowired
private UserMapper userMapper;
public User getUser(Long id) {
// 1. 参数校验
if (id == null || id <= 0) {
return null;
}
// 2. 布隆过滤器检查
if (!bloomFilter.contains(id)) {
return null;
}
// 3. 本地缓存
User user = localCache.getIfPresent(id);
if (user != null) {
return user;
}
// 4. 获取分布式锁
String lockKey = "lock:user:" + id;
RLock lock = redissonClient.getLock(lockKey);
try {
lock.lock(10, TimeUnit.SECONDS);
// 5. 双重检查本地缓存
user = localCache.getIfPresent(id);
if (user != null) {
return user;
}
// 6. 查分布式缓存
String cacheKey = "user:" + id;
user = redisTemplate.opsForValue().get(cacheKey);
if (user != null) {
localCache.put(id, user);
return user;
}
// 7. 查数据库
user = userMapper.selectById(id);
if (user != null) {
// 8. 写入缓存
localCache.put(id, user);
redisTemplate.opsForValue().set(cacheKey, user,
Duration.ofHours(1) + Duration.ofSeconds(random.nextInt(3600)));
}
return user;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
六、面试高频问题
6.1 什么是缓存穿透?如何解决?
问题:
- 查询不存在的数据
- 缓存没有,数据库也没有
- 每次请求都打到数据库
解决方案:
1. 布隆过滤器
- 使用多个哈希函数映射到位数组
- 判断不存在一定不存在,判断存在可能误判
2. 缓存空值
- 将空结果也缓存
- 设置较短的过期时间
3. 参数校验
- 过滤非法参数
6.2 什么是缓存击穿?如何解决?
问题:
- 热点 key 过期瞬间
- 大量并发请求同时查询数据库
- 数据库压力剧增
解决方案:
1. 互斥锁
- 保证只有一个线程查数据库
- 其他线程等待后从缓存获取
2. 永不过期
- value 中存储过期时间
- 逻辑过期,后台异步刷新
3. 预热
- 热点数据提前加载
6.3 什么是缓存雪崩?如何解决?
问题:
- 大量 key 同时过期
- 或 Redis 宕机
- 数据库压力过大
解决方案:
1. 过期时间随机化
- 基础时间 + 随机偏移
2. 多级缓存
- 本地缓存 + Redis + 数据库
3. 限流降级
- 限制并发访问
- 服务降级返回默认值
4. Redis 高可用
- Sentinel / Cluster
6.4 布隆过滤器的误判率如何计算?
误判率公式:
P = (1 - e^(-kn/m))^k
其中:
- k: 哈希函数数量
- n: 插入元素数量
- m: 位数组长度
最优哈希函数数量:
k = (m/n) * ln(2)
示例:
- n = 1000000
- m = 10000000 (约 10M bits = 1.25MB)
- k = (10000000/1000000) * 0.693 ≈ 7
七、总结
7.1 三大问题对比
| 问题 | 成因 | 解决方案 |
|---|---|---|
| 穿透 | 查询不存在的数据 | 布隆过滤器、缓存空值 |
| 击穿 | 热点 key 过期瞬间 | 互斥锁、永不过期 |
| 雪崩 | 大量 key 同时过期 | 随机过期、多级缓存、限流 |
7.2 最佳实践
缓存最佳实践:
1. 穿透防护
✅ 使用布隆过滤器
✅ 缓存空值(短过期)
2. 击穿防护
✅ 使用互斥锁
✅ 永不过期 + 逻辑过期
3. 雪崩防护
✅ 过期时间随机化
✅ 多级缓存架构
✅ 限流降级
✅ Redis 高可用