缓存的常见问题
1. 缓存雪崩
缓存系统的IOPS比数据库高很多,因此需要注意短时间内的大量缓存失效的情况。这种情况一旦发生,可能就会在瞬间有大量的数据需要回源到数据库查询,对数据库造成极大的压力,极限情况下甚至导致后端数据库直接崩溃。这就是我们说的缓存雪崩。
产生雪崩的原因有两种:
- 缓存系统本身不可用,导致大量请求直接回源到数据库
- 应用设计层面大量的Key在同一时间过期,导致大量的数据回源
- 解决方法
- 差异化缓存过期时间
- 不让大量的Key在同一时间过期
- 初始化缓存的时候,设置缓存的过期时间为30秒 + 10秒以内的随机延迟(扰动值)。这样key就不会集中在30秒这个时刻过期,而是会分散在30 - 40秒之间
- 让缓存不主动过期
- 初始化缓存数据的时候设置缓存永不过期,然后启动一个后台线程30秒一次定时将所有数据更新到缓存,通过适当的休眠,控制从数据库更新数据的频率,降低数据库的压力
- 如果无法全量缓存所有的数据,那么就无法使用该种方案
- 差异化缓存过期时间
- 解决方法
// 差异化缓存过期时间
@PostConstruct
public void rightInit1() {
//这次缓存的过期时间是30秒+10秒内的随机延迟
IntStream.rangeClosed(1, 1000).forEach(i -> stringRedisTemplate.opsForValue().set("city" + i, getCityFromDb(i), 30 + ThreadLocalRandom.current().nextInt(10), TimeUnit.SECONDS));
log.info("Cache init finished");
//同样1秒一次输出数据库QPS:
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
log.info("DB QPS : {}", atomicInteger.getAndSet(0));
}, 0, 1, TimeUnit.SECONDS);
}
// 让缓存不主动过期
@PostConstruct
public void rightInit2() throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(1);
//每隔30秒全量更新一次缓存
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
IntStream.rangeClosed(1, 1000).forEach(i -> {
String data = getCityFromDb(i);
//模拟更新缓存需要一定的时间
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) { }
if (!StringUtils.isEmpty(data)) {
//缓存永不过期,被动更新
stringRedisTemplate.opsForValue().set("city" + i, data);
}
});
log.info("Cache update finished");
//启动程序的时候需要等待首次更新缓存完成
countDownLatch.countDown();
}, 0, 30, TimeUnit.SECONDS);
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
log.info("DB QPS : {}", atomicInteger.getAndSet(0));
}, 0, 1, TimeUnit.SECONDS);
countDownLatch.await();
}
2. 缓存击穿
在某些Key属于极端热点数据并且并发量很大的情况下,如果这个Key过期,可能会在某个瞬间出现大量的并发请求同时回源,相当于大量的并发请求直接打到了数据库。这就是我们所说的缓存击穿或缓存并发问题。
如果说回源操作比较昂贵的话,那么这种并发就不能忽略不计了。可以考虑使用锁机制来限制回源的并发,或者可以使用类似Semaphore的工具来限制并发数,比如限制为10.这样子既限制了回源并发数不至于太大,又能够使得一定量的线程可以同时回源。
3. 缓存穿透
缓存穿透指的是实际上缓存里有key 和value,但是其value可能为空,如果没做正确处理,那我们的逻辑可能会认为没有对当前的key做好缓存,会对所有的请求都回源到数据库上,这就会给数据库造成压力了。
针对这种问题,可以用以下方案解决:
对于不存在的数据,同样设置一个特殊的Value到缓存当中,比如NODATA,这样子就不会有缓存穿透的问题
- 可能会将大量无效的数据加入到缓存当中
使用布隆过滤器
- 放在缓存数据读取前先进行过滤操作
- Google Guava BloomFilter
private BloomFilter<Integer> bloomFilter;
@PostConstruct
public void init() {
//创建布隆过滤器,元素数量10000,期望误判率1%
bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 10000, 0.01);
//填充布隆过滤器
IntStream.rangeClosed(1, 10000).forEach(bloomFilter::put);
}
@GetMapping("right2")
public String right2(@RequestParam("id") int id) {
String data = "";
//通过布隆过滤器先判断
if (bloomFilter.mightContain(id)) {
String key = "user" + id;
//走缓存查询
data = stringRedisTemplate.opsForValue().get(key);
if (StringUtils.isEmpty(data)) {
//走数据库查询
data = getCityFromDb(id);
stringRedisTemplate.opsForValue().set(key, data, 30, TimeUnit.SECONDS);
}
}
return data;
}
4. 缓存数据同步策略
- 当原始数据被修改了以后,我们很可能会采用主动更新缓存的策略
- 可能的策略有
- 先更新缓存,再更新数据库
- 不可行
- 数据库操作失败是有可能的,会导致缓存和数据库当中的数据不一致
- 先更新数据库,再更新缓存
- 不可行
- 多线程情况下数据库中更新的顺序和缓存更新的顺序会不同,可能会导致旧数据最后到,导致问题的出现
- 先删除缓存,再更新数据库,访问的时候按需加载数据到缓存当中
- 很可能删除缓存以后还没来得及更新数据库,就有另外一个线程先读取了旧值到缓存中
- 先更新数据库,再删除缓存,访问的时候按需加载数据到缓存当中
- 出现缓存不一致的概率很低
- 先更新缓存,再更新数据库
- 可能的策略有
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 stone2paul@gmail.com
文章标题:缓存的常见问题
文章字数:1.3k
本文作者:Leilei Chen
发布时间:2020-10-07, 06:55:09
最后更新:2020-10-07, 06:55:54
原始链接:https://www.llchen60.com/%E7%BC%93%E5%AD%98%E7%9A%84%E5%B8%B8%E8%A7%81%E9%97%AE%E9%A2%98/版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。