缓存作为应对高并发大流量的神兵利器,如果使用不当,可能会给系统造成致命一击。
缓存穿透
什么叫缓存穿透
缓存穿透:简而言之就是查询缓存系统和后端系统都不存在的数据。如果这类查询并发量很大,将会对后端存储系统造成很大压力。
如何避免缓存穿透
造成缓存穿透根本原因:空查询。前端系统不知道所查数据到底存不存在,导致不必要查询。造成空查询的原因主要有两个:
如何解决空查询呢?
避免查库有两个条件:
- 缓存命中,则不需要查库
 
- 事先知道库中不存在,则不需要查库
 
解决方案
针对第一个条件
- 缓存空值
 
如果查询数据库不存在,我们之前的操作就不会进行缓存,这里我们仍然缓存空对象
。之后再访问这个数据将会从缓存中获取,保护了后端数据源。
缓存空对象会有两个问题:
- 空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间 ( 如果是攻击,问题更严重 ),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。
 
- 缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为 5 分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。
 
注意:采用缓存空值策略,只能避免第二次空查询,第一次还是会进行查库操作。
针对第二个条件
- bloom filter(布隆过滤器)
 
根据存储层数据构建布隆过滤器,在进行查询操作之前先通过bloom filter判断是否存在,如果存在则继续查询操作,不存在,则直接返回,避免空查询。
采用布隆过滤器可能会存在以下问题:
- 占用部分内存空间,因为要将数据库中的数据全量构造出一个bitmap
 
- 存在误判的情况,比如某个key对应的数据其实不存在,但通过bloomfilter判断结果可能存在,这时只需进行一次查库操作,毕竟这种误判率比较低。
 
- 无法删除:即使数据库中删除该数据,也无法将其从bloomfilter中删除,只能重新构建。
 
使用场景:缓存命中率不高,如下场景:
- 电商客户咨询场景:系统查询最近咨询客服分配给改客户,如无,则随机分配,且这里客户->最近咨询客服对应信息只存储7天。这类场景缓存命中率不高。
 
- 电商商品推荐场景:针对老用户,系统根据用户购买记录进行商品推荐,新用户则没有。用户登录网站系统查询是否存在推荐数据场景,命中率不高。
 
缓存雪崩
什么叫缓存雪崩
缓存雪崩: 简而言之就是缓存不可用或失效,导致所有的查询操作都落到后端存储系统,对后端存储系统造成很大压力,严重时可能会冲垮存储系统,产生连锁反应,最终导致服务不可用。
如何防止雪崩发生
要避免缓存雪崩,首先要清楚雪崩产生的根本原因:所有缓存在同一个时间段同时失效或不可用,导致同一时间所有查询操作都落到存储层。
避免过多查库请求有两个条件:
- 不要让缓存在同一时间段失效即始终有部分缓存可能
 
- 控制查库请求,只允许少量查库操作
 
解决方案
针对以上两个条件在业务代码层面可采取以下策略:
- 针对不同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){                          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) {                          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,暂时到这。