## 缓存架构之防雪崩设计

---

使用缓存时有三个目标：

* 第一，加快用户访问速度，提高用户体验
* 第二，降低后端负载，减少潜在的风险，保证系统平稳
* 第三，保证数据“尽可能”及时更新

### 缓存穿透原因

缓存穿透是指查询一个根本不存在的数据，缓存层和存储层都不会命中，但是出于容错的考虑，如果从存储层查不到数据则不写入缓存层

* 缓存层不命中
* 存储层不命中，所以不将空结果写回缓存
* 返回空结果

**缓存穿透将导致不存在的数据每次请求都要到存储层去查询，失去了缓存保护后端存储的意义。**

缓存穿透问题可能会使后端存储负载加大，由于很多后端存储不具备高并发性，甚至可能造成后端存储宕掉。通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数，如果发现大量存储层空命中，可能就是出现了缓存穿透问题。

造成缓存穿透的基本有两个：

* 业务自身代码或者数据出现问题
* 一些恶意攻击、爬虫等造成大量空命中

### 缓存穿透的解决方法

#### 1）缓存空对象

当存储层不命中后，仍然将空对象保留到缓存层中，之后再访问这个数据将会从缓存中获取，保护了后端数据源。


缓存空对象会有两个问题：

* 空值做了缓存，意味着缓存层中存了更多的键，需要更多的内存空间 ( 如果是攻击，问题更严重 )，比较有效的方法是针对这类数据设置一个较短的过期时间，让其自动剔除。

* 缓存层和存储层的数据会有一段时间窗口的不一致，可能会对业务有一定影响。例如过期时间设置为 5 分钟，如果此时存储层添加了这个数据，那此段时间就会出现缓存层和存储层数据的不一致，此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。



#### 2）布隆过滤器拦截

在访问缓存层和存储层之前，将存在的 key 用布隆过滤器提前保存起来，做第一层拦截。例如： 一个个性化推荐系统有 4 亿个用户 ID，每个小时算法工程师会根据每个用户之前历史行为做出来的个性化放到存储层中，但是最新的用户由于没有历史行为，就会发生缓存穿透的行为，为此可以将所有有个性化推荐数据的用户做成布隆过滤器。如果布隆过滤器认为该用户 ID 不存在，那么就不会访问存储层，在一定程度保护了存储层。


有关布隆过滤器的相关知识，可以参考： https://en.wikipedia.org/wiki/Bloom_filter

可以利用 Redis 的 Bitmaps 实现布隆过滤器，GitHub 上已经开源了类似的方案，读者可以进行参考：
https://github.com/erikdubbelboer/Redis-Lua-scaling-bloom-filter

这种方法适用于数据命中不高，数据相对固定实时性低（通常是数据集较大）的应用场景，代码维护较为复杂，但是缓存空间占用少。


### 缓存雪崩问题优化

预防和解决缓存雪崩问题，可以从以下三个方面进行着手。

* 1）保证缓存层服务高可用性。

和飞机都有多个引擎一样，如果缓存层设计成高可用的，即使个别节点、个别机器、甚至是机房宕掉，依然可以提供服务

* 2）依赖隔离组件为后端限流并降级。

无论是缓存层还是存储层都会有出错的概率，可以将它们视同为资源。作为并发量较大的系统，假如有一个资源不可用，可能会造成线程全部 hang 在这个资源上，造成整个系统不可用。降级在高并发系统中是非常正常的：比如推荐服务中，如果个性化推荐服务不可用，可以降级补充热点数据，不至于造成前端页面是开天窗。

在实际项目中，我们需要对重要的资源 ( 例如 Redis、 MySQL、 Hbase、外部接口 ) 都进行隔离，让每种资源都单独运行在自己的线程池中，即使个别资源出现了问题，对其他服务没有影响。但是线程池如何管理，比如如何关闭资源池，开启资源池，资源池阀值管理，这些做起来还是相当复杂的，这里推荐一个 Java 依赖隔离工具 Hystrix(https://github.com/Netflix/Hystrix)

* 3）提前演练。在项目上线前，演练缓存层宕掉后，应用以及后端的负载情况以及可能出现的问题，在此基础上做一些预案设定。


### 缓存热点 key 重建优化

开发人员使用缓存 + 过期时间的策略既可以加速数据读写，又保证数据的定期更新，这种模式基本能够满足绝大部分需求。但是有两个问题如果同时出现，可能就会对应用造成致命的危害：

*  当前 key 是一个热点 key( 例如一个热门的娱乐新闻），并发量非常大。
*  重建缓存不能在短时间完成，可能是一个复杂计算，例如复杂的 SQL、多次 IO、多个依赖等。

在缓存失效的瞬间，有大量线程来重建缓存，造成后端负载加大，甚至可能会让应用崩溃。

解决思路：

* 1）互斥锁 (mutex key)

只允许一个线程重建缓存，其他线程等待重建缓存的线程执行完，重新从缓存获取数据即可

* 2）永远不过期，“永远不过期”包含两层意思：

	* 从缓存层面来看，确实没有设置过期时间，所以不会出现热点 key 过期后产生的问题，也就是“物理”不过期。
	* 从功能层面来看，为每个 value 设置一个逻辑过期时间，当发现超过逻辑过期时间后，会使用单独的线程去构建缓存。


方案比较：

* 互斥锁 (mutex key)：这种方案思路比较简单，但是存在一定的隐患，如果构建缓存过程出现问题或者时间较长，可能会存在死锁和线程池阻塞的风险，但是这种方法能够较好的降低后端存储负载并在一致性上做的比较好。
* " 永远不过期 "：这种方案由于没有设置真正的过期时间，实际上已经不存在热点 key 产生的一系列危害，但是会存在数据不一致的情况，同时代码复杂度会增大。
