三大缓存问题的本质区别
Redis缓存实践中,三个概念经常被混淆:
1. 缓存穿透:请求的数据在缓存和数据库中都不存在,每次请求都穿透到数据库
2. 缓存雪崩:大量缓存key同时过期,请求瞬间全部打到数据库
3. 缓存击穿:某个热点key过期瞬间,大量并发请求同时穿透到数据库
三者症状相似(数据库压力骤增),但根因和防御方案完全不同。下文逐个拆解。
缓存穿透:布隆过滤器与空值缓存
穿透场景的典型例子:查询id=-1的用户、搜索不存在的商品SKU。恶意请求会构造大量不存在的key,绕过Redis直击MySQL。
方案1:布隆过滤器前置拦截
在Redis前加一层布隆过滤器,所有可能存在的数据ID写入布隆过滤器。请求到达时先查布隆过滤器,不存在的直接返回:
@Service
public class UserQueryService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private final RBloomFilter<Long> userBloomFilter;
public UserQueryService(RedissonClient redisson) {
// 布隆过滤器,预计100万元素,误判率0.01
this.userBloomFilter = redisson.getBloomFilter("userBloom");
userBloomFilter.tryInit(1_000_000L, 0.01);
}
public User getUser(Long id) {
String key = "user:" + id;
// 布隆过滤器判断
if (!userBloomFilter.contains(id)) {
return null; // 一定不存在
}
// 查缓存
User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) {
return user;
}
// 查数据库
user = userMapper.selectById(id);
if (user != null) {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
} else {
// 空值缓存,短TTL
redisTemplate.opsForValue().set(key, User.EMPTY, 5, TimeUnit.MINUTES);
}
return user;
}
}
布隆过滤器的特性:判断存在可能误判(false positive),判断不存在则一定准确(no false negative)。这意味着少量穿透仍会发生,但比例可控。
方案2:空值缓存
更简单的做法——数据库查不到时,缓存一个空对象,设短TTL:
public User getUserWithNullCache(Long id) {
String key = "user:" + id;
User cached = (User) redisTemplate.opsForValue().get(key);
if (User.EMPTY.equals(cached)) {
return null; // 空值缓存命中
}
if (cached != null) {
return cached;
}
User user = userMapper.selectById(id);
if (user != null) {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
} else {
// 空值缓存5分钟
redisTemplate.opsForValue().set(key, User.EMPTY, 5, TimeUnit.MINUTES);
}
return user;
}
空值缓存的问题:恶意请求构造大量不同key时,Redis内存会被空值占满。解法是限制空值缓存的key空间——比如只缓存合法ID范围内的空值。
缓存雪崩:过期时间随机化与高可用架构
雪崩发生在大量key同时过期。常见场景:批量导入数据时所有key设了相同TTL,或凌晨低峰期批量写入的数据在早高峰同时过期。
方案1:TTL加随机偏移
public void batchSetCache(Map<String, Object> dataMap) {
long baseTtl = 30 * 60; // 30分钟
Random random = new Random();
dataMap.forEach((key, value) -> {
// TTL = 30分钟 ± 5分钟随机
long ttl = baseTtl + random.nextInt(600) - 300;
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);
});
}
5分钟的随机偏移让过期时间分散开,避免同一秒大量key同时失效。
方案2:多级缓存
本地缓存(Caffeine)作为L1,Redis作为L2。Redis大面积失效时,L1仍能挡住部分流量:
@Configuration
public class MultiLevelCacheConfig {
@Bean
public Cache<String, Object> localCache() {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
}
}
@Service
public class ProductQueryService {
@Autowired
private Cache<String, Object> localCache;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public Product getProduct(Long id) {
String key = "product:" + id;
// L1: 本地缓存
Product cached = (Product) localCache.getIfPresent(key);
if (cached != null) return cached;
// L2: Redis
cached = (Product) redisTemplate.opsForValue().get(key);
if (cached != null) {
localCache.put(key, cached);
return cached;
}
// L3: 数据库
Product product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
localCache.put(key, product);
}
return product;
}
}
本地缓存的TTL比Redis短(5分钟 vs 30分钟),且不同实例的本地缓存过期时间自然不同,不会出现同时失效。
方案3:Redis高可用兜底
雪崩的终极后果是数据库被打挂。在数据库前加最后一道限流:
# Sentinel限流配置
spring:
cloud:
sentinel:
flow:
rules:
- resource: "queryUser"
limitApp: default
count: 500
grade: 1
controlBehavior: 0
数据库查询QPS超500直接拒绝,避免雪崩传导到存储层。被拒绝的请求走降级逻辑,返回缓存数据或友好提示。
缓存击穿:互斥锁重建缓存
击穿是单个热点key过期后,几百个并发请求同时穿透到数据库。与雪崩的区别——击穿是单key、高并发;雪崩是多key、同时过期。
方案1:互斥锁(Mutex Lock)
只允许一个线程重建缓存,其他线程等待:
public User getUserWithMutex(Long id) {
String key = "user:" + id;
String lockKey = "lock:user:" + id;
User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) return user;
// 尝试获取互斥锁
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查
user = (User) redisTemplate.opsForValue().get(key);
if (user != null) return user;
// 查数据库并重建缓存
user = userMapper.selectById(id);
if (user != null) {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
} else {
redisTemplate.opsForValue().set(key, User.EMPTY, 5, TimeUnit.MINUTES);
}
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 等待50ms后重试
try { Thread.sleep(50); } catch (InterruptedException ignored) {}
return getUserWithMutex(id);
}
return user;
}
互斥锁方案简单有效,但存在两个细节问题:
1. 递归重试在高并发下可能栈溢出——改用循环+最大重试次数
2. 锁的过期时间(10秒)必须大于数据库查询耗时,否则锁提前释放会导致并发重建
方案2:逻辑过期
不设TTL,在value中嵌入逻辑过期时间。过期后返回旧数据,异步更新:
@Data
public class CacheData<T> {
private T data;
private long expireTime;
}
public User getUserWithLogicalExpire(Long id) {
String key = "user:" + id;
CacheData<User> cacheData = (CacheData<User>) redisTemplate.opsForValue().get(key);
if (cacheData == null) {
return loadAndCache(id, key);
}
if (cacheData.getExpireTime() > System.currentTimeMillis()) {
return cacheData.getData();
}
// 逻辑过期,异步更新
String lockKey = "lock:user:" + id;
boolean locked = Boolean.TRUE.equals(
redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS));
if (locked) {
CompletableFuture.runAsync(() -> {
try {
loadAndCache(id, key);
} finally {
redisTemplate.delete(lockKey);
}
});
}
return cacheData.getData();
}
逻辑过期方案的优点:请求永远不会阻塞等待,用户体验最好。缺点是短暂返回过期数据,对一致性要求极高的场景不适用。
监控指标与告警配置
Redis缓存策略的防御体系需要监控支撑。关键指标:
# Prometheus Redis指标
- redis_connected_clients
- redis_keyspace_hits_total
- redis_keyspace_misses_total
- redis_evicted_keys_total
# 自定义告警:缓存命中率低于80%
- alert: RedisCacheHitRateLow
expr: |
redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total) < 0.8
for: 5m
labels:
severity: warning
命中率低于80%说明大量请求穿透到数据库,需要排查是穿透问题还是缓存容量不足。数据库运维的核心就是防患于未然——等数据库被打挂了再处理,止损成本已经是数量级差异了。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-chuan-tou-xue-beng-ji-chuan-xi-tong-xing/