Redis常见问题

缓存雪崩

缓存雪崩是指在我们设置某些key的缓存时采用了相同的过期时间,导致在某一个时刻出现大规模的key失效,那么此时大量的流水请求时Redis出现Cache Miss,进而将这些请求全部转发到DB,DB瞬时压力过大导致崩溃;这就是缓存雪崩。

如何避免缓存雪崩:

1、在设置缓存数据的过期时间时,加上个随机值,防止同一时间大量数据过期现象发生。
2、如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中。
3、设置热点数据永远不过期,有更新操作就更新缓存就好了。

缓存穿透

缓存穿透是指查询一个缓存和数据库都一定不存在的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库中去查询,失去了缓存的意义。如果有恶意攻击者不断请求系统中不存在的数据,会导致短时间大量请求落在数据库上,造成数据库压力过大,甚至导致数据库承受不住而宕机崩溃。

如何避免缓存穿透:

1、最常见的则是采用布隆过滤器,如果布隆过滤器判定某个 key 不存在布隆过滤器中,那么就一定不存在,如果判定某个 key 存在,那么很大可能是存在(存在一定的误判率);于是我们可以在缓存之前再加一个布隆过滤器,将数据库中的所有key都存储在布隆过滤器中,在查询Redis前先去布隆过滤器查询 key 是否存在,如果不存在就直接返回,不让其访问数据库,从而避免了对数据库系统的查询压力。
2、后端接口层增加用户鉴权校验,参数做校验等。
3、单个IP每秒访问次数超过阈值直接拉黑IP。
4、不存在的key也写入到Redis中,即使从缓存取不到的数据,在数据库中也没有取到,这时也可以将这个key写入到Redis中,value为null,同时失效时间可以设置为15秒防止恶意攻击,后面再出现查询这个key的请求的时候,直接返回null,就不需要再查询数据库了;但是这种方法也有问题,假如传进来的这个不存在的key值每次都是随机的,那存进Redis也没有意义。

缓存击穿

缓存中存在某个设置了过期时间的热点key,且一直有大量并发的对这个key进行访问,当这个Key在失效的瞬间,这时候大并发会直接到达后端数据库,在这瞬间数据库可能引起数据库压力剧增甚至压垮;这种现象就叫做缓存击穿。

缓存击穿跟缓存雪崩有点像,但是又有一点不一样,缓存雪崩是大规模的缓存失效,而缓存击穿是指一个Key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个Key在失效的瞬间,持续的大并发就穿破缓存,请求直接到达数据库。

如何避免缓存击穿:

1、设置热点数据永远不过期,需要更新的话,直接更新缓存。
2、在缓存失效后,通过互斥锁或者队列来控制读数据写缓存的线程数量,比如某个key只允许一个线程查询数据和写缓存,其他线程等待。这种方式会阻塞其他的线程,此时系统的吞吐量会下降。

脑裂

脑裂是指因为网络原因,导致master节点、slave节点和 sentinel集群处于不用的网络分区,此时因为sentinel集群无法感知到master的存在,所以将slave节点提升为master节点此时存在两个不同的master节点就像一个大脑分裂成了两个。

集群脑裂问题中,如果客户端还在基于原来的master节点继续写入数据那么新的master节点将无法同步这些数据,当网络问题解决后sentinel集群将原先的master节点降为slave节点,此时再从新的master中同步数据将造成大量的数据丢失。

Redis处理方案是redis的配置文件中存在两个参数:
min-replicas-to-write 3 表示连接到master的最少slave数量
min-replicas-max-lag 10 表示slave连接到master的最大延迟时间

如果连接到master的slave数量 < 第一个参数 且 ping的延迟时间 <= 第二个参数那么master就会拒绝写请求,配置了这两个参数后如果发生了集群脑裂则原先的master节点接收到客户端的写入请求会拒绝就可以减少数据同步之后的数据丢失。

缓存和数据库的双写一致性

双写一致性:缓存跟数据库均更新数据,如何保证数据一致性?

这是个十分引战的话题,也是面试的时候经常会问的时候,更是实际开发中需要好好考虑的问题,因为这没有一个十分标准和完美的方案,只有最适合自己业务的方案。
操作缓存和操作数据库,操作的时序不同产生的影响也不同;哪一种组合产生的负面影响对业务最小,就倾向于哪种方案。

