前言
最近在优化项目性能,大量使用了Redis做缓存。但缓存也不是万能的,用不好反而会出现各种问题
今天就总结一下Redis常见的三个问题:缓存穿透、缓存击穿、缓存雪崩,以及对应的解决方案
这三个词虽然听起来很像,但其实是三种不同的问题,下面一个一个来说
缓存穿透
什么是缓存穿透
缓存穿透是指查询一个不存在的数据,缓存中没有,数据库中也没有
这样每次请求都会打到数据库,如果有人恶意攻击,一直查询不存在的数据,可能会导致数据库崩溃
解决方案
方案一:缓存空对象
当查询数据库发现数据不存在时,也把空对象缓存起来:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| @Service public class UserService {
@Autowired UserMapper userMapper;
@Autowired RedisTemplate<String, Object> redisTemplate;
public User getUserById(Long id) { String key = "user:" + id; User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) { return user.getId() == null ? null : user; }
user = userMapper.selectById(id);
if (user != null) { redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES); } else { User nullUser = new User(); redisTemplate.opsForValue().set(key, nullUser, 5, TimeUnit.MINUTES); }
return user; } }
|
这种方式实现简单,但缺点是如果缓存了很多空对象,会占用内存
方案二:布隆过滤器
布隆过滤器可以快速判断一个元素是否在一个集合中
在缓存之前加一层布隆过滤器,如果布隆过滤器说不存在,那就直接返回,不用查数据库了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| @Configuration public class BloomFilterConfig {
private BloomFilter<Long> bloomFilter;
@PostConstruct public void init() { bloomFilter = BloomFilter.create( Funnels.longFunnel(), 1000000, 0.0001 );
List<Long> allIds = getAllIds(); for (Long id : allIds) { bloomFilter.put(id); } }
public boolean mightContain(Long id) { return bloomFilter.mightContain(id); }
private List<Long> getAllIds() { return Collections.emptyList(); } }
|
使用的时候:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Service public class UserService {
@Autowired BloomFilterConfig bloomFilterConfig;
public User getUserById(Long id) { if (!bloomFilterConfig.mightContain(id)) { return null; }
} }
|
布隆过滤器的缺点是有一定的误判率,而且删除数据不方便
缓存击穿
什么是缓存击穿
缓存击穿是指某个热点数据过期了,这时候大量请求同时过来,缓存没命中,全部打到数据库
比如一个热点新闻,缓存刚好过期,这时候有上万人同时来访问,数据库瞬间就炸了
解决方案
方案一:设置热点数据永不过期
对于热点数据,可以不设置过期时间,或者设置很长的过期时间:
1 2 3 4 5 6 7 8 9 10 11 12
| public User getHotUser(Long id) { String key = "user:" + id; User user = (User) redisTemplate.opsForValue().get(key);
if (user == null) { user = userMapper.selectById(id); redisTemplate.opsForValue().set(key, user); }
return user; }
|
方案二:互斥锁
当缓存失效时,只允许一个线程去查数据库,其他线程等待
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| public User getUserWithLock(Long id) { String key = "user:" + id; User user = (User) redisTemplate.opsForValue().get(key);
if (user == null) { String lockKey = "lock:" + key; try { Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) { user = userMapper.selectById(id); if (user != null) { redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES); } } else { Thread.sleep(100); return getUserWithLock(id); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { redisTemplate.delete(lockKey); } }
return user; }
|
方案三:逻辑过期
不设置Redis的过期时间,而是在缓存的数据中加一个过期时间字段
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| @Data public class CacheData<T> { private T data; private Long expireTime; }
public <T> T getWithLogicalExpire(String key, Class<T> type, long expireTime) { String cacheKey = "cache:" + key; CacheData<T> cacheData = (CacheData<T>) redisTemplate.opsForValue().get(cacheKey);
if (cacheData == null) { return null; }
T data = cacheData.getData();
if (System.currentTimeMillis() > cacheData.getExpireTime()) { CompletableFuture.runAsync(() -> { T newData = loadFromDatabase(key); CacheData<T> newCache = new CacheData<>(); newCache.setData(newData); newCache.setExpireTime(System.currentTimeMillis() + expireTime); redisTemplate.opsForValue().set(cacheKey, newCache); }); }
return data; }
|
这种方式的好处是不会有缓存击穿的问题,但缺点是可能会返回过期数据
缓存雪崩
什么是缓存雪崩
缓存雪崩是指大量的缓存在同一时间过期,或者Redis服务挂了,导致所有请求都打到数据库
解决方案
方案一:设置不同的过期时间
给不同的key设置不同的过期时间,避免同时过期:
1 2 3 4 5 6
| public void cacheUser(User user) { String key = "user:" + user.getId(); long expireTime = 30 + (long) (Math.random() * 10 - 5); redisTemplate.opsForValue().set(key, user, expireTime, TimeUnit.MINUTES); }
|
方案二:缓存预热
系统启动时,提前把热点数据加载到缓存中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| @Component public class CacheWarmupService implements ApplicationRunner {
@Autowired UserService userService;
@Autowired RedisTemplate<String, Object> redisTemplate;
@Override public void run(ApplicationArguments args) { List<User> hotUsers = userService.getHotUsers();
for (User user : hotUsers) { String key = "user:" + user.getId(); redisTemplate.opsForValue().set(key, user, 1, TimeUnit.HOURS); } } }
|
方案三:Redis高可用
搭建Redis集群或者哨兵模式,保证Redis服务的高可用:
1 2 3 4 5 6 7 8
| spring: redis: sentinel: master: mymaster nodes: - 127.0.0.1:26379 - 127.0.0.1:26380 - 127.0.0.1:26381
|
方案四:多级缓存
使用本地缓存 + Redis缓存的多级架构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| @Service public class UserService {
@Autowired RedisTemplate<String, Object> redisTemplate;
private final Cache<Long, User> localCache = Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(5, TimeUnit.MINUTES) .build();
public User getUserById(Long id) { User user = localCache.getIfPresent(id); if (user != null) { return user; }
String key = "user:" + id; user = (User) redisTemplate.opsForValue().get(key); if (user != null) { localCache.put(id, user); return user; }
user = userMapper.selectById(id); if (user != null) { localCache.put(id, user); redisTemplate.opsForValue().set(key, user, 30, TimeUnit.MINUTES); }
return user; } }
|
即使Redis挂了,本地缓存还能顶一段时间
总结
这三个问题虽然听起来很像,但其实是不同场景:
- 缓存穿透:查询不存在的数据,用布隆过滤器或者缓存空对象
- 缓存击穿:热点数据过期,用互斥锁或者逻辑过期
- 缓存雪崩:大量缓存同时过期,用随机过期时间或者多级缓存
实际使用中,往往是多种方案组合使用,根据业务场景选择合适的方案
暂时就先记录这么多