缓存是系统性能优化的一把利剑,但使用不当反而会“伤及自身”。今天我们就通过真实场景,彻底搞懂缓存穿透、击穿和雪崩这三个面试必考题。
先来看一个真实的电商场景
假设我们在开发一个电商网站,商品信息缓存在Redis中,数据库存储原始数据。
// 典型的缓存查询逻辑
public Product getProductById(Long id) {
// 1. 先从缓存查询
Product product = redis.get("product:" + id);
if (product != null) {
return product;
}
// 2. 缓存不存在,查询数据库
product = db.query("SELECT * FROM products WHERE id = ?", id);
if (product != null) {
// 3. 将查询结果写入缓存
redis.setex("product:" + id, 3600, product);
}
return product;
}
这个看似完美的流程,实则暗藏玄机!
一、缓存穿透:幽灵请求的攻击
场景还原
晚上10点,你正准备下班,突然收到数据库CPU 100%的告警。查看日志发现,大量请求在查询一个不存在的商品ID:-1。
// 恶意请求示例
for (int i = 0; i < 10000; i++) {
// 请求不存在的数据
http.get("/product/" + UUID.randomUUID());
}
问题分析
缓存穿透:查询一个根本不存在的数据,缓存和数据库都没有,每次请求都直接打到数据库。
危害程度:
- 数据库压力暴增,可能被压垮
- 系统响应变慢,正常请求受影响
解决方案
方案1:布隆过滤器(推荐)
// 初始化布隆过滤器
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
1000000, // 预期数据量
0.01 // 误判率
);
// 系统启动时加载所有存在的ID
List<Long> existIds = productDao.getAllIds();
for (Long id : existIds) {
bloomFilter.put("product:" + id);
}
// 查询时先检查布隆过滤器
public Product getProductById(Long id) {
String key = "product:" + id;
// 布隆过滤器判断
if (!bloomFilter.mightContain(key)) {
return null; // 肯定不存在,直接返回
}
// 后续查询逻辑...
return product;
}
方案2:缓存空对象
public Product getProductById(Long id) {
Product product = redis.get("product:" + id);
if (product != null) {
// 特殊标记的空对象
if (product.isEmptyObject()) {
return null;
}
return product;
}
product = db.query("SELECT * FROM products WHERE id = ?", id);
if (product != null) {
redis.setex("product:" + id, 3600, product);
} else {
// 缓存空对象,设置较短过期时间
redis.setex("product:" + id, 300, EmptyObject);
}
return product;
}
二、缓存击穿:热点数据的突然死亡
场景还原
某网红商品晚上8点做秒杀活动,缓存刚好在这个时间点过期。瞬间几万个请求同时涌向数据库:
// 并发场景下的问题
public class ProductService {
public Product getProduct(Long id) {
// 缓存刚好过期,大量请求同时到达
return getProductById(id);
}
}
问题分析
缓存击穿:某个热点key过期时,大量并发请求同时访问这个key,瞬间击穿缓存,直接访问数据库。
危害程度:
- 数据库瞬间压力巨大
- 可能引起连锁反应
解决方案
方案1:互斥锁(Mutex Lock)
public Product getProductWithLock(Long id) {
String cacheKey = "product:" + id;
Product product = redis.get(cacheKey);
if (product != null) {
return product;
}
// 获取分布式锁
String lockKey = "lock:product:" + id;
try {
if (redis.setnx(lockKey, "1", 10)) { // 获取锁
// 查询数据库
product = db.query("SELECT * FROM products WHERE id = ?", id);
if (product != null) {
redis.setex(cacheKey, 3600, product);
}
return product;
} else {
// 未获取到锁,稍后重试
Thread.sleep(50);
return getProductWithLock(id);
}
} finally {
redis.delete(lockKey); // 释放锁
}
}
方案2:逻辑过期时间
// 缓存数据包装类
@Data
public class RedisData {
private Object data;
private LocalDateTime expireTime; // 逻辑过期时间
}
public Product getProductWithLogicalExpire(Long id) {
String cacheKey = "product:" + id;
RedisData redisData = redis.get(cacheKey);
// 判断逻辑过期时间
if (redisData == null) {
// 缓存不存在,重新加载
return loadProductToCache(id);
}
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
// 未过期,直接返回
return (Product) redisData.getData();
} else {
// 已过期,异步更新
CompletableFuture.runAsync(() -> {
updateProductCache(id);
});
return (Product) redisData.getData(); // 返回旧数据
}
}
三、缓存雪崩:系统的多米诺骨牌
场景还原
凌晨2点,缓存集群中大量key同时过期,数据库连接池被占满,整个系统陷入瘫痪:
// 问题代码:同时设置一样的过期时间
public void initCache() {
List<Product> products = getAllProducts();
for (Product product : products) {
// 所有缓存设置一样的过期时间
redis.setex("product:" + product.getId(), 7200, product);
}
}
问题分析
缓存雪崩:大量缓存key在同一时间过期,导致所有请求直接访问数据库,引起数据库压力过大而宕机。
危害程度:
- 系统完全不可用
- 恢复困难,可能形成恶性循环
解决方案
方案1:随机过期时间
public void setProductCache(Product product) {
String key = "product:" + product.getId();
int baseExpire = 7200; // 基础过期时间2小时
int randomExpire = baseExpire + new Random().nextInt(1800); // 增加随机时间
redis.setex(key, randomExpire, product);
}
方案2:缓存永不过期 + 后台更新
// 缓存永不过期,通过后台线程更新
public void initCache() {
List<Product> products = getAllProducts();
for (Product product : products) {
redis.set("product:" + product.getId(), product); // 不设置过期时间
}
// 启动后台线程,定时更新缓存
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
updateAllProductCache();
}, 1, 1, TimeUnit.HOURS); // 每小时更新一次
}
方案3:多级缓存架构
public class MultiLevelCacheService {
// 本地缓存
private Cache<Long, Product> localCache =
Caffeine.newBuilder().expireAfterWrite(10, TimeUnit.MINUTES).build();
// Redis缓存
private RedisTemplate redisTemplate;
public Product getProduct(Long id) {
// 1. 查询本地缓存
Product product = localCache.getIfPresent(id);
if (product != null) {
return product;
}
// 2. 查询Redis缓存
product = (Product) redisTemplate.opsForValue().get("product:" + id);
if (product != null) {
localCache.put(id, product);
return product;
}
// 3. 查询数据库
product = db.query("SELECT * FROM products WHERE id = ?", id);
if (product != null) {
redisTemplate.opsForValue().set("product:" + id, product, 3600, TimeUnit.SECONDS);
localCache.put(id, product);
}
return product;
}
}
实战:综合防御方案
在实际项目中,我们需要综合运用多种方案:
@Component
public class CacheService {
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private ProductDao productDao;
// 布隆过滤器
private BloomFilter<String> bloomFilter;
@PostConstruct
public void init() {
// 初始化布隆过滤器
bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()), 1000000, 0.01);
// 加载存在的键
loadExistKeys();
}
public Product getProductSafely(Long id) {
String key = "product:" + id;
// 1. 布隆过滤器校验
if (!bloomFilter.mightContain(key)) {
return null;
}
// 2. 查询缓存
Product product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
if (product instanceof EmptyProduct) {
return null; // 空对象
}
return product;
}
// 3. 获取分布式锁
String lockKey = "lock:" + key;
try {
if (tryLock(lockKey)) {
// 4. 双重检查
product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
// 5. 查询数据库
product = productDao.findById(id);
if (product != null) {
// 6. 异步设置缓存,使用随机过期时间
setCacheWithRandomExpire(key, product);
} else {
// 缓存空对象
redisTemplate.opsForValue().set(key, new EmptyProduct(), 300, TimeUnit.SECONDS);
}
return product;
} else {
// 未获取到锁,重试
Thread.sleep(100);
return getProductSafely(id);
}
} finally {
releaseLock(lockKey);
}
}
private void setCacheWithRandomExpire(String key, Object value) {
int expire = 3600 + new Random().nextInt(600); // 随机过期时间
redisTemplate.opsForValue().set(key, value, expire, TimeUnit.SECONDS);
}
}
总结对比
|
问题类型 |
根本缘由 |
解决方案 |
适用场景 |
|
缓存穿透 |
查询不存在数据 |
1. 布隆过滤器 |
恶意攻击、参数校验 |
|
缓存击穿 |
热点key过期 |
1. 互斥锁 |
热点数据、秒杀场景 |
|
缓存雪崩 |
大量key同时过期 |
1. 随机过期时间 |
缓存预热、系统初始化 |
写在最后
记住这三兄弟的特点:
- 穿透:查无此”人”,布隆来挡
- 击穿:热点”阵亡”,加锁护航
- 雪崩:集体”下岗”,随机应变
掌握了这些解决方案,不仅能轻松应对面试,更能写出健壮的生产级代码。赶紧收藏起来,下次面试必定用得上!
思考题:你的项目中遇到过哪种缓存问题?是如何解决的呢?欢迎在评论区分享你的实战经验!
© 版权声明
文章版权归作者所有,未经允许请勿转载。如内容涉嫌侵权,请在本页底部进入<联系我们>进行举报投诉!
THE END


















- 最新
- 最热
只看作者