下面我们来介绍一下,大体上有几种方案来保证数据的一致性,大家可以带着自己的分析和思考,去看看各个方案的利弊,去想想业务上的权衡点。

方案一:给缓存设置过期时间

仅仅依靠缓存的过期时间来保证非常弱的一致性;根据业务不同,给不同的缓存设置不同的过期时间;当过期时间到达时,Redis就会删掉这条数据,后续读请求Redis出现Cache Miss,进而读取数据库,然后把数据写到Redis。

这样设置的话,如果过期的时间设置得稍微长一点,客户的体验越差,数据不一致的时间越长,而如果设置得太短,这样请求可能又都会直达数据库,失去了缓存的意义。

所以这种方案对于那种一段时间内数据可以不一致的业务来说还是足够的,实现也简单。

方案二:先删除缓存,再更新数据库

进行更新操作时,先删除Redis里面的相关数据,然后将数据更新到数据库;这样后续的请求再次读取Redis时触发Cache Miss,从而读取到数据库里的新数据,再将新数据更新到Redis。

但是这有个问题:当删除Redis里面的数据后,更新数据库的过程中,因为有大量的请求,所以可能会有请求在删除瞬间,读取Redis时触发Cache Miss,同时将老的数据更新的Redis,随后更新数据库的操作才完成;这样就导致了后续请求读到的都是老数据,而且缓存和数据库有可能就会长时间的数据不一致(假如缓存没设置过期时间的话,就需要等下次的更新操作了)。

方案三:先更新数据库,再更新缓存

进行更新操作时,先更新数据库里面的数据,成功之后再删除Redis里面的相关数据;这样后续的请求再次读取Redis时触发Cache Miss,从而读取到数据库里的新数据,再将新数据更新到Redis。

这也有个问题:
在更新数据库成功之前,有请求来到Redis并触发了Cache Miss,然后会有一个从数据库读数据更新到Redis的过程,这个过程称为P,这个P过程读到的是老数据;在这个P执行的过程中,上面说的数据更新操作也在同时执行,这个过程称为A,A将会更新数据库数据后删除Redis里面的缓存。

这里有个概率很低的情况,那就是P的执行时间大于A的执行时间,导致A删除了缓存后,P马上将老数据写入到Redis,然后后续的请求读到的还是老数据,也造成了数据不一致的问题。

这还有个问题,先更新数据库,再去删除Redis,如果删除Redis没删成功;这时候可以通过订阅数据库的binlog和使用消息队列来解决。

当数据库更新成功后,更新的数据会写入binlog,写一个订阅程序,可以在binlog中提取到所需要的数据,然后将想要删除的数据放入到消息队列中;还需要一个删除程序,从消息队列中获取数据,然后去删除缓存,若删除失败再重试,保证删除成功。

所以说,结合方案二和方案三中的特殊情况,最好还是为缓存设置上过期时间,这样至少不会数据长久的不一致,保持了弱一致性。

方案四:延时双删

延时双删可以说是方案二的延伸,先删除缓存,再更新数据库,延时x毫秒后,再去删一次缓存。

这样原本方案二产生的缓存不一致的问题,就可以通过延时删除的方式,让一致性更有保证。而且延时删除可以做成异步的方式去操作,避免对性能产生较大的影响;这样的方式老数据最多也就会存活x毫秒,数据不一致发生的概率也更低。

这也有个问题:延时双删?要是删除失败了怎么办?

这里删失败的解决方式跟上面一样,可以通过订阅数据库的binlog和使用消息队列来解决。

方案五:异步更新缓存

通过引入中间层的方式,数据库更新完后,将需要更新的数据放到中间件里面,然后由特定的程序进行消费,若消费失败还可以重试。这种方式的话跟上面说的删失败处理方式有点像;但是额外引入一个中间件单纯为了更新缓存的话,对于有些业务来说可能没必要。

双写一致性总结

对于更新缓存的设计模式,业界比较推崇的回答是 FackBook 提出的 Cache Aside Pattern:

失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
命中:应用程序从cache中取数据,取到后返回。
更新:先把数据存到数据库中,成功后,再让缓存失效。

综合看下来其实没有一个特别好的方案,只有最适合的方案,双写一致性跟业务的关联程度非常高,所以对一致性的要求也不一样,需要权衡得失,而且脱离场景单单谈方案的话都是胡扯。