Redis缓存三大经典问题与架构设计
Redis作为高性能缓存中间件,在数据库高可用架构中承担着抵挡海量查询流量的核心角色。但缓存引入后,缓存穿透、缓存雪崩、缓存击穿三大问题随之而来。这三个问题本质上是缓存失效后的流量击穿到数据库的差异化表现。本文从架构设计到编码实现,给出经过生产验证的完整解决方案。
三者区别:
| 问题 | 触发条件 | 本质 |
|---------|---------------------------|------------------------|
| 穿透 | 查询不存在的数据 | 缓存和DB都没有,请求直达DB |
| 雪崩 | 大量Key同时过期 | 缓存批量失效,流量涌入DB |
| 击穿 | 热点Key过期瞬间大量并发请求 | 单Key失效,并发击穿到DB |
缓存穿透解决方案:布隆过滤器与空值缓存
缓存穿透的典型场景:恶意攻击查询不存在的ID,或业务数据尚未写入。解决方案分两层:
方案一:布隆过滤器前置拦截
@Component
public class BloomFilterService {
private final RBloomFilter<Long> bloomFilter;
public BloomFilterService(RedissonClient redisson) {
this.bloomFilter = redisson.getBloomFilter("user:bloom");
// 预期插入量100万,误判率0.01%
bloomFilter.tryInit(1_000_000L, 0.0001);
}
public boolean mightExist(Long userId) {
return bloomFilter.contains(userId);
}
public void addToFilter(Long userId) {
bloomFilter.add(userId);
}
}
// 在查询前先过布隆过滤器
@Service
public class UserService {
@Autowired
private BloomFilterService bloomFilterService;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private UserMapper userMapper;
public User getUserById(Long id) {
// 第一层:布隆过滤器判断
if (!bloomFilterService.mightExist(id)) {
return null; // 一定不存在,直接返回
}
// 第二层:查缓存
String key = "user:" + id;
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);
}
return user;
}
}
方案二:空值缓存+短TTL
public User getUserById(Long id) {
String key = "user:" + id;
User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) {
return user instanceof NullUser ? null : user;
}
user = userMapper.selectById(id);
if (user != null) {
redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES);
} else {
// 缓存空值,短TTL防止长期占用内存
redisTemplate.opsForValue().set(key, NullUser.INSTANCE, 5, TimeUnit.MINUTES);
}
return user;
}
缓存雪崩解决方案:随机过期与多级缓存
缓存雪崩的核心问题是大量Key同时过期。解决方案:
方案一:过期时间加随机偏移
public void batchCacheUsers(List<User> users) {
users.forEach(user -> {
String key = "user:" + user.getId();
// 基础过期时间30分钟 + 随机0~10分钟偏移
long expireSeconds = 30 * 60 + ThreadLocalRandom.current().nextLong(600);
redisTemplate.opsForValue().set(key, user, expireSeconds, TimeUnit.SECONDS);
});
}
方案二:多级缓存架构
@Service
public class MultiLevelCacheService {
private final Cache<String, User> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES) // 本地缓存5分钟
.recordStats()
.build();
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private UserMapper userMapper;
public User getUserById(Long id) {
String key = "user:" + id;
// L1: 本地缓存(Caffeine)
User user = localCache.getIfPresent(key);
if (user != null) return user;
// L2: Redis分布式缓存
user = (User) redisTemplate.opsForValue().get(key);
if (user != null) {
localCache.put(key, user);
return user;
}
// L3: 数据库
user = userMapper.selectById(id);
if (user != null) {
long expire = 30 * 60 + ThreadLocalRandom.current().nextLong(600);
redisTemplate.opsForValue().set(key, user, expire, TimeUnit.SECONDS);
localCache.put(key, user);
}
return user;
}
}
方案三:熔断降级兜底
@HystrixCommand(
fallbackMethod = "getUserFallback",
commandProperties = {
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "20"),
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50"),
@HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "30000")
}
)
public User getUserById(Long id) {
return multiLevelCacheService.getUserById(id);
}
public User getUserFallback(Long id) {
// 降级:返回默认值或从备份存储读取
return User.defaultUser(id);
}
缓存击穿解决方案:互斥锁与逻辑过期
缓存击穿针对热点Key过期时的并发穿透问题。两种主流方案:
方案一:互斥锁(Mutex Lock)
public User getUserWithMutex(Long id) {
String key = "user:" + id;
User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) return user;
String lockKey = "lock:user:" + id;
try {
// 尝试获取分布式锁
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);
}
return user;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 获取锁失败,短暂等待后重试
Thread.sleep(50);
return getUserWithMutex(id);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
}
}
方案二:逻辑过期(Logical Expiry)
@Data
public class CacheData<T> {
private T data;
private LocalDateTime expireTime;
}
public User getUserWithLogicalExpiry(Long id) {
String key = "user:" + id;
CacheData cacheData = (CacheData) redisTemplate.opsForValue().get(key);
if (cacheData == null) {
// 缓存不存在,直接查库
return loadAndCache(id, key);
}
if (cacheData.getExpireTime().isAfter(LocalDateTime.now())) {
// 逻辑未过期,直接返回
return (User) cacheData.getData();
}
// 逻辑已过期,异步刷新
String lockKey = "lock:user:" + id;
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
// 获取锁,异步刷新缓存
CompletableFuture.runAsync(() -> {
try {
loadAndCache(id, key);
} finally {
redisTemplate.delete(lockKey);
}
});
}
// 无论是否获取锁,都返回过期数据(保证可用性)
return (User) cacheData.getData();
}
互斥锁方案保证数据强一致,但高并发下存在线程阻塞风险;逻辑过期方案保证高可用,但牺牲了数据一致性。选择哪种方案取决于业务场景:金融交易类选互斥锁,信息展示类选逻辑过期。生产环境中三种问题的解决方案需要组合使用,布隆过滤器防穿透、随机过期防雪崩、互斥锁防击穿,构建完整的Redis缓存高可用防护体系。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/redis-huan-cun-chuan-tou-xue-beng-ji-chuan-jie-jue-fang-an/