Redis 缓存击穿、雪崩、穿透:面试高频详解
一、核心概念对比
| 现象 | 描述 | 原因 | 影响 | 解决方案 |
|---|---|---|---|---|
| 缓存穿透 | 查询不存在的数据 | 恶意攻击或误操作 | 直接打到数据库 | 布隆过滤器、空值缓存 |
| 缓存击穿 | 热点key过期瞬间 | 高并发访问同一个key | 数据库瞬时压力 | 互斥锁、永不过期 |
| 缓存雪崩 | 大量key同时失效 | 缓存大面积失效 | 数据库压力过大 | 过期时间随机、集群高可用 |
二、缓存穿透(Cache Penetration)
1. 问题定义
- 查询一个数据库中根本不存在的数据
- 缓存无法命中,每次请求都直接访问数据库
- 恶意攻击者可能利用此漏洞攻击系统
2. 场景示例
// 恶意攻击:查询不存在的ID
public User getUserById(Long id) {
// 1. 先查缓存
String key = "user:" + id;
String value = redis.get(key);
if (value != null) {
return JSON.parseObject(value, User.class);
}
// 2. 缓存没有,查数据库
User user = userDao.findById(id);
if (user != null) {
// 3. 写入缓存
redis.setex(key, 3600, JSON.toJSONString(user));
}
return user; // 如果user为null,每次都会查数据库
}
3. 解决方案
方案1:布隆过滤器(Bloom Filter)
// 初始化布隆过滤器
public class BloomFilterService {
private BloomFilter<String> bloomFilter;
public BloomFilterService() {
// 预计元素数量1000万,误判率0.01%
bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
10_000_000,
0.0001
);
// 预热:加载所有存在的key
List<String> allKeys = userDao.findAllIds();
for (String key : allKeys) {
bloomFilter.put("user:" + key);
}
}
public boolean mightContain(String key) {
return bloomFilter.mightContain(key);
}
}
// 使用
public User getUserById(Long id) {
String key = "user:" + id;
// 1. 布隆过滤器判断
if (!bloomFilter.mightContain(key)) {
return null; // 肯定不存在
}
// 2. 正常流程...
}
方案2:空值缓存
public User getUserById(Long id) {
String key = "user:" + id;
// 1. 查缓存
String value = redis.get(key);
if (value != null) {
if ("NULL".equals(value)) {
return null; // 空值缓存
}
return JSON.parseObject(value, User.class);
}
// 2. 查数据库
User user = userDao.findById(id);
if (user != null) {
// 3. 正常缓存
redis.setex(key, 3600, JSON.toJSONString(user));
} else {
// 4. 缓存空值(短时间)
redis.setex(key, 300, "NULL"); // 5分钟
}
return user;
}
方案3:接口层校验
// 1. 参数校验
public User getUserById(@NotNull @Min(1) Long id) {
// 2. 正则校验
if (!Pattern.matches("^\\d+$", id.toString())) {
return null;
}
// 3. 范围校验
if (id > 100000000L) {
return null; // ID超出范围
}
// 正常流程...
}
三、缓存击穿(Cache Breakdown)
1. 问题定义
- 某个热点key突然失效
- 大量并发请求同时访问数据库
- 数据库瞬时压力剧增
2. 场景示例
// 商品秒杀场景
public Product getProductDetail(Long productId) {
String key = "hot_product:" + productId;
// 热点商品缓存失效
String value = redis.get(key);
if (value == null) { // 缓存失效
// 大量请求同时进入这里,查询数据库
Product product = productDao.findById(productId);
redis.setex(key, 3600, JSON.toJSONString(product));
return product;
}
return JSON.parseObject(value, Product.class);
}
3. 解决方案
方案1:互斥锁(Mutex Lock)
public Product getProductDetailWithLock(Long productId) {
String cacheKey = "product:" + productId;
// 1. 尝试从缓存获取
String data = redis.get(cacheKey);
if (data != null) {
return JSON.parseObject(data, Product.class);
}
// 2. 获取分布式锁
String lockKey = "lock:product:" + productId;
String requestId = UUID.randomUUID().toString();
try {
// 尝试获取锁
boolean locked = redis.setnx(lockKey, requestId, 10); // 10秒超时
if (locked) {
try {
// 3. 再次检查缓存(双重检查)
data = redis.get(cacheKey);
if (data != null) {
return JSON.parseObject(data, Product.class);
}
// 4. 查询数据库
Product product = productDao.findById(productId);
if (product != null) {
// 5. 写入缓存
redis.setex(cacheKey, 3600, JSON.toJSONString(product));
}
return product;
} finally {
// 释放锁
if (requestId.equals(redis.get(lockKey))) {
redis.del(lockKey);
}
}
} else {
// 6. 未获取到锁,等待后重试
Thread.sleep(50);
return getProductDetailWithLock(productId);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return productDao.findById(productId);
}
}
方案2:逻辑过期
// 缓存数据结构
class CacheData {
private Object data; // 真实数据
private long expireTime; // 逻辑过期时间
// getters and setters
}
public Product getProductDetailWithLogicExpire(Long productId) {
String key = "product:" + productId;
// 1. 从缓存获取
String json = redis.get(key);
if (json == null) {
// 缓存没有,直接查数据库
Product product = productDao.findById(productId);
if (product != null) {
// 异步更新缓存
redis.setex(key, 3600, JSON.toJSONString(product));
}
return product;
}
// 2. 反序列化
CacheData cacheData = JSON.parseObject(json, CacheData.class);
Product product = (Product) cacheData.getData();
// 3. 判断是否逻辑过期
if (cacheData.getExpireTime() <= System.currentTimeMillis()) {
// 已过期,异步更新
CompletableFuture.runAsync(() -> {
updateProductCache(productId);
});
}
return product;
}
// 异步更新缓存
private void updateProductCache(Long productId) {
String lockKey = "refresh:product:" + productId;
// 获取锁,防止重复更新
if (redis.setnx(lockKey, "1", 30)) { // 锁30秒
try {
Product product = productDao.findById(productId);
if (product != null) {
CacheData cacheData = new CacheData();
cacheData.setData(product);
cacheData.setExpireTime(System.currentTimeMillis() + 3600 * 1000);
redis.set("product:" + productId, JSON.toJSONString(cacheData));
}
} finally {
redis.del(lockKey);
}
}
}
方案3:热点key永不过期
public class HotKeyManager {
private static final Set<String> HOT_KEYS = new ConcurrentHashSet<>();
// 监控热点key
public void monitorHotKey(String key) {
// 1. 计数器
Long count = redis.incr("counter:" + key, 1);
redis.expire("counter:" + key, 60); // 60秒窗口
// 2. 超过阈值标记为热点
if (count > 1000) { // 阈值
HOT_KEYS.add(key);
// 3. 取消过期时间
redis.persist(key);
// 4. 启动后台线程定期更新
startRefreshTask(key);
}
}
private void startRefreshTask(String key) {
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
// 异步更新缓存
refreshHotKey(key);
}, 0, 30, TimeUnit.SECONDS); // 每30秒更新一次
}
}
四、缓存雪崩(Cache Avalanche)
1. 问题定义
- 大量缓存key在同一时间失效
- 所有请求直接访问数据库
- 数据库压力过大,可能导致宕机
2. 场景示例
// 错误的设置方式:所有key同时过期
public void initCache() {
List<Product> products = productDao.findAll();
for (Product product : products) {
String key = "product:" + product.getId();
// 所有key都设置相同过期时间
redis.setex(key, 3600, JSON.toJSONString(product));
}
// 1小时后,所有缓存同时失效!
}
3. 解决方案
方案1:过期时间随机化
public void setCacheWithRandomExpire(String key, Object value) {
// 基础过期时间 + 随机时间
int baseExpire = 3600; // 1小时
int randomRange = 600; // 10分钟
// 生成随机过期时间
int expireTime = baseExpire +
new Random().nextInt(randomRange * 2) - randomRange;
redis.setex(key, expireTime, JSON.toJSONString(value));
}
// 批量设置缓存
public void initCacheWithRandomExpire() {
List<Product> products = productDao.findAll();
for (Product product : products) {
String key = "product:" + product.getId();
setCacheWithRandomExpire(key, product);
}
}
方案2:缓存预热
@Component
public class CacheWarmUp {
@PostConstruct
public void warmUp() {
// 1. 服务启动时预热
CompletableFuture.runAsync(this::loadHotData);
}
@Scheduled(cron = "0 0 3 * * ?") // 每天凌晨3点执行
public void scheduledWarmUp() {
loadHotData();
}
private void loadHotData() {
// 2. 加载热点数据
List<Product> hotProducts = productDao.findHotProducts();
for (Product product : hotProducts) {
String key = "product:" + product.getId();
// 设置不同的过期时间
int expireTime = 3600 + new Random().nextInt(1800);
redis.setex(key, expireTime, JSON.toJSONString(product));
}
}
}
方案3:多级缓存架构
public class MultiLevelCache {
// 一级缓存:本地缓存(Caffeine)
private Cache<String, Object> localCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(10000)
.build();
// 二级缓存:Redis集群
private RedisTemplate<String, Object> redisTemplate;
public Object get(String key) {
// 1. 查本地缓存
Object value = localCache.getIfPresent(key);
if (value != null) {
return value;
}
// 2. 查Redis
value = redisTemplate.opsForValue().get(key);
if (value != null) {
// 3. 写入本地缓存
localCache.put(key, value);
return value;
}
// 3. 查数据库
value = loadFromDB(key);
if (value != null) {
// 异步更新缓存
CompletableFuture.runAsync(() -> {
redisTemplate.opsForValue().set(key, value,
getRandomExpire(), TimeUnit.SECONDS);
});
localCache.put(key, value);
}
return value;
}
}
方案4:服务降级和熔断
@Component
public class CacheServiceWithFallback {
// 使用Hystrix或Sentinel实现熔断
@HystrixCommand(fallbackMethod = "getFromDBWithLimit")
public Product getProduct(Long productId) {
String key = "product:" + productId;
// 1. 查缓存
Product product = redis.get(key);
if (product == null) {
// 2. 查数据库
product = productDao.findById(productId);
redis.setex(key, 3600, product);
}
return product;
}
// 降级方法:限制数据库查询
public Product getFromDBWithLimit(Long productId) {
// 使用信号量限制并发
if (!semaphore.tryAcquire()) {
throw new RuntimeException("系统繁忙,请稍后重试");
}
try {
return productDao.findById(productId);
} finally {
semaphore.release();
}
}
}
五、高级解决方案
1. Redis集群高可用
# Redis哨兵模式配置
spring:
redis:
sentinel:
master: mymaster
nodes:
- 192.168.1.1:26379
- 192.168.1.2:26379
- 192.168.1.3:26379
2. 多级缓存架构
客户端请求
↓
Nginx缓存(静态资源)
↓
应用层本地缓存(Caffeine/Guava)
↓
分布式缓存(Redis Cluster)
↓
数据库
3. 监控和报警系统
@Component
public class CacheMonitor {
// 监控缓存命中率
@Scheduled(fixedRate = 60000) // 每分钟
public void monitorHitRate() {
long hitCount = getHitCount();
long missCount = getMissCount();
double hitRate = (double) hitCount / (hitCount + missCount);
if (hitRate < 0.8) { // 命中率低于80%
// 发送报警
sendAlert("缓存命中率过低:" + hitRate);
}
}
// 监控缓存雪崩风险
public void monitorExpireKeys() {
// 扫描即将过期的key
Set<String> keys = redis.keys("product:*");
Map<Long, Integer> expireCountMap = new HashMap<>();
for (String key : keys) {
Long ttl = redis.ttl(key);
if (ttl != null && ttl < 300) { // 5分钟内过期
long timeSlot = ttl / 60; // 按分钟分组
expireCountMap.merge(timeSlot, 1, Integer::sum);
}
}
// 检查是否有大量key同时过期
for (Map.Entry<Long, Integer> entry : expireCountMap.entrySet()) {
if (entry.getValue() > 1000) { // 同一分钟超过1000个key过期
sendAlert("缓存雪崩风险:" + entry.getKey() + "分钟");
}
}
}
}
六、面试高频问题
Q1:缓存穿透、击穿、雪崩的区别是什么?
参考答案:
1. 缓存穿透:查不存在的数据,缓存和数据库都没有
- 原因:恶意攻击、误操作
- 解决:布隆过滤器、空值缓存
2. 缓存击穿:热点key突然失效
- 原因:单个key过期,高并发访问
- 解决:互斥锁、永不过期、逻辑过期
3. 缓存雪崩:大量key同时失效
- 原因:缓存服务宕机、相同过期时间
- 解决:过期时间随机、多级缓存、集群高可用
Q2:布隆过滤器的原理和优缺点?
参考答案:
原理:
1. 使用多个哈希函数映射到位数组
2. 插入时,将多个位置设为1
3. 查询时,所有位置都为1才认为可能存在
优点:
1. 空间效率极高
2. 查询时间O(k),k为哈希函数数量
缺点:
1. 存在误判率(假阳性)
2. 不能删除元素
3. 需要预先知道数据规模
Q3:如何选择互斥锁和逻辑过期方案?
参考答案:
选择依据:
使用互斥锁的场景:
1. 数据一致性要求高
2. 并发量不是特别大
3. 对性能要求不是极致
使用逻辑过期的场景:
1. 热点数据,访问频繁
2. 允许短暂的数据不一致
3. 对性能要求高
实际项目中可以结合使用:
1. 对关键数据使用互斥锁
2. 对热点数据使用逻辑过期
3. 配合监控系统动态调整
Q4:如何监控和预防缓存雪崩?
参考答案:
监控指标:
1. 缓存命中率
2. Redis内存使用率
3. 数据库QPS
4. Key过期时间分布
预防措施:
1. 设计阶段:设置随机过期时间
2. 部署阶段:Redis集群+哨兵
3. 运行阶段:缓存预热+多级缓存
4. 应急方案:服务降级+熔断
报警机制:
1. 命中率低于阈值
2. 大量key同时过期
3. 数据库连接数突增
4. Redis集群节点故障
七、实战代码示例
完整的缓存工具类
@Component
public class CacheUtil {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RedissonClient redissonClient;
// 解决缓存穿透
public <T> T getWithPenetrationProtection(String key,
Class<T> clazz,
Supplier<T> dbLoader,
int expireSeconds) {
// 1. 尝试从缓存获取
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
// 空值处理
if ("NULL".equals(value)) {
return null;
}
return JSON.parseObject((String) value, clazz);
}
// 2. 获取分布式锁
RLock lock = redissonClient.getLock("lock:" + key);
try {
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
try {
// 3. 双重检查
value = redisTemplate.opsForValue().get(key);
if (value != null) {
if ("NULL".equals(value)) {
return null;
}
return JSON.parseObject((String) value, clazz);
}
// 4. 查询数据库
T data = dbLoader.get();
if (data != null) {
// 设置随机过期时间,防止雪崩
int randomExpire = expireSeconds +
new Random().nextInt(600) - 300;
redisTemplate.opsForValue().set(
key,
JSON.toJSONString(data),
randomExpire,
TimeUnit.SECONDS
);
} else {
// 空值缓存
redisTemplate.opsForValue().set(
key,
"NULL",
300, // 5分钟
TimeUnit.SECONDS
);
}
return data;
} finally {
lock.unlock();
}
} else {
// 等待后重试
Thread.sleep(100);
return getWithPenetrationProtection(key, clazz, dbLoader, expireSeconds);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取缓存失败", e);
}
}
// 热点key保护
public <T> T getHotKey(String key, Class<T> clazz, Supplier<T> dbLoader) {
// 逻辑过期方案
String value = (String) redisTemplate.opsForValue().get(key);
if (value == null) {
return dbLoader.get();
}
CacheWrapper<T> wrapper = JSON.parseObject(value,
new TypeReference<CacheWrapper<T>>() {});
// 检查是否逻辑过期
if (wrapper.getExpireTime() > System.currentTimeMillis()) {
return wrapper.getData();
}
// 过期,异步刷新
CompletableFuture.runAsync(() -> refreshHotKey(key, dbLoader));
return wrapper.getData();
}
private <T> void refreshHotKey(String key, Supplier<T> dbLoader) {
RLock lock = redissonClient.getLock("refresh:" + key);
if (lock.tryLock()) {
try {
T data = dbLoader.get();
if (data != null) {
CacheWrapper<T> wrapper = new CacheWrapper<>();
wrapper.setData(data);
wrapper.setExpireTime(System.currentTimeMillis() + 3600 * 1000);
redisTemplate.opsForValue().set(
key,
JSON.toJSONString(wrapper)
);
}
} finally {
lock.unlock();
}
}
}
// 缓存包装类
@Data
static class CacheWrapper<T> {
private T data;
private long expireTime;
}
}
八、最佳实践总结
-
设计阶段
- 合理设置过期时间(基础时间+随机偏移)
- 热点数据永不过期+异步更新
- 非热点数据设置较短过期时间
-
开发阶段
- 所有缓存操作都要有降级方案
- 使用连接池,避免频繁创建连接
- 大key拆分,避免阻塞
-
测试阶段
- 压力测试缓存系统
- 模拟缓存失效场景
- 验证降级熔断机制
-
运维阶段
- 监控缓存命中率
- 定期分析热点key
- 建立报警机制
-
应急方案
- 快速扩容Redis集群
- 启用本地缓存
- 数据库限流保护
随着技术发展,缓存防护也在不断演进:
- 智能化防护:AI预测热点数据,自动调整缓存策略
- 无服务化缓存:Serverless缓存服务,按需弹性伸缩
- 边缘缓存:CDN + 边缘计算,进一步降低延迟
- 统一缓存层:跨多种数据源和存储的统一缓存抽象
最后的思考
缓存不仅仅是性能优化的工具,更是系统稳定性的基石 。缓存穿透、击穿、雪崩这三个问题,表面上看起来是技术挑战,实际上考验的是我们对系统架构的深度理解 和工程实践能力。
记住:没有银弹,只有合适的方案。在实际工作中,我们需要:
- 深入理解业务特点:不同业务对一致性、可用性的要求不同
- 权衡各种方案的利弊:在性能、一致性、复杂度之间找到平衡点
- 建立持续优化的机制:缓存策略需要随着业务发展不断调整
- 培养系统性思维:缓存问题往往反映出整个系统架构的深层次问题
优秀的缓存设计,就像优秀的城市规划------不仅要解决当前的交通拥堵,更要预见未来的发展需求。希望通过本文的学习,你不仅掌握了解决缓存三大问题的具体技术方案,更重要的是建立了防御性设计的思维方式,能够在实际工作中构建出更加健壮、可靠的系统。