目录
- 1. 缓存击穿
- 2. 常见解决方案
- 3.single flight方式
- 3.1 模拟业务场景
- 3.2 使用single flight的方式
缓存雪崩、缓存击穿、缓存穿透不单单是缓存领域的经典场景,更是面试当牛马时必备(背)八股文。
我们来讨论下缓存击穿场景下的解决方案。
1. 缓存击穿
高并发场景下,某个缓存到了过期时间,自动失效,导致大量请求在该缓存中查询不到值,会直接请求数据库进行查询,连接过多可能会导致数据库压力过大无法响应,从而导致系统宕机。
2. 常见解决方案
- 缓存永不过期
既然缓存过期会导致缓存,我们可以让它没机会过期,在设置缓存过期时间时设置为永不过期就好了。
这种方式简单且方便理解,但缺点也明显。
首先缓存本身不是做永久性数据存储,要不然也不会叫做’缓’存,缓存一般使用的是内存,相对于磁盘来说是一种昂贵的资源,当需要缓存的数据很多时,永不过期的方式弊大于利。
从业务层面来说,很多时候缓存的数据都是热门数据,比如说活动页,大促商品,会吸引大量请求,需要缓存缓解数据库压力,如果活动结束,大促结束,这些数据的请求急剧降低,无需在缓存中存在,永不过期就不是理想的方案。
- 缓存一个空值
缓存失效时,可能是因为DB的数据已删除,为了保证一致性,缓存中的数据也会删除,此时如果大量查询进来,缓存中无数据,也会打到DB。
缓存一个空值对上述场景可减轻DB压力。
- 加分布式锁
要保证只有一个请求查询DB,显而易见的一个方案就是加锁,对于查询DB的函数,加上分布式锁来控制查询,当有请求获取到锁时,其他请求只能轮询等待。
这样当然也有很大弊端,需要不断的释放锁获取锁,对锁进行整个生命周期的管理。另外加锁也会对查询的并发带来很大的降低。
3.single flight方式
single flight设计思想,Go语言开发者应该都很熟悉,譬如go-zero框架中的, core/syncx/singleflight.go
,
将并发请求合并成一个请求,以减少对下层服务的压力。
将single flight应用到缓存击穿
场景上,基本思想就是:
确保在缓存失效后,只有一个线程去加载数据,其余线程等待该线程完成加载后直接使用其结果。
3.1 模拟业务场景
简单用业务代码模拟一个业务场景:
优先从缓存中查询数据,如果缓存不存在再查询DB,缓存设置一定的过期时间。
代码如下:
public class CacheExpired {
private final static JedisPool jedisPool = new JedisPool("localhost", 6379);
public static void main(String[] args) {
//初始化缓存
try (Jedis jedis = jedisPool.getResource()) {
jedis.psetex("key", 300, "value");
}
CacheExpired cacheExpired = new CacheExpired();
ExecutorService executorService = Executors.newFixedThreadPool(5);
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10; i++) {
Future<String> result = executorService.submit(() -> {
//先从缓存中获取
String s = cacheExpired.loadFromCache();
if (s != null) {
return s;
}
//缓存中无数据时,再从DB中获取。
s = cacheExpired.loadFromDB();
return s;
});
futures.add(result);
}
for (Future<String> future : futures) {
try {
System.out.println(future.get());
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
}
public String loadFromDB() {
try {
//从db获取数据是个耗时的操作
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
//更新缓存
try (Jedis jedis = jedisPool.getResource()) {
jedis.psetex("key", 200, "value");
}
return Thread.currentThread().getName() + ":从db获取数据成功";
}
public String loadFromCache() {
try (Jedis jedis = jedisPool.getResource()) {
//模拟从缓存中获取数据
Thread.sleep(100);
String cachedValue = jedis.get("key");
if (cachedValue != null) {
return Thread.currentThread().getName() + ":从缓存获取数据成功";
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(Thread.currentThread().getName() + ":缓存中无数据");
return null;
}
}
当缓存失效时,如上,高并发场景下,DB将承担所有的查询请求,会给DB带来巨大的压力,造成缓存击穿。
3.2 使用single flight的方式
这里讨论使用single flight的方式主要就是为了替换掉加锁的逻辑,需要保证以下两点:
1.只会有一个请求查询DB
2.其他请求需要获取第一个请求查询DB后的数据
总结起来就是等待计算。
恰好JDK中包含有支持此逻辑的功能:Future
。
Future表示异步计算的结果,Future提供了多个方法用来校验执行计算的结果是否完成,并且等待计算的完成,在计算完成之前,会一直阻塞等待。
对于上面要保证的两点,可以使用Map + Future
的方式来实现。
Map用来缓存第一次请求,Key是请求参数,Value为Future包装的异步计算的结果。
后续的请求根据Key获取到第一次请求查询封装的Future
,然后通过Future.get()
,获取第一次查询DB的结果。
如下String singleFlight()
中
- 请求进来时,
判断Map中是否有相同的请求
。 - 如果没有包装成
FutureTask
放入Map
中。 - 执行
FutureTask
的run()
方法。 - 如果其他请求此时进来,
Map
中已有相同请求在执行,其他请求会在Future.get()
处阻塞等待第一次请求的结果。
为了更好的观测执行效果,我们可以将从redis中获取缓存的逻辑去掉,直接全部请求DB。
public class CacheExpired {
// private final static JedisPool jedisPool = new JedisPool("localhost", 6379);
public static void main(String[] args) {
// //初始化缓存
// try (Jedis jedis = jedisPool.getResource()) {
// jedis.psetex("key", 300, "value");
// }
CacheExpired cacheExpired = new CacheExpired();
ExecutorService executorService = Executors.newFixedThreadPool(5);
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10; i++) {
Future<String> result = executorService.submit(() -> {
// //先从缓存中获取
// String s = cacheExpired.loadFromCache();
// if (s != null) {
// return s;
// }
//全部从DB中获取。
String s = cacheExpired.singleFlight();
return s;
});
futures.add(result);
}
for (Future<String> future : futures) {
try {
System.out.println(future.get());
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
}
//存储正在进行或者已完成的请求,如果多个请求同时进来,可保证只有一个请求回去查询DB
private final ConcurrentHashMap<String, Future<String>> cache = new ConcurrentHashMap<>();
public String singleFlight() throws Exception {
while (true) {
Future<String> future = cache.get("key");
if (future == null) {
Callable<String> callable = () -> {
loadFromDB();
return "执行完成";
};
FutureTask<String> futureTask = new FutureTask<>(callable);
future = cache.putIfAbsent("key", futureTask);
if (future == null) {
future = futureTask;
futureTask.run(); // 执行加载任务
}
}
try {
return future.get(); // 等待结果
} catch (CancellationException e) {
cache.remove("key", future);
System.out.println(e);
} catch (ExecutionException e) {
throw new Exception(e.getCause());
}
}
}
public void loadFromDB() {
try {
//从db获取数据是个耗时的操作
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(Thread.currentThread().getName() + ":从db获取数据成功");
}
}
执行结果如下,可以看到只有第一次查询请求达到了DB。
当然上述方案也是有缺点的,比如Map中数据存储请求数据的时效,需不需要自动过期删除,Map本身不支持自动过期,需要根据业务需求来处理Map中缓存的数据。