缓存与数据库一致性之三:缓存穿透、缓存雪崩、key重建方案

一、缓存穿透预防及优化 

缓存穿透是指查询一个根本不存在的数据,缓存层和存储层都不会命中,但是出于容错的考虑,如果从存储层查不到数据则不写入缓存层,如图 11-3 所示整个过程分为如下 3 步:

  1. 缓存层不命中
  2. 存储层不命中,所以不将空结果写回缓存
  3. 返回空结果 

缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储的意义。缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高并发性,甚至可能造成后端存储宕掉。通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。 
造成缓存穿透的基本有两个:

第一,业务自身代码或者数据出现问题,

第二,一些恶意攻击、爬虫等造成大量空命中,下面我们来看一下如何解决缓存穿透问题。

 如何解决缓存穿透问题,实际上这是一个开放问题,有很多解决方法。下面是两种典型的解决方案:

二、缓存穿透的解决方法

1)缓存空对象

如下图所示,当第 2 步存储层不命中后,仍然将空对象保留到缓存层中,之后再访问这个数据将会从缓存中获取,保护了后端数据源。 

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

String get(String key) {
    // 从缓存中获取数据
    String cacheValue = cache.get(key);
    // 缓存为空
    if (StringUtils.isBlank(cacheValue)) {
        // 从存储中获取
        String storageValue = storage.get(key);
        cache.set(key, storageValue);
        // 如果存储数据为空,需要设置一个过期时间(300秒)
        if (storageValue == null) {
            cache.expire(key, 60 * 5);
        }
        return storageValue;
    } else {
        // 缓存非空
        return cacheValue;
    }
}

2)布隆过滤器拦截

如下图所示,在访问缓存层和存储层之前,将存在的 key 用布隆过滤器提前保存起来,做第一层拦截。

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

开发提示: 
有关布隆过滤器的相关知识,可以参考: Bloom Filter(布隆过滤器)的概念和原理

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

使用布隆过滤器应对穿透问题 
这种方法适用于数据命中不高,数据相对固定实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少。 

************************************************布隆过滤器:************************************************

本质上布隆过滤器是一种数据结构,比较巧妙的概率型数据结构(probabilistic data structure),特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”。

布隆过滤器是一个 bit 向量或者说 bit 数组,长这样:

 对于每个key,只需要k个比特位,每个存储一个标志,用来判断key是否在集合中。
    首先需要k个hash函数,每个函数可以把key散列成为1个整数
    初始化时,需要一个长度为n比特的数组,每个比特位初始化为0
    某个key加入集合时,用k个hash函数计算出k个散列值,并把数组中对应的比特位置为1
    判断某个key是否在集合时,用k个hash函数计算出k个散列值,并查询数组中对应的比特位,如果所有的比特位都是1,认为在集合中。

3)两种方案对比

前面介绍了缓存穿透问题的两种解决方法 ( 实际上这个问题是一个开放问题,有很多解决方法 ),下面通过下表从适用场景和维护成本两个方面对两种方案进行分析。 
缓存空对象和布隆过滤器方案对比 

二、缓存雪崩问题优化 

从下图可以很清晰出什么是缓存雪崩:由于缓存层承载着大量请求,有效的保护了存储层,但是如果缓存层由于某些原因整体不能提供服务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会挂掉的情况。缓存雪崩的英文原意是 stampeding herd(奔逃的野牛),指的是缓存层宕掉后,流量会像奔逃的野牛一样,打向后端存储。 

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

1)提前演练

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

2)保证缓存层服务高可用性

和飞机都有多个引擎一样,如果缓存层设计成高可用的,即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务,例如前面介绍过的 Redis Sentinel 和 Redis Cluster 都实现了高可用。 

