使用Redis缓存可能遇到的常见问题(缓存穿透、缓存雪崩、并发竞争双写一致性)

一、缓存穿透

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

2)存储层不命中,不将空结果写回缓存。    

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

造成缓存穿透的基本原因有两个:

①自身业务代码或者数据出现问题

②一些恶意攻击、爬虫等造成大量空命中

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

1.缓存空对象

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

下面是缓存空对象的代码实现:

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;
    }
}

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

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

②缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为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这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少。

(3)两种方案的对比

二、缓存雪崩

缓存层承载着大量请求,有效地保护了存储层。但是如果缓存层由于某些原因不能提供服务,比如缓存服务挂掉了或者某一时间缓存集中失效,于是所有的请求都会直达存储层,存储层的访问暴增,造成存储层被拖垮的情况。缓存雪崩的英文原意是stampeding herd(奔逃的野牛),指的是缓存层宕掉后,流量会像奔逃的野牛一样,打向后端存储。

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

1.提前演练

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

2.保证缓存服务高可用性

针对缓存服务可能挂掉的原因,我们可以将缓存层设计成高可用,即使个别节点宕掉,依然可以提供服务,就像飞机有多个引擎一样。Redis可以Redis Sentinel和Redis Cluster实现高可用。

3.依赖隔离组件为后端限流并降级

无论是缓存层还是存储层都会有出错的概率,可以将它们视同为资源。作为并发量较大的系统,假如有一个资源不可用,可能会造成线程全部阻塞在这个资源上,造成整个系统不可用。降级机制在高并发系统中是非常普遍的:比如推荐服务中,如果个性化推荐服务不可用,可以降级补充热点数据,不至于造成前端页面是开天窗。在实际项目中,我们需要对重要的资源(例如Redis、MySQL、HBase、外部接口)都进行隔离,让每种资源都单独运行在自己的线程池中,即使个别资源出现了问题,对其他服务没有影响。但是线程池如何管理,比如如何关闭资源池、开启资源池、资源池阀值管理,这些做起来还是相当复杂的。这里推荐Hystrix,Hystrix是解决依赖隔离的利器,只适用于Java应用。

 

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

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

事中:
①高可用集群(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)热点商品的路由问题,导致请求的倾斜

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

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

但是的确可能某些机器的负载会高一些

 

posted @ 2019-04-20 21:01  静水楼台/Java部落阁  阅读(1280)  评论(0编辑  收藏  举报