面试很喜欢问一题:"给我说说缓存穿透、缓存击穿、缓存雪崩的区别和解决方案。"能完整讲清楚的,十个里不到3个。今天这篇文章,把这三个问题从根因到代码一趟讲透。
一、先搞清楚:三个问题本质不同
| 问题 | 触发条件 | 核心矛盾 | 形象比喻 |
|---|---|---|---|
| 缓存穿透 | 查询一个数据库里也没有的数据 | Redis和MySQL都没有,每次请求都白查一遍DB | 不停问一个不存在的人借钱 |
| 缓存击穿 | 一个热点key在缓存过期的瞬间,大量并发打到DB | 单个热点key失效时的瞬时并发洪峰 | 热门餐厅突然关门,顾客全部涌向隔壁 |
| 缓存雪崩 | 大量key在同一时间段集中过期,或者Redis宕机 | 大面积缓存失效导致DB被压垮 | 整个批发市场的店铺同时歇业 |
理解区别后,我们一个一个拆解。
二、缓存穿透:布隆过滤器是最终的答案
2.1 问题场景
java
// 恶意攻击者或爬虫不断请求不存在的商品ID
// 比如:productId = -1,productId = "invalid_xxx"
// 每次请求都会穿透Redis,直击数据库
public Product getProductById(String productId) {
// 1. 查缓存
String cacheKey = "product:" + productId;
Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) return product;
// 2. 查数据库------但数据库里也没有这条数据!
product = productMapper.selectById(productId);
if (product == null) {
// 数据库也没有,但这次已经白查了DB
return null;
}
// 3. 写缓存
redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
return product;
}
每一次恶意请求都穿透到MySQL。1000QPS的无效查询就能让你的数据库跪下。
2.2 方案一:缓存空对象(临时凑合)
最简单粗暴的办法------数据库查不到,也在Redis里存一个"空值标记":
java
public Product getProductById(String productId) {
String cacheKey = "product:" + productId;
Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
// 命中空值标记(说明之前查过,确实不存在)
if (EMPTY_PLACEHOLDER.equals(product)) {
return null;
}
if (product != null) return product;
product = productMapper.selectById(productId);
if (product == null) {
// 缓存空对象,设置较短的过期时间(防止占用太多内存)
redisTemplate.opsForValue().set(cacheKey, EMPTY_PLACEHOLDER, 5, TimeUnit.MINUTES);
return null;
}
redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
return product;
}
问题也很明显:如果攻击者用大量不存在的随机key轰炸,Redis里会塞满垃圾空值。这是堵漏,不是根治。
2.3 方案二:布隆过滤器(正确解法)
布隆过滤器的核心思想:在查缓存之前,先用一个超轻量的数据结构判断key是否可能存在。
原理说起来也简单:一个巨大的bit数组 + 多个哈希函数。插入时把几个哈希位置都置为1;查询时如果任何一个位置是0,那这个key一定不存在 。如果所有位置都是1,只能说可能存在------会有一定的误判率。
java
// ========== 依赖版本 ==========
// Spring Boot 3.2 + Redisson 3.25.2(内置布隆过滤器)
// <dependency>
// <groupId>org.redisson</groupId>
// <artifactId>redisson-spring-boot-starter</artifactId>
// <version>3.25.2</version>
// </dependency>
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private final RedisTemplate<String, Object> redisTemplate;
private final ProductMapper productMapper;
private final RBloomFilter<String> bloomFilter;
public ProductService(RedisTemplate<String, Object> redisTemplate,
ProductMapper productMapper,
RedissonClient redissonClient) {
this.redisTemplate = redisTemplate;
this.productMapper = productMapper;
// 初始化布隆过滤器:预期插入100万条,误判率0.01
this.bloomFilter = redissonClient.getBloomFilter("product:bloom");
this.bloomFilter.tryInit(1_000_000L, 0.001);
}
// 启动时加载已有商品ID到布隆过滤器
@PostConstruct
public void init() {
List<String> allIds = productMapper.selectAllProductIds();
allIds.forEach(bloomFilter::add);
}
// 新增商品时同步更新布隆过滤器
public void addProduct(Product product) {
productMapper.insert(product);
String id = String.valueOf(product.getId());
bloomFilter.add(id);
redisTemplate.opsForValue().set("product:" + id, product, 30, TimeUnit.MINUTES);
}
// 核心查询方法:布隆过滤器 + Redis + MySQL 三层防护
public Product getProductById(String productId) {
// 第一层:布隆过滤器,直接拦截不存在的数据
// contains() 返回 false → 这个key百分百不存在,不用查Redis,更不用查DB
if (!bloomFilter.contains(productId)) {
return null; // 秒级返回,保护Redis和MySQL
}
String cacheKey = "product:" + productId;
// 第二层:Redis缓存
Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) return product;
// 第三层:数据库(只有布隆过滤器认为"可能存在"的key才会走到这里)
product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
}
return product;
}
}
选型提醒:Redisson内置的布隆过滤器是单机内存方案。如果是分布式环境(多个服务节点),可以考虑Redis Bitmap自建、或者Guava BloomFilter + 容器启动时全量加载。误判率设为0.1%~1%之间是比较合适的。
三、缓存击穿:互斥锁 vs 逻辑过期,实战怎么选
3.1 问题场景
秒杀商品的缓存只有30秒。31秒时,500个线程同时发现缓存过期,它们不约而同地去查MySQL重建缓存。500个并发查询,DB卒。
3.2 方案一:互斥锁(简单可靠)
核心思路:缓存未命中时,只让一个线程去查DB,其他线程自旋等待。
java
import java.util.concurrent.TimeUnit;
@Service
public class HotProductService {
private final StringRedisTemplate redisTemplate;
private final ProductMapper productMapper;
public HotProductService(StringRedisTemplate redisTemplate, ProductMapper productMapper) {
this.redisTemplate = redisTemplate;
this.productMapper = productMapper;
}
public Product getProductWithMutex(String productId) {
String cacheKey = "hot:product:" + productId;
String lockKey = "lock:product:" + productId;
// 1. 查缓存
String cachedJson = redisTemplate.opsForValue().get(cacheKey);
if (cachedJson != null) {
return JSON.parseObject(cachedJson, Product.class);
}
// 2. 缓存未命中,尝试获取互斥锁
try {
// SETNX + EXPIRE 原子操作,锁过期时间5秒(防止死锁)
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
// 获得锁------去查数据库重建缓存
try {
// 双重检查:锁内再查一次缓存(防止重复建)
cachedJson = redisTemplate.opsForValue().get(cacheKey);
if (cachedJson != null) {
return JSON.parseObject(cachedJson, Product.class);
}
Product product = productMapper.selectById(productId);
if (product != null) {
String json = JSON.toJSONString(product);
// 加一个随机偏移量,避免雪崩
int ttl = 60 + ThreadLocalRandom.current().nextInt(10);
redisTemplate.opsForValue().set(cacheKey, json, ttl, TimeUnit.SECONDS);
}
return product;
} finally {
// 释放锁------但要确保是自己加的锁(生产环境建议用Lua脚本保证原子性)
redisTemplate.delete(lockKey);
}
} else {
// 没获得锁------自旋等待
Thread.sleep(50);
return getProductWithMutex(productId); // 递归重试
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
}
互斥锁方案的核心原则:
- 锁超时时间要短(3~5秒足够),防止死锁
- 获得锁后要双重检查缓存,避免重复查库
- 生产环境建议用Lua脚本保证「判断锁归属 + 删除锁」的原子性
3.3 方案二:逻辑过期(适合极致性能场景)
互斥锁有个问题------所有没抢到锁的线程都得自旋等待。如果对延迟极度敏感,可以用「逻辑过期」:
核心思想:缓存永不过期(物理上不设TTL),由后台线程异步刷新数据。
java
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDateTime;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CacheData<T> {
private T data; // 实际数据
private LocalDateTime expireTime; // 逻辑过期时间
}
@Service
public class LogicalExpireService {
private final StringRedisTemplate redisTemplate;
private final ProductMapper productMapper;
private final ObjectMapper objectMapper;
// 线程池用于异步重建缓存
private final ThreadPoolExecutor rebuildPool =
(ThreadPoolExecutor) Executors.newFixedThreadPool(4);
public LogicalExpireService(StringRedisTemplate redisTemplate,
ProductMapper productMapper,
ObjectMapper objectMapper) {
this.redisTemplate = redisTemplate;
this.productMapper = productMapper;
this.objectMapper = objectMapper;
}
public Product getProductLogicalExpire(String productId) {
String cacheKey = "hot:product:" + productId;
String lockKey = "lock:product:" + productId;
String cachedJson = redisTemplate.opsForValue().get(cacheKey);
// 缓存完全不存在------说明之前从未加载过,同步查库
if (cachedJson == null) {
return loadAndCache(cacheKey, productId);
}
CacheData<Product> cacheData = JSON.parseObject(cachedJson,
new TypeReference<CacheData<Product>>() {});
Product product = cacheData.getData();
LocalDateTime expireTime = cacheData.getExpireTime();
// 判断是否逻辑过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 未过期,直接返回
return product;
}
// 逻辑过期了,但数据还能用------先返回旧数据
// 同时异步重建缓存(抢锁来决定谁去重建)
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
rebuildPool.execute(() -> {
try {
// 重建缓存,设置新的逻辑过期时间
Product freshProduct = productMapper.selectById(productId);
CacheData<Product> freshData = new CacheData<>(
freshProduct,
LocalDateTime.now().plusMinutes(30) // 逻辑过期30分钟
);
String json = JSON.toJSONString(freshData);
redisTemplate.opsForValue().set(cacheKey, json); // 注意:不设TTL
} finally {
redisTemplate.delete(lockKey);
}
});
}
// 不管重建有没有完成,先返回旧数据(保证用户体验)
return product;
}
private Product loadAndCache(String cacheKey, String productId) {
Product product = productMapper.selectById(productId);
if (product != null) {
CacheData<Product> cacheData = new CacheData<>(
product,
LocalDateTime.now().plusMinutes(30)
);
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(cacheData));
}
return product;
}
}
逻辑过期的核心优势:请求进来时永远不需要等待------数据存在就返回,哪怕已经逻辑过期了也是返回旧数据,由后台线程异步更新。这对高并发场景(双11秒杀、排行榜)是降维打击。
| 对比维度 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 一致性 | 强一致性(拿到的一定是最新数据) | 最终一致性(可能有短暂旧数据) |
| 性能 | 抢不到锁的线程会等待 | 所有线程无等待 |
| 复杂度 | 简单 | 中等(需要额外的线程池管理) |
| 适用场景 | 一般热点数据 | 极热点数据,允许短暂不一致 |
四、缓存雪崩:多级缓存 + 随机化过期 + 熔断降级
4.1 问题场景
凌晨0点,运维批量导入了10万条商品缓存,TTL全部设为1小时。凌晨1点01分,这些缓存全部同时过期,瞬时流量洪峰全部砸向MySQL。这就是雪崩。
4.2 解决方案详解
① 随机化过期时间(基础防御)
java
// 别这么写------所有缓存的TTL完全一样
redisTemplate.opsForValue().set(key, value, 3600, TimeUnit.SECONDS);
// 应该加随机偏移量------让缓存散列过期
int baseTtl = 3600;
int randomOffset = ThreadLocalRandom.current().nextInt(300); // 0~300秒随机偏移
redisTemplate.opsForValue().set(key, value, baseTtl + randomOffset, TimeUnit.SECONDS);
② 多级缓存架构(纵深防御)
java
/**
* 二级缓存:Caffeine(JVM本地) + Redis(分布式)
* 查询链路:Caffeine → Redis → MySQL
*
* 依赖版本:
* Spring Boot 3.2 + Spring Cache + Caffeine
* Caffeine 3.1.8
*/
@Configuration
@EnableCaching
public class MultiLevelCacheConfig {
// 一级缓存:Caffeine(JVM本地,LRU + 时间窗口,10秒过期)
@Bean
public CacheManager caffeineCacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(10_000) // 最多1万条
.expireAfterWrite(10, TimeUnit.SECONDS) // 本地10秒过期
.recordStats()); // 开启统计
return cacheManager;
}
// 二级缓存:Redis(分布式,30分钟,但加随机偏移)
@Bean
public CacheManager redisCacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
@Service
public class MultiLevelProductService {
// 一级缓存:Caffeine本地(@Cacheable打在方法上即可)
@Cacheable(value = "productLocal", key = "#productId",
cacheManager = "caffeineCacheManager")
@Cacheable(value = "productRedis", key = "#productId",
cacheManager = "redisCacheManager")
public Product getProductMultiLevel(String productId) {
// 只有两级缓存都miss时才查数据库
return productMapper.selectById(productId);
}
}
多级缓存的好处:即使Redis全部挂了,Caffeine本地缓存还能扛10秒。这10秒足够运维恢复Redis,或者触发熔断机制。
③ Redis高可用 + 熔断降级(最后防线)
java
/**
* 使用Sentinel实现Redis的熔断降级
* 当Redis不可用时,快速失败返回兜底数据,避免雪崩
*
* alibaba sentinel 1.8.6
*/
import com.alibaba.csp.sentinel.annotation.SentinelResource;
import com.alibaba.csp.sentinel.slots.block.BlockException;
@Service
public class ResilientProductService {
private final RedisTemplate<String, Object> redisTemplate;
private final ProductMapper productMapper;
public ResilientProductService(RedisTemplate<String, Object> redisTemplate,
ProductMapper productMapper) {
this.redisTemplate = redisTemplate;
this.productMapper = productMapper;
}
@SentinelResource(value = "getProduct",
fallback = "getProductFallback", // 异常降级
blockHandler = "getProductBlockHandler") // 限流降级
public Product getProduct(String productId) {
String cacheKey = "product:" + productId;
Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) return product;
product = productMapper.selectById(productId);
if (product != null) {
int ttl = 1800 + ThreadLocalRandom.current().nextInt(300);
redisTemplate.opsForValue().set(cacheKey, product, ttl, TimeUnit.SECONDS);
}
return product;
}
// 异常降级方法:Redis挂了或DB挂了时返回兜底数据
public Product getProductFallback(String productId, Throwable e) {
log.error("query product failed, id={}, use fallback", productId, e);
// 返回一个兜底对象(前端可以识别并展示"数据暂不可用")
return Product.fallback(productId);
}
// 限流降级方法
public Product getProductBlockHandler(String productId, BlockException e) {
log.warn("query product blocked, id={}", productId);
return Product.fallback(productId);
}
}
五、三条实战建议
第一条:不要一上来就上全套方案。 你的项目大概率没有10万QPS,一个"布隆过滤器 + 互斥锁 + 随机过期TTL"的组合就能解决95%的问题。多级缓存和逻辑过期是给真正的高并发场景准备的,先评估你的真实流量再决定。
第二条:上线前做一次缓存击穿演练。 挑一个业务低峰期,手动删除某个热点key的缓存,观察:
- 数据库QPS是否激增?
- 互斥锁机制是否正常工作?
- 如果有多个实例,锁是否是分布式的?
这些在本地环境测不出来,只能到生产环境的低峰时段去验证。
第三条:监控比方案更重要。 在Prometheus/Grafana里建三个大盘:
cache_hit_rate(缓存命中率------低于80%要预警)cache_penetration_count(穿透次数------突然暴涨说明有攻击)db_active_connections(数据库活跃连接数------这是最后的防线)
没有监控的缓存方案,就是盲人骑瞎马。
下篇预告 :《Redis分布式锁:Redisson源码剖析与正确使用姿势》------你以为 lock.lock() 就万事大吉了?看门狗续期、红锁争议、与ZooKeeper锁的终极对比,下篇讲。