缓存作为应对高并发大流量的神兵利器,如果使用不当,可能会给系统造成致命一击。
缓存穿透
什么叫缓存穿透
缓存穿透:简而言之就是查询缓存系统和后端系统都不存在的数据。如果这类查询并发量很大,将会对后端存储系统造成很大压力。
如何避免缓存穿透
造成缓存穿透根本原因:空查询。前端系统不知道所查数据到底存不存在,导致不必要查询。造成空查询的原因主要有两个:
如何解决空查询呢?
避免查库有两个条件:
- 缓存命中,则不需要查库
- 事先知道库中不存在,则不需要查库
解决方案
针对第一个条件
- 缓存空值
如果查询数据库不存在,我们之前的操作就不会进行缓存,这里我们仍然缓存空对象
。之后再访问这个数据将会从缓存中获取,保护了后端数据源。
缓存空对象会有两个问题:
- 空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间 ( 如果是攻击,问题更严重 ),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。
- 缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为 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,暂时到这。