分布式缓存穿透、击穿、雪崩:三种场景的完整防护方案
分布式缓存穿透、击穿、雪崩:三种场景的完整防护方案
适读人群:中高级Java工程师 | 阅读时长:约18分钟 | 技术栈:Spring Boot 3.x、Redis、Caffeine、Bloom Filter
开篇故事
2021年的一个周末,我正在睡觉,凌晨两点被告警电话叫醒。监控显示:数据库 CPU 100%,连接数耗尽,接口 P99 超过 10 秒,整个电商平台几乎瘫痪。
那是我们第一次真实遭遇"缓存雪崩"。原因是周五晚上我们做了一次促销活动预热,批量写入了将近 200 万条商品缓存,所有缓存都设置了相同的过期时间:48 小时(第二天晚上8点促销结束后过期)。周日凌晨两点正好是 48 小时后,200 万条缓存几乎同时过期,所有请求瞬间打向数据库,数据库直接被打崩。
当时我们花了将近 40 分钟才恢复:先关了一半的服务实例减少 DB 压力,然后紧急预热缓存,再逐步开放服务。那晚的损失难以估量,主要是用户信任。
这次事故让我把缓存穿透、击穿、雪崩三个问题都研究透了。
一、核心问题分析
三种问题的本质
缓存穿透(Cache Penetration):查询一个不存在的 key(比如被攻击者构造的随机 ID),缓存里没有(空值不缓存),每次都打到数据库,数据库里也没有,等于无效查询持续消耗 DB。
缓存击穿(Cache Breakdown):一个热点 key 在缓存中过期的瞬间,大量并发请求同时发现缓存 miss,同时去查数据库,造成 DB 的瞬时压力峰值。
缓存雪崩(Cache Avalanche):大量 key 在同一时间过期(或 Redis 宕机),导致大批请求同时打向数据库,数据库被压垮。开篇的事故就是这个。
三者的区别:穿透是 key 不存在、击穿是单个热 key 失效、雪崩是大批 key 同时失效。
二、原理与解决方案
缓存穿透的防护
方案一:空值缓存 查询结果为空时,在 Redis 里缓存一个空值,设置较短的 TTL(如 5 分钟)。简单有效,但有两个缺点:如果空值被大量缓存,占用内存;如果后来数据真的被创建,需要等 TTL 过期才能查到。
方案二:布隆过滤器(Bloom Filter) 在缓存前加一层布隆过滤器,存储所有合法 key 的集合。查询时先检查布隆过滤器,如果不在过滤器里,直接返回空,不查缓存不查 DB。
布隆过滤器有假阳性(不在集合里的元素可能被误判为在集合里),但没有假阴性(在集合里的元素一定不会被误判为不在集合里),对于防穿透来说是安全的。
缓存击穿的防护
方案一:互斥锁(Mutex Lock) 缓存 miss 时,只允许一个线程去查数据库,其他线程等待。查完后写入缓存,其他线程从缓存读取。
方案二:热点数据永不过期(逻辑过期) 缓存不设置实际的 TTL,而是在 value 里存一个逻辑过期时间。读取时如果逻辑时间已过期,启动后台线程去刷新缓存,当前线程继续返回旧数据(stale data)。
这种方案没有缓存 miss,不会有 DB 瞬时压力,但会短暂返回过期数据。
缓存雪崩的防护
方案一:TTL 加随机抖动 缓存过期时间 = 基础时间 + 随机时间,将原本同时过期的请求分散到不同时间点。
方案二:热点数据预热 系统启动时或大促前,提前将热点数据加载到缓存,避免缓存冷启动时的雪崩。
方案三:多级缓存 本地缓存(Caffeine)+ Redis 缓存,Redis 雪崩时本地缓存兜底。
三、完整代码实现
布隆过滤器(缓存穿透防护)
@Component
@Slf4j
public class ProductBloomFilter {
private static final int EXPECTED_INSERTIONS = 10_000_000; // 预期元素数量
private static final double FALSE_POSITIVE_RATE = 0.01; // 误判率 1%
// Guava 布隆过滤器(单机版)
private final BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
EXPECTED_INSERTIONS,
FALSE_POSITIVE_RATE
);
@Autowired
private ProductMapper productMapper;
/**
* 应用启动时,将所有合法商品ID加载到布隆过滤器
*/
@PostConstruct
public void init() {
log.info("开始初始化商品布隆过滤器...");
long startTime = System.currentTimeMillis();
// 分批加载,避免一次性查询全部数据
long maxId = productMapper.selectMaxId();
int batchSize = 10000;
int loadedCount = 0;
for (long offset = 0; offset <= maxId; offset += batchSize) {
List<Long> productIds = productMapper.selectIdRange(offset, offset + batchSize);
productIds.forEach(bloomFilter::put);
loadedCount += productIds.size();
}
log.info("商品布隆过滤器初始化完成,加载{}条,耗时{}ms",
loadedCount, System.currentTimeMillis() - startTime);
}
/**
* 检查 ID 是否可能存在
*/
public boolean mightContain(Long productId) {
return bloomFilter.mightContain(productId);
}
/**
* 新增商品时,同步更新布隆过滤器
*/
public void add(Long productId) {
bloomFilter.put(productId);
}
}Redis 分布式布隆过滤器(生产推荐)
@Component
@Slf4j
public class RedisBloomFilter {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String BLOOM_KEY_PREFIX = "bloom:product:";
// 布隆过滤器参数:哈希函数数量 = 7,位数组大小 = 10M
private static final int HASH_FUNCTIONS = 7;
private static final long BIT_ARRAY_SIZE = 10_000_000L;
/**
* 向布隆过滤器添加元素
*/
public void put(String bloomKey, String element) {
for (int i = 0; i < HASH_FUNCTIONS; i++) {
long bitIndex = hash(element, i) % BIT_ARRAY_SIZE;
redisTemplate.opsForValue().setBit(BLOOM_KEY_PREFIX + bloomKey, bitIndex, true);
}
}
/**
* 检查元素是否可能存在
*/
public boolean mightContain(String bloomKey, String element) {
for (int i = 0; i < HASH_FUNCTIONS; i++) {
long bitIndex = hash(element, i) % BIT_ARRAY_SIZE;
Boolean bit = redisTemplate.opsForValue()
.getBit(BLOOM_KEY_PREFIX + bloomKey, bitIndex);
if (!Boolean.TRUE.equals(bit)) {
return false; // 确定不存在
}
}
return true; // 可能存在(有一定误判率)
}
private long hash(String element, int seed) {
return Math.abs((long) (element + seed).hashCode() * 2654435769L);
}
}多级缓存服务(防击穿 + 防雪崩)
@Service
@Slf4j
public class ProductCacheService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ProductMapper productMapper;
@Autowired
private ProductBloomFilter bloomFilter;
@Autowired
private RedissonClient redissonClient;
// 本地缓存(二级,防止 Redis 雪崩时 DB 被打)
private final Cache<Long, Optional<Product>> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.build();
private static final String CACHE_KEY_PREFIX = "product:detail:";
private static final String NULL_VALUE = "NULL"; // 空值标记
private static final long BASE_TTL_SECONDS = 3600L;
private static final long TTL_RANDOM_RANGE = 600L; // 10分钟随机抖动
/**
* 查询商品(防穿透 + 防击穿 + 防雪崩)
*/
public Product getProduct(Long productId) {
// 1. 布隆过滤器:防穿透
if (!bloomFilter.mightContain(productId)) {
log.debug("布隆过滤器拦截,productId={}", productId);
return null;
}
// 2. 本地缓存:防雪崩兜底
Optional<Product> localResult = localCache.getIfPresent(productId);
if (localResult != null) {
return localResult.orElse(null);
}
// 3. Redis 缓存
String cacheKey = CACHE_KEY_PREFIX + productId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (NULL_VALUE.equals(cached)) {
// 空值缓存,直接返回 null
return null;
}
if (cached != null) {
Product product = JSON.parseObject(cached, Product.class);
localCache.put(productId, Optional.of(product));
return product;
}
// 4. 缓存 miss,使用分布式锁防击穿
return loadFromDbWithMutex(productId, cacheKey);
}
/**
* 互斥锁加载(防止缓存击穿时大量请求打 DB)
*/
private Product loadFromDbWithMutex(Long productId, String cacheKey) {
RLock lock = redissonClient.getLock("product:mutex:" + productId);
try {
// 尝试加锁,最多等待 500ms
boolean locked = lock.tryLock(500, 10_000, TimeUnit.MILLISECONDS);
if (!locked) {
// 等待超时,从缓存重试(可能其他线程已经写入了)
String retryCache = redisTemplate.opsForValue().get(cacheKey);
if (retryCache != null && !NULL_VALUE.equals(retryCache)) {
return JSON.parseObject(retryCache, Product.class);
}
return null;
}
try {
// 双重检查:加锁后再查一次缓存
String doubleCheck = redisTemplate.opsForValue().get(cacheKey);
if (doubleCheck != null) {
if (NULL_VALUE.equals(doubleCheck)) {
return null;
}
return JSON.parseObject(doubleCheck, Product.class);
}
// 查数据库
Product product = productMapper.selectById(productId);
if (product == null) {
// 空值缓存,TTL = 5分钟(比正常数据短)
redisTemplate.opsForValue().set(cacheKey, NULL_VALUE, Duration.ofMinutes(5));
localCache.put(productId, Optional.empty());
} else {
// 加随机抖动,防雪崩
long ttl = BASE_TTL_SECONDS +
ThreadLocalRandom.current().nextLong(TTL_RANDOM_RANGE);
redisTemplate.opsForValue().set(
cacheKey, JSON.toJSONString(product), Duration.ofSeconds(ttl));
localCache.put(productId, Optional.of(product));
}
return product;
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
/**
* 逻辑过期方案(适用于超热点数据,永不 miss)
*/
public Product getProductWithLogicalExpiry(Long productId) {
String cacheKey = CACHE_KEY_PREFIX + "logical:" + productId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached == null) {
// 缓存不存在,同步加载一次
return loadAndCacheWithLogicalExpiry(productId);
}
ProductWithExpiry data = JSON.parseObject(cached, ProductWithExpiry.class);
if (System.currentTimeMillis() > data.getExpireTime()) {
// 逻辑过期,异步刷新,当前请求返回旧数据
refreshCacheAsync(productId);
}
return data.getProduct();
}
private void refreshCacheAsync(Long productId) {
CompletableFuture.runAsync(() -> {
String lockKey = "product:logical:refresh:" + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(0, 10_000, TimeUnit.MILLISECONDS)) {
try {
loadAndCacheWithLogicalExpiry(productId);
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
private Product loadAndCacheWithLogicalExpiry(Long productId) {
Product product = productMapper.selectById(productId);
if (product != null) {
ProductWithExpiry data = new ProductWithExpiry(product,
System.currentTimeMillis() + BASE_TTL_SECONDS * 1000);
String cacheKey = CACHE_KEY_PREFIX + "logical:" + productId;
// 不设 TTL(永不自动过期),通过逻辑过期时间控制
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(data));
}
return product;
}
}缓存预热工具
@Component
@Slf4j
public class CacheWarmupService {
@Autowired
private ProductMapper productMapper;
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 大促前缓存预热
* 关键:TTL 加随机抖动,避免批量过期
*/
public void warmupProductCache(List<Long> hotProductIds) {
log.info("开始缓存预热,共{}个商品", hotProductIds.size());
long startTime = System.currentTimeMillis();
// 分批处理,避免 Redis pipeline 命令过大
Lists.partition(hotProductIds, 100).forEach(batch -> {
try (var pipeline = redisTemplate.executePipelined((RedisCallback<?>) connection -> {
batch.forEach(productId -> {
Product product = productMapper.selectById(productId);
if (product != null) {
String key = ("product:detail:" + productId).getBytes().toString();
// 基础 TTL 2小时 + 0-30分钟随机抖动
long ttl = 7200 + ThreadLocalRandom.current().nextLong(1800);
connection.set(
key.getBytes(),
JSON.toJSONString(product).getBytes(),
Expiration.seconds(ttl),
RedisStringCommands.SetOption.UPSERT
);
}
});
return null;
})) {
// pipeline 执行完毕
}
});
log.info("缓存预热完成,耗时{}ms", System.currentTimeMillis() - startTime);
}
}四、生产调优与配置
TTL 随机抖动的范围设置
随机抖动的范围应该足够大,让缓存过期时间在一个足够长的时间窗口内均匀分布。建议:抖动范围 >= 基础 TTL 的 10-20%。
例如基础 TTL 1小时,抖动范围设 6-12 分钟,让过期分散在一个小时内的不同时间点。
布隆过滤器容量规划
布隆过滤器的误判率公式:p ≈ (1 - e^(-kn/m))^k,其中 k 是哈希函数数量,n 是元素数,m 是位数组大小。
推荐工具:用 Guava 的 BloomFilter.create 指定预期元素数和误判率,让 Guava 自动计算最优的位数组大小和哈希函数数量。
五、踩坑实录
坑一:开篇雪崩事故的复盘
根因:200 万条缓存用了相同的 TTL(48小时),到点同时过期。修复方案是所有批量预热的缓存都加随机抖动,抖动范围设为 TTL 的 20%(即 48 * 0.2 = 9.6 小时的随机量)。
改完之后,相同的促销活动再次举办,缓存过期均匀分布在一个宽窗口内,DB 没有出现任何瞬时压力峰值。
坑二:布隆过滤器的扩容问题
布隆过滤器的大小是在创建时固定的,后来商品数量超过了初始容量,误判率从设计的 1% 上升到了 8%,有 8% 的不存在 key 被误判为存在,穿透到数据库。
解决方案:定期检测布隆过滤器的填充率,当填充率超过 70% 时,重建一个更大的过滤器。使用分段 Bloom Filter 或 Counting Bloom Filter 也可以支持元素删除。
坑三:空值缓存的 TTL 设置错误
我们把空值缓存的 TTL 设成了和正常值一样(1小时),导致一个商品从"不存在"变成"存在"后(新建了这条记录),用户还要等 1 小时才能查到。客服接了大量"商品看不到"的工单。
空值缓存的 TTL 应该明显短于正常值的 TTL,我们改为 5 分钟,兼顾了穿透防护和数据及时性。
六、总结
三种场景的防护方案总结:
穿透:布隆过滤器(推荐)+ 空值缓存(兜底),两者结合效果最好。 击穿:互斥锁(Mutex)适合大多数场景;逻辑过期适合超热点、不能有缓存 miss 的场景。 雪崩:TTL 加随机抖动(必须)+ 多级缓存(兜底)+ 限流熔断(最后防线)。
三者共同的最终兜底:数据库连接池的合理配置 + 限流。即使缓存全部失效,只要限流把 DB 的 QPS 控制在安全范围内,系统就不会崩溃,只是慢一些而已。
