问题
Redis 7 缓存穿透,缓存击穿,缓存雪崩问题
答案
一、核心概念
这三个问题是Redis缓存架构中最常见的高并发场景下的故障模式,都会导致大量请求直接打到数据库,造成数据库压力剧增甚至宕机。
| 问题类型 | 核心特征 | 典型场景 |
|---|---|---|
| 缓存穿透 | 查询不存在的数据 | 恶意攻击、业务漏洞 |
| 缓存击穿 | 热点key突然失效 | 秒杀、热点新闻 |
| 缓存雪崩 | 大量key同时失效 | 缓存集中过期、Redis宕机 |
二、缓存穿透(Cache Penetration)
2.1 问题原理
查询一个缓存和数据库中都不存在的数据,每次请求都会穿透缓存直达数据库。
请求 → Redis(未命中) → DB(查无数据) → 返回空
2.2 危害
- 恶意攻击者可以构造大量不存在的key,绕过缓存直接打垮数据库
- 缓存失去保护作用
2.3 解决方案
方案1:布隆过滤器(Bloom Filter)
@Component
public class BloomFilterCache {
private final RedissonClient redissonClient;
// 初始化布隆过滤器
public RBloomFilter<String> initBloomFilter() {
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("user:bloom");
// 预期元素数量100万,误判率1%
bloomFilter.tryInit(1000000L, 0.01);
// 将所有有效key加载到布隆过滤器
List<String> validKeys = loadAllValidKeysFromDB();
validKeys.forEach(bloomFilter::add);
return bloomFilter;
}
public User getUser(String userId) {
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("user:bloom");
// 先判断key是否可能存在
if (!bloomFilter.contains(userId)) {
return null; // 一定不存在,直接返回
}
// 可能存在,继续查询缓存和数据库
return queryFromCacheOrDB(userId);
}
}
方案2:缓存空值
public User getUserWithNullCache(String userId) {
// 1. 查询Redis
String cacheKey = "user:" + userId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return "NULL".equals(cached) ? null : JSON.parseObject(cached, User.class);
}
// 2. 查询数据库
User user = userMapper.selectById(userId);
// 3. 缓存结果(包括空值)
if (user == null) {
// 缓存空值,设置较短过期时间(5分钟)
redisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
} else {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), 30, TimeUnit.MINUTES);
}
return user;
}
方案3:参数校验
// 在接口层进行参数合法性校验
public User getUser(String userId) {
// 校验参数格式
if (!isValidUserId(userId)) {
throw new IllegalArgumentException("Invalid userId format");
}
// 限制查询频率(结合限流)
if (!rateLimiter.tryAcquire()) {
throw new TooManyRequestsException();
}
return queryUser(userId);
}
三、缓存击穿(Cache Breakdown/Hotspot Invalid)
3.1 问题原理
某个热点key在高并发访问时突然失效,导致大量请求同时穿透到数据库。
时刻T: 热点key过期
时刻T+1ms: 10000个并发请求 → Redis(未命中) → 10000个DB查询
3.2 解决方案
方案1:互斥锁(Mutex Lock)
public User getUserWithMutex(String userId) {
String cacheKey = "user:" + userId;
String lockKey = "lock:user:" + userId;
// 1. 查询缓存
User user = getFromCache(cacheKey);
if (user != null) {
return user;
}
// 2. 缓存未命中,尝试获取分布式锁
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁,最多等待10秒,锁自动释放时间30秒
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
try {
// 双重检查:再次查询缓存(可能其他线程已经加载)
user = getFromCache(cacheKey);
if (user != null) {
return user;
}
// 查询数据库
user = userMapper.selectById(userId);
// 写入缓存
if (user != null) {
redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);
}
return user;
} finally {
lock.unlock();
}
} else {
// 获取锁失败,等待后重试或返回降级数据
Thread.sleep(50);
return getFromCache(cacheKey);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Lock interrupted", e);
}
}
方案2:逻辑过期(热点数据永不过期)
@Data
public class CacheData<T> {
private T data;
private LocalDateTime expireTime; // 逻辑过期时间
}
public User getUserWithLogicalExpire(String userId) {
String cacheKey = "user:" + userId;
// 1. 查询缓存(物理上永不过期)
CacheData<User> cacheData = redisTemplate.opsForValue().get(cacheKey);
if (cacheData == null) {
return null; // 缓存未命中
}
// 2. 判断逻辑过期时间
if (cacheData.getExpireTime().isAfter(LocalDateTime.now())) {
return cacheData.getData(); // 未过期,直接返回
}
// 3. 已过期,尝试获取锁进行重建
String lockKey = "lock:rebuild:" + userId;
if (tryLock(lockKey)) {
// 开启独立线程异步重建缓存
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
User user = userMapper.selectById(userId);
CacheData<User> newData = new CacheData<>();
newData.setData(user);
newData.setExpireTime(LocalDateTime.now().plusMinutes(30));
redisTemplate.opsForValue().set(cacheKey, newData);
} finally {
unlock(lockKey);
}
});
}
// 4. 返回旧数据(保证可用性)
return cacheData.getData();
}
方案3:热点数据预热
@Component
public class CacheWarmUp {
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点执行
public void warmUpHotKeys() {
// 从数据库或日志分析系统获取热点key列表
List<String> hotKeys = getHotKeysFromAnalytics();
hotKeys.forEach(userId -> {
User user = userMapper.selectById(userId);
if (user != null) {
String cacheKey = "user:" + userId;
// 设置较长过期时间,并添加随机值防止同时过期
int expireTime = 3600 + RandomUtils.nextInt(0, 300);
redisTemplate.opsForValue().set(cacheKey, user, expireTime, TimeUnit.SECONDS);
}
});
}
}
四、缓存雪崩(Cache Avalanche)
4.1 问题原理
大量缓存key在同一时间失效,或者Redis服务宕机,导致所有请求瞬间打到数据库。
4.2 解决方案
方案1:过期时间打散
public void setCacheWithRandomExpire(String key, Object value, int baseExpireSeconds) {
// 在基础过期时间上增加随机值(±20%)
int randomRange = (int) (baseExpireSeconds * 0.2);
int finalExpire = baseExpireSeconds + RandomUtils.nextInt(-randomRange, randomRange);
redisTemplate.opsForValue().set(key, value, finalExpire, TimeUnit.SECONDS);
}
// 批量设置缓存
public void batchSetCache(Map<String, User> userMap) {
userMap.forEach((userId, user) -> {
String cacheKey = "user:" + userId;
// 基础30分钟,实际过期时间在24-36分钟之间
setCacheWithRandomExpire(cacheKey, user, 1800);
});
}
方案2:Redis高可用架构
# Redis Cluster 或 哨兵模式配置
spring:
redis:
cluster:
nodes:
- 192.168.1.101:6379
- 192.168.1.102:6379
- 192.168.1.103:6379
max-redirects: 3
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 2
方案3:多级缓存架构
@Component
public class MultiLevelCache {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 本地缓存(Caffeine)
private final Cache<String, User> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
public User getUser(String userId) {
String cacheKey = "user:" + userId;
// 1. 查询本地缓存
User user = localCache.getIfPresent(cacheKey);
if (user != null) {
return user;
}
// 2. 查询Redis
user = (User) redisTemplate.opsForValue().get(cacheKey);
if (user != null) {
localCache.put(cacheKey, user);
return user;
}
// 3. 查询数据库
user = userMapper.selectById(userId);
if (user != null) {
// 同时更新两级缓存
localCache.put(cacheKey, user);
redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);
}
return user;
}
}
方案4:限流降级
@RestController
public class UserController {
@Autowired
private RateLimiter rateLimiter;
@GetMapping("/user/{id}")
@SentinelResource(value = "getUser",
blockHandler = "handleBlock",
fallback = "handleFallback")
public Result<User> getUser(@PathVariable String id) {
// 限流保护
if (!rateLimiter.tryAcquire()) {
return Result.error("系统繁忙,请稍后重试");
}
User user = userService.getUser(id);
return Result.success(user);
}
// 降级处理
public Result<User> handleFallback(String id, Throwable ex) {
// 返回默认数据或提示
return Result.error("服务暂时不可用");
}
}
五、Redis 7 新特性支持
Redis 7 提供了一些新特性来更好地应对这些问题:
- Client-side caching:客户端缓存,减少网络往返
- Functions:替代Lua脚本,更好的原子性操作支持
- ACL增强:更细粒度的权限控制,防止恶意访问
// Redis 7 客户端缓存示例(Lettuce 6.2+)
RedisClient client = RedisClient.create("redis://localhost:6379");
StatefulRedisConnection<String, String> connection = client.connect();
// 启用客户端缓存
connection.setClientName("cache-client");
CacheFrontend<String, String> frontend = ClientSideCaching.enable(
CacheAccessor.forMap(new ConcurrentHashMap<>()),
connection,
TrackingArgs.Builder.enabled()
);
六、答题总结
面试时可以按照以下思路回答:
- 先区分三个概念:穿透(不存在的数据)、击穿(热点key失效)、雪崩(大量key失效)
- 说明危害:都会导致数据库压力剧增
- 针对性方案:
- 穿透 → 布隆过滤器 + 缓存空值
- 击穿 → 互斥锁 + 逻辑过期
- 雪崩 → 过期时间打散 + 高可用 + 多级缓存
- 补充:结合限流降级、监控告警形成完整的高可用方案
关键点:这三个问题的本质都是缓存失效导致数据库承压,解决思路是减少失效概率 + 控制并发量 + 提升系统容错能力。