3)请求db限流(依赖隔离组件为后端限流并降级

无论是缓存层还是存储层都会有出错的概率,可以将它们视同为资源。作为并发量较大的系统,假如有一个资源不可用,可能会造成线程全部 hang(阻塞)在这个资源上,造成整个系统不可用。降级在高并发系统中是非常普遍的:比如推荐服务中,如果个性化推荐服务不可用,可以降级补充热点数据,不至于造成前端页面是开天窗。 在实际项目中,我们需要对重要的资源 ( 例如 Redis、 MySQL、 Hbase、外部接口 ) 都进行隔离,让每种资源都单独运行在自己的线程池中,即使个别资源出现了问题,对其他服务没有影响。

3.1、限流:

通过限制 DB 的每秒请求数,避免把 DB 也打挂了。这样至少能有两个好处:

  1. 可能有一部分用户,还可以使用,系统还没死透。

  2. 未来缓存服务恢复后,系统立即就已经恢复,无需在处理 DB 也挂掉的情况。

如果我们使用 Java ,则可以使用 Guava RateLimiter、Sentinel 实现限流的功能。

3.2、服务降级:

如果请求被限流,或者请求 DB 超时,我们可以服务降级,提供一些默认的值,或者友情提示,甚至空白的值也行。

如果我们使用 Java ,则可以使用 Hystrix、Sentinel 实现限流的功能。

针对缓存集中失效的情况,可以为缓存的过期时间加上一个随机值,防止缓存集中失效。

【中华石杉】
事前:
项目上线前提前演练,做好预案;

事中:
①高可用集群(sentinel/cluster),避免缓存全盘崩溃。
②本地缓存+限流&降级(hystrix),避免mysql被拖垮。(可以通过限流组件设置只让部分请求进入,比如来了5000请求,让2000通过,进入数据库,剩余请求走降级,限流组件会调用你自己开发好的一个降级组件,返回一些默认值,友情提示等)
【这样能保证数据库不会死。能保证2/5的请求可以被处理,也就是说用户点5次可能有几次刷不出来页面。】

事后:
借助持久化,快速恢复。

三、缓存并发竞争

多客户端同时并发写一个key,可能本来应该先到的数据后到了,导致数据版本错了。或者是多客户端同时获取一个key,修改值之后再写回去,只要顺序错了,数据就错了。

解决方法:

1.分布式锁+时间戳

主要是使用一个分布式锁,大家去抢锁,抢到锁就做set操作。加锁的目的实际上就是把并行读写改成串行读写的方式,从而来避免资源竞争。

【中华石杉】
我们线上常用的是zookeeper的分布式锁,用redis自身的分布式锁也可以实现,但是我们很少这样做。

另外,Redis自己就有天然解决这个问题的CAS类的乐观锁方案。基于redis的分布式锁主要用到的是setnx

2.利用消息队列

在并发量过大的情况下,可以通过消息中间件进行处理,把并行读写进行串行化。把Redis.set操作放在队列中使其串行化,必须的一个一个执行。这种方式在一些高并发的场景中算是一种通用的解决方案。

四、缓存与数据库双写一致性

1.Cache-Aside Pattern

最经典的缓存+数据库读写的模式,Cache-Aside Pattern,可以参考微软官网的介绍:Cache-Aside Pattern(翻译)

Cache-Aside Pattern总结起来就是下面两句话:

(1)读的时候:先读缓存,如果缓存中没有,就读数据库,然后取出数据后放入缓存,同时返回响应

(2)更新的时候:先删除缓存,然后再更新数据库

2.操作缓存时是先操作数据库还是先操作缓存?操作缓存是删除缓存,还是更新缓存?

参考:究竟先操作缓存,还是数据库?

读缓存的情况,应该先读缓存,再读数据库,这点是没有疑问的 。而写缓存的情况就有点复杂了,可以看下面这幅图。

对于删除缓存的方案,当缓存删除后,客户端如果再次需要访问相关数据时,由于缓存中没有,就会通过走数据库,最终再放入缓存中。

另外需要说明的是,缓存中的数据有时可能就是一张表中的某个字段的值,而有时可能是多张表进行复杂计算后生成的一个最终值。

 

3.库存案例

以库存服务来说明说明更新缓存的操作。库存服务特点是实时性比较高,也会用到缓存,会将库存数据放在缓存中。

如何保证数据库和缓存中的库存数据的双写一致性?

(1)最初级的解决方案

也就是使用前面提到的:操作缓存时,先删除缓存,在更新数据库。

(2)将数据库与缓存更新与读取操作进行异步串行化

队列头部的写请求操作可能20ms完成,之后的读请求可能会hang住40ms,之后也可能花费20ms完成读操作。

复制代码
【中华石杉】
高并发的场景下,该解决方案要注意的问题
(1)读请求长时阻塞
由于读请求进行了非常轻度的异步化,所以一定要注意读超时的问题,每个读请求必须在超时时间范围内返回

该解决方案,最大的风险点在于说,可能数据更新很频繁,导致队列中积压了大量更新操作在里面,然后读请求会发生大量的超时,最后导致大量的请求直接走数据库。

务必通过一些模拟真实的测试,看看更新数据的频繁是怎样的

另外一点,因为一个队列中,可能会积压针对多个数据项的更新操作,因此需要根据自己的业务情况进行测试,可能需要部署多个服务,每个服务分摊一些数据的更新操作

如果一个内存队列里居然会挤压100个商品的库存修改操作,每隔库存修改操作要耗费10ms区完成,那么最后一个商品的读请求,可能等待10 * 100 = 1000ms = 1s后,才能得到数据,这个时候就导致读请求的长时阻塞

一定要做根据实际业务系统的运行情况,去进行一些压力测试,和模拟线上环境,去看看最繁忙的时候,内存队列可能会挤压多少更新操作,可能会导致最后一个更新操作对应的读请求,会hang多少时间,如果读请求在200ms返回,如果你计算过后,哪怕是最繁忙的时候,积压10个更新操作,最多等待200ms,那还可以的。

如果一个内存队列可能积压的更新操作特别多,那么你就要加机器,让每个机器上部署的服务实例处理更少的数据,那么每个内存队列中积压的更新操作就会越少

其实根据之前的项目经验,一般来说数据的写频率是很低的,因此实际上正常来说,在队列中积压的更新操作应该是很少的

针对读高并发,读缓存架构的项目,一般写请求相对读来说,是非常非常少的,每秒的QPS能到几百就不错了

一秒,500的写操作,5份,每200ms,就100个写操作

单机器,20个内存队列,每个内存队列,可能就积压5个写操作,每个写操作性能测试后,一般在20ms左右就完成

那么针对每个内存队列中的数据的读请求,也就最多hang一会儿,200ms以内肯定能返回了

写QPS扩大10倍,但是经过刚才的测算,就知道,单机支撑写QPS几百没问题,那么就扩容机器,扩容10倍的机器,10台机器,每个机器20个队列,200个队列

大部分的情况下,应该是这样的,大量的读请求过来,都是直接走缓存取到数据的

少量情况下,可能遇到读跟数据更新冲突的情况,如上所述,那么此时更新操作如果先入队列,之后可能会瞬间来了对这个数据大量的读请求,但是因为做了去重的优化,所以也就一个更新缓存的操作跟在它后面

等数据更新完了,读请求触发的缓存更新操作也完成,然后临时等待的读请求全部可以读到缓存中的数据

(2)读请求并发量过高

这里还必须做好压力测试,确保恰巧碰上上述情况的时候,还有一个风险,就是突然间大量读请求会在几十毫秒的延时hang在服务上,看服务能不能抗的住,需要多少机器才能抗住最大的极限情况的峰值

但是因为并不是所有的数据都在同一时间更新,缓存也不会同一时间失效,所以每次可能也就是少数数据的缓存失效了,然后那些数据对应的读请求过来,并发量应该也不会特别大

按1:99的比例计算读和写的请求,每秒5万的读QPS,可能只有500次更新操作

如果一秒有500的写QPS,那么要测算好,可能写操作影响的数据有500条,这500条数据在缓存中失效后,可能导致多少读请求,发送读请求到库存服务来,要求更新缓存

一般来说,1:1,1:2,1:3,每秒钟有1000个读请求,会hang在库存服务上,每个读请求最多hang多少时间,200ms就会返回

在同一时间最多hang住的可能也就是单机200个读请求,同时hang住

单机hang200个读请求,还是ok的

1:20,每秒更新500条数据,这500秒数据对应的读请求,会有20 * 500 = 1万

1万个读请求全部hang在库存服务上,就死定了

(3)【多服务实例部署的请求路由】

可能这个服务部署了多个实例,那么必须保证执行数据更新操作,以及执行缓存更新操作的请求,都通过nginx服务器路由到相同的服务实例上

(4)热点商品的路由问题,导致请求的倾斜

万一某个商品的读写请求特别高,全部打到相同的机器的相同的队列里面去了,可能造成某台机器的压力过大

就是说,因为只有在商品数据更新的时候才会清空缓存,然后才会导致读写并发,所以更新频率不是太高的话,这个问题的影响并不是特别大

但是的确可能某些机器的负载会高一些
复制代码

 

五、缓存击穿

开发人员使用缓存 + 过期时间的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。

但是有两个问题如果同时出现,可能就会对应用造成致命的危害:

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

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

 

  • 缓存被“击穿”的问题,和缓存“雪崩“”的区别在于,前者针对某一 KEY 缓存,后者则是很多 KEY 。

  • 缓存被“击穿”的问题,和缓存“穿透“”的区别在于,这个 KEY 是真实存在对应的值的。

 解决方案
要解决这个问题也不是很复杂,但是不能为了解决这个问题给系统带来更多的麻烦,所以需要制定如下目标:

    1. 减少重建缓存的次数
    2. 数据尽可能一致
    3. 较少的潜在危险

 

热点 key 失效后大量线程重建缓存的问题:解决方案有3种:

1)互斥锁 (mutex key)
此方法只允许一个线程重建缓存,使用互斥锁(jvm的lock,分布式锁等)让其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可,整个过程如图 : 

使用互斥锁重建缓存 
下面代码使用 Redis 的 setnx 命令实现上述功能。

(1) 从 Redis 获取数据,如果值不为空,则直接返回值,否则执行 (2.1) 和 (2.2)。 
(2) 如果 set(nx 和 ex) 结果为 true,说明此时没有其他线程重建缓存,那么当前线程执行缓存构建逻辑。 
(2.2) 如果 setnx(nx 和 ex) 结果为 false,说明此时已经有其他线程正在执行构建缓存的工作,那么当前线程将休息指定时间 ( 例如这里是 50 毫秒,取决于构建缓存的速度 ) 后,重新执行函数,直到获取到数据。 
2)永远不过期,逻辑过期(过期数据没有被缓存中及时清理)
“永远不过期”包含两层意思: 
从缓存层面来看,确实没有设置过期时间,所以不会出现热点 key 过期后产生的问题,也就是“物理”不过期。 
从功能层面来看,为每个 value 设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。

如果发现要过期,通过一个后台的异步线程进行缓存的构建,也就是“手动”过期。通过后台的异步线程,保证有且只有一个线程去查询 DB。 
整个过程如下图所示: 

” 永远不过期 ” 策略 
从实战看,此方法有效杜绝了热点 key 产生的问题,但唯一不足的就是重构缓存期间,会出现数据不一致的情况,这取决于应用方是否容忍这种不一致。下面代码使用 Redis 进行模拟: 

作为一个并发量较大的应用,在使用缓存时有三个目标:

  • 第一,加快用户访问速度,提高用户体验。
  • 第二,降低后端负载,减少潜在的风险,保证系统平稳。
  • 第三,保证数据“尽可能”及时更新。下面将按照这三个维度对上述两种解决方案进行分析。 
  1. 互斥锁 (mutex key):这种方案思路比较简单,但是存在一定的隐患,如果构建缓存过程出现问题或者时间较长,可能会存在死锁和线程池阻塞的风险,但是这种方法能够较好的降低后端存储负载并在一致性上做的比较好
  2. 永远不过期 ”-逻辑过期:这种方案由于没有设置真正的过期时间,实际上已经不存在热点 key 产生的一系列危害,但是会存在数据不一致的情况,同时代码复杂度会增大。

两种解决方法对比如下表所示。 

两种热点 key 的解决方法

public static String getData(String key) throws InterruptedException {
        //从Redis查询数据
        String result = getDataByKV(key);
        //参数校验
        if (StringUtils.isBlank(result)) {
            try {
                //获得锁
                if (reenLock.tryLock()) {
                    //去数据库查询
                    result = getDataByDB(key);
                    //校验
                    if (StringUtils.isNotBlank(result)) {
                        //插进缓存
                        setDataToKV(key, result);
                    }
                } else {
                    //睡一会再拿
                    Thread.sleep(100L);
                    result = getData(key);
                }
            } finally {
                //释放锁
                reenLock.unlock();
            }
        }
        return result;
    }

 

posted on 2014-06-17 09:15  duanxz  阅读(8478)  评论(0编辑  收藏  举报