Redis缓存三大经典问题
Redis作为高性能缓存层,在缓解数据库压力、提升响应速度方面发挥关键作用。但在高并发场景下,缓存穿透、缓存雪崩、缓存击穿是三个必须解决的核心问题。这三个问题的本质都是缓存未命中导致大量请求直达数据库,区别在于触发原因不同:穿透是查询不存在的数据,雪崩是大量缓存同时过期,击穿是热点数据过期。
缓存穿透:布隆过滤器与空值缓存
缓存穿透指客户端请求的数据在缓存和数据库中都不存在,每次请求都穿透到数据库。常见场景是恶意攻击者使用不存在的ID频繁请求接口。如果不做防护,数据库连接池可能被耗尽。
解决方案有两种:空值缓存和布隆过滤器。空值缓存简单直接,但会占用额外内存且可能造成数据不一致。布隆过滤器在内存占用和准确性之间取得平衡,适合海量数据场景。
// Spring Boot + Redisson 布隆过滤器实现
@Configuration
public class BloomFilterConfig {
@Bean
public RBloomFilter<Long> productBloomFilter(RedissonClient redisson) {
RBloomFilter<Long> filter = redisson.getBloomFilter("product:bloom");
// 初始化:预计元素数量100万,误判率0.01%
filter.tryInit(1_000_000L, 0.0001);
return filter;
}
}
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RBloomFilter<Long> productBloomFilter;
private static final String CACHE_PREFIX = "product:detail:";
public Product getProductById(Long id) {
String cacheKey = CACHE_PREFIX + id;
// 1. 布隆过滤器前置检查
if (!productBloomFilter.contains(id)) {
// 数据一定不存在,直接返回
return null;
}
// 2. 查询缓存
Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 3. 查询数据库
product = productMapper.selectById(id);
if (product != null) {
// 缓存有效数据,设置随机过期时间防止雪崩
int ttl = 1800 + ThreadLocalRandom.current().nextInt(600);
redisTemplate.opsForValue().set(cacheKey, product, ttl, TimeUnit.SECONDS);
}
return product;
}
// 数据预热时将ID加入布隆过滤器
public void preloadBloomFilter() {
List<Long> allIds = productMapper.selectAllIds();
allIds.forEach(productBloomFilter::add);
}
}
布隆过滤器的误判率需要根据业务场景选择。误判率越低,需要的位数组越大。100万元素、0.01%误判率约需要2MB内存。布隆过滤器存在假阳性(不存在的数据可能被判定为存在),但不存在假阴性(存在的数据不会被判定为不存在),因此不会漏放真实数据。
缓存雪崩:随机过期与多级缓存
缓存雪崩指大量缓存在同一时间过期,导致所有请求同时打到数据库。典型场景是缓存预热时批量设置了相同TTL,或在整点时间批量失效。
// 解决方案1:随机过期时间
public void cacheProduct(Product product) {
String cacheKey = CACHE_PREFIX + product.getId();
// 基础TTL 30分钟 + 随机偏移 0-10分钟
int baseTtl = 1800;
int randomOffset = ThreadLocalRandom.current().nextInt(600);
redisTemplate.opsForValue().set(
cacheKey, product,
baseTtl + randomOffset, TimeUnit.SECONDS
);
}
// 解决方案2:多级缓存(本地缓存 + Redis)
@Service
public class MultiLevelCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// Caffeine本地缓存
private final Cache<String, Product> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(60, TimeUnit.SECONDS)
.recordStats()
.build();
public Product getProduct(Long id) {
String key = CACHE_PREFIX + id;
// L1: 本地缓存
Product product = localCache.getIfPresent(key);
if (product != null) {
return product;
}
// L2: Redis缓存
product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
localCache.put(key, product);
return product;
}
// L3: 数据库
product = productMapper.selectById(id);
if (product != null) {
// 双写缓存
localCache.put(key, product);
int ttl = 1800 + ThreadLocalRandom.current().nextInt(600);
redisTemplate.opsForValue().set(key, product, ttl, TimeUnit.SECONDS);
}
return product;
}
}
多级缓存通过本地缓存(Caffeine)作为第一层,Redis作为第二层,有效降低了Redis的压力。本地缓存TTL设置较短(60秒),主要应对突发流量;Redis缓存TTL设置较长(30分钟),作为持久化缓存层。当Redis整体不可用时,本地缓存仍能提供部分服务,起到降级保护作用。
缓存击穿:互斥锁与逻辑过期
缓存击穿指热点数据过期瞬间,大量并发请求同时查询数据库。与雪崩的区别在于击穿是单个Key的问题,雪崩是大量Key的问题。
互斥锁方案通过分布式锁保证只有一个请求查询数据库,其他请求等待结果:
@Service
public class HotProductService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RedissonClient redissonClient;
@Autowired
private ProductMapper productMapper;
public Product getHotProduct(Long id) {
String cacheKey = "product:hot:" + id;
String lockKey = "lock:product:" + id;
// 1. 查缓存
Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 2. 获取分布式锁
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁,等待3秒,锁自动释放10秒
boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (acquired) {
try {
// 双重检查:获取锁后再次查缓存
product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 查数据库
product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(
cacheKey, product,
1800, TimeUnit.SECONDS
);
}
return product;
} finally {
lock.unlock();
}
} else {
// 获取锁失败,短暂等待后重试
Thread.sleep(50);
return getHotProduct(id);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取缓存失败", e);
}
}
}
互斥锁方案的缺点是获取锁失败的线程会自旋等待,增加响应延迟。逻辑过期方案通过在缓存值中记录逻辑过期时间,避免物理过期,适用于对一致性要求不极端的场景:
// 逻辑过期方案
@Data
public class CacheData<T> {
private T data;
private long expireTime; // 逻辑过期时间戳
}
public Product getProductWithLogicalExpire(Long id) {
String cacheKey = "product:logical:" + id;
CacheData<Product> cacheData = (CacheData<Product>)
redisTemplate.opsForValue().get(cacheKey);
if (cacheData == null) {
// 缓存不存在,理论上不会发生(预热时永久写入)
return null;
}
// 检查逻辑过期时间
if (cacheData.getExpireTime() > System.currentTimeMillis()) {
// 未过期,直接返回
return cacheData.getData();
}
// 已过期,尝试获取锁异步刷新
String lockKey = "lock:refresh:" + id;
RLock lock = redissonClient.getLock(lockKey);
boolean acquired = lock.tryLock(0, 10, TimeUnit.SECONDS);
if (acquired) {
try {
// 异步刷新缓存
CompletableFuture.runAsync(() -> {
Product fresh = productMapper.selectById(id);
CacheData<Product> newData = new CacheData<>();
newData.setData(fresh);
newData.setExpireTime(System.currentTimeMillis() + 1800_000);
redisTemplate.opsForValue().set(cacheKey, newData);
});
} finally {
lock.unlock();
}
}
// 返回旧数据(接受短暂不一致)
return cacheData.getData();
}
逻辑过期方案的核心思想是缓存永不物理失效,过期后返回旧数据的同时异步刷新。获取到锁的线程负责刷新缓存,未获取到锁的线程直接返回旧数据。这种方案牺牲了短暂的数据一致性,换取了零等待时间的高可用性。对于商品详情页、热门文章等对实时性要求不高的场景,逻辑过期是更优选择。
缓存一致性保障策略
缓存与数据库的一致性是缓存系统设计的核心问题。Cache Aside Pattern是最常用的策略:读操作先查缓存,未命中查数据库并回写缓存;写操作先更新数据库,再删除缓存。删除而非更新缓存的原因是避免并发写导致的缓存数据不一致。
// Cache Aside Pattern 实现
public Product updateProduct(Product product) {
// 1. 更新数据库
productMapper.updateById(product);
// 2. 删除缓存
redisTemplate.delete(CACHE_PREFIX + product.getId());
return product;
}
// 延迟双删:解决读写并发导致的脏缓存
public Product updateProductWithDoubleDelete(Product product) {
// 1. 先删除缓存
redisTemplate.delete(CACHE_PREFIX + product.getId());
// 2. 更新数据库
productMapper.updateById(product);
// 3. 延迟500ms后再次删除缓存
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
executor.schedule(() -> {
redisTemplate.delete(CACHE_PREFIX + product.getId());
}, 500, TimeUnit.MILLISECONDS);
return product;
}
延迟双删方案通过在数据库更新前后两次删除缓存,覆盖了读写并发场景下缓存被旧数据回写的窗口期。延迟时间需要大于一次数据库读操作的耗时,通常设置为500ms。对于强一致性场景,需要引入消息队列保证缓存删除的可靠性,或将缓存删除操作放入数据库事务中。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-ce-lyue-shi-zhan-huan-cun-chuan-tou-xue-beng/