缓存穿透及雪崩之常见解决方案

Catalogue
  1. 1. 缓存穿透
    1. 1.1. 什么叫缓存穿透
    2. 1.2. 如何避免缓存穿透
    3. 1.3. 解决方案
  2. 2. 缓存雪崩
    1. 2.1. 什么叫缓存雪崩
    2. 2.2. 如何防止雪崩发生
    3. 2.3. 解决方案
  3. 3. 案例分析

缓存作为应对高并发大流量的神兵利器,如果使用不当,可能会给系统造成致命一击。

缓存穿透

什么叫缓存穿透

缓存穿透:简而言之就是查询缓存系统和后端系统都不存在的数据。如果这类查询并发量很大,将会对后端存储系统造成很大压力。

如何避免缓存穿透

造成缓存穿透根本原因:空查询。前端系统不知道所查数据到底存不存在,导致不必要查询。造成空查询的原因主要有两个:

  • 代码设计或数据出现问题
  • 恶意攻击

如何解决空查询呢?
避免查库有两个条件:

  1. 缓存命中,则不需要查库
  2. 事先知道库中不存在,则不需要查库

解决方案

针对第一个条件

  1. 缓存空值

如果查询数据库不存在,我们之前的操作就不会进行缓存,这里我们仍然缓存空对象
。之后再访问这个数据将会从缓存中获取,保护了后端数据源。
缓存空对象会有两个问题:

  • 空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间 ( 如果是攻击,问题更严重 ),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。
  • 缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为 5 分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。

注意:采用缓存空值策略,只能避免第二次空查询,第一次还是会进行查库操作。

针对第二个条件

  1. bloom filter(布隆过滤器)

根据存储层数据构建布隆过滤器,在进行查询操作之前先通过bloom filter判断是否存在,如果存在则继续查询操作,不存在,则直接返回,避免空查询。
采用布隆过滤器可能会存在以下问题:

  • 占用部分内存空间,因为要将数据库中的数据全量构造出一个bitmap
  • 存在误判的情况,比如某个key对应的数据其实不存在,但通过bloomfilter判断结果可能存在,这时只需进行一次查库操作,毕竟这种误判率比较低。
  • 无法删除:即使数据库中删除该数据,也无法将其从bloomfilter中删除,只能重新构建。

使用场景:缓存命中率不高,如下场景:

  1. 电商客户咨询场景:系统查询最近咨询客服分配给改客户,如无,则随机分配,且这里客户->最近咨询客服对应信息只存储7天。这类场景缓存命中率不高。
  2. 电商商品推荐场景:针对老用户,系统根据用户购买记录进行商品推荐,新用户则没有。用户登录网站系统查询是否存在推荐数据场景,命中率不高。

缓存雪崩

什么叫缓存雪崩

缓存雪崩: 简而言之就是缓存不可用或失效,导致所有的查询操作都落到后端存储系统,对后端存储系统造成很大压力,严重时可能会冲垮存储系统,产生连锁反应,最终导致服务不可用。

如何防止雪崩发生

要避免缓存雪崩,首先要清楚雪崩产生的根本原因:所有缓存在同一个时间段同时失效或不可用,导致同一时间所有查询操作都落到存储层。
避免过多查库请求有两个条件:

  1. 不要让缓存在同一时间段失效即始终有部分缓存可能
  2. 控制查库请求,只允许少量查库操作

解决方案

针对以上两个条件在业务代码层面可采取以下策略:

  • 针对不同key设置不同失效时间,尽量将失效时间打散,不要聚集在一个时间段
  • 采取二级缓存策略:同一个数据,缓存两次,分别设置不同的失效时间,这样即使其中一个缓存失效,另一个仍然可用,注意:数据更新时要同时处理两个缓存。
  • 采用加锁或队列控制查库线程数。在缓存失效后,控制真正查库线程数。让一部分线程去查库,获取之后,存入缓存,后续查询直接从缓存获取。
  • 缓存预加载。在系统提供服务之前进行热点key缓存预加载,不至于系统启动之初,由于缓存还没存放,导致所有请求达到后端系统。
  • 缓存永不过期即不设置过期时间:不建议使用,1.造成数据不一致,2.浪费存储空间,可能会造成内存溢出。

在缓存系统架构层面,则尽量采用集群多副本方式保证缓存服务高可用,如redis可采用Redis Sentinel或者Redis Cluster保证缓存服务高可用。

案例分析

先给出通用的数据获取方式:

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
38
39
40
41
42
@Test
public void testGetData() {
String id = "3";
String data = getData(id);
LOGGER.info("get data:{}", data);
}
/**
* 获取数据:先缓存再DB
* @param id
* @return
*/
private String getData(String id) {
String key = KEY_PREFIX + id;;
String value = cacheUtil.getString(key);
if (StringUtils.isEmpty(value)){
value = getFromDB(id);
if (value != null) {
cacheUtil.setString(key, value);
}
LOGGER.info("get from DB:{}", value);
} else {
LOGGER.info("get from cache:{}", value);
}
return value;
}
/**
* 模拟从DB获取
* @param id
* @return
*/
private String getFromDB(String id) {
try {
Thread.sleep(2000L);
return mockDB.get(id);
} catch (InterruptedException e) {
e.printStackTrace();
}
return null;
}

一般我们获取数据,先查缓存,有责返回,无,则查库,再存缓存,返回。
以上getData方法就是一种非常普遍的写法,但有没问题呢?
举个例子:假如该查库操作比较耗时,需全表扫描,耗时2s,同时数据库最大连接数200,该系统平时并发量1000,假如某时刻缓存失效,此时,所有请求落到数据库。将达到1000*2的并发。

以上情况如果没有进行限制数据库连接,很有可能导致数据库挂掉。

那怎么怎么限制数据库连接数呢,加锁,互斥访问。
如下方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 获取数据:先缓存再DB
* @param id
* @return
*/
private synchronized String getData(String id) {
String key = KEY_PREFIX + id;;
String value = cacheUtil.getString(key);
if (StringUtils.isEmpty(value)){
value = getFromDB(id);
if (value != null) {
cacheUtil.setString(key, value);
}
LOGGER.info("get from DB:{}", value);
} else {
LOGGER.info("get from cache:{}", value);
}
return value;
}

给getData方法加上synchronized 关键字。
以上方式确实可以限制数据库连接数,防止雪崩,但还是存在问题。synchronized关键字的加入,导致所有线程同步访问该访问,就算查缓存也要互斥访问,大大降低了系统响应速度,不可取。

这是我们可能会想,那将锁粒度细化,采用如下方式加锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 获取数据:先缓存再DB
* @param id
* @return
*/
private String getData(String id) {
String key = KEY_PREFIX + id;;
String value = cacheUtil.getString(key);
if (StringUtils.isEmpty(value)){
synchronized (this){
value = getFromDB(id);
if (value != null) {
cacheUtil.setString(key, value);
}
LOGGER.info("get from DB:{}", value);
}
} else {
LOGGER.info("get from cache:{}", value);
}
return value;
}

是不是ok了呢?其实不然,synchronized (this)虽然控制了数据库连接的并发数,但是没有减少连接数,因为所有的查询线程都会发现缓存失效,然后跑到if中,等待查库。那如何解决呢,很简单:
double check

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
/**
* 获取数据:先缓存再DB
* @param id
* @return
*/
private String getData(String id) {
String key = KEY_PREFIX + id;;
String value = cacheUtil.getString(key);
if (StringUtils.isEmpty(value)){
synchronized (this){
//double check
value = cacheUtil.getString(key);
if (!StringUtils.isEmpty(value)) {
LOGGER.info("get from cache:{}", value);
return value;
}
value = getFromDB(id);
if (value != null) {
cacheUtil.setString(key, value);
}
LOGGER.info("get from DB:{}", value);
}
} else {
LOGGER.info("get from cache:{}", value);
}
return value;
}

以上还算比较好的解决了缓存雪崩问题,但是在分布式多实例的情况下,可能会出现重复更新缓存问题。

其实,针对最后一种方式,我们可以进行方法重构,getDate方法中,除了查库这条语句不太相同之外,其他代码都一样。所以,可以参考google guava cache加载策略,当缓存不存在时,调用cacheloader进行数据加载。重构之后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public <T> T getFromCache(String key, long expire, Class<T> tClass, CacheLoader<T> loader) {
T value = cacheUtil.get(key, tClass);
if (StringUtils.isEmpty(value)) {
synchronized (this) {
//double check
value = cacheUtil.get(key, tClass);
if (!StringUtils.isEmpty(value)) {
LOGGER.info("get from cache:{}", value);
return value;
}
value = loader.load();
if (value != null) {
cacheUtil.setWithExp(key, value, expire);
}
LOGGER.info("get from DB:{}", value);
}
} else {
LOGGER.info("get from cache:{}", value);
}
return value;
}

这里定义了一个CacheLoader接口,该接口内只包含一个load抽象方法,待子类具体实现。

1
2
3
4
5
public interface CacheLoader<T> {
T load();
}

以下是测试代码:

1
2
3
4
5
6
7
8
9
10
public User findUser(final Long id) {
String key = KEY_PREFIX + id;
User user = getFromCache(key, 3600L, User.class, new CacheLoader<User>() {
@Override
public User load() {
return getUserFromDB(id);
}
});
return user;
}

ok,暂时到这。

Comments