缓存和数据一致性问题
Redis的高频面试:缓存和数据的一致性问题。今天我们整理总结一下。
Redis的缓存分两种类型,读写缓存,只读缓存。不同的类型会产生不同的问题以及不同的解决方案,下面我们了解一下。
读写缓存
我们先来介绍一下什么是读写缓存。读写缓存就代表一个主库一样。既要提供写服务,也要提供读服务。
当用户选用读写缓存时,如果对数据发生了修改。我们除了要考虑数据库的一致性以外,还要考虑缓存中的数据一致性。正如我们前面所说,Redis的写请求也是一个存储介质。所以我们在配置时,要采取相应的写回策略
- 当采用同步直写策略,写缓存时,可以保证缓存与数据库的数据一致性。
- 当采用异步写回策略,写缓存时,由于是异步执行,无法保证命令都执行也就是无法保证数据一致性。
对于读写缓存来说,如果要保证数据一致性就尽量采用同步直写策略。也就是同步执行,程序中一定不要忘记加事务机制来保证数据库和缓存的原子性。
有些数据要求比较高的可以采用同步直写,对要求不高的属性来说(创建时间,来源地,家乡,属性等)我们就可以采用异步写回策略。
只读缓存
介绍完读写缓存,我相信很多人对只读缓存应该能知道的差不多了。我再来整理总结一下。只读缓存就代表从库一样,只负责读服务,不负责用户的写服务。
所以平时我们在 新增数据 的时候就可以绕过Redis,直接写入数据库,这样下次用户访问的时候发现缓存中没有(缓存缺失)就会直接打到数据库,在数据库发现之后就会缓存下来插入到缓存中。缓存之后我们下次便可以在缓存中直接读取就不需要再去查询数据库了。
众所周知,Redis的并发承受能力大于MySQL的并发承受能力。这也是我们为什么要缓存到Redis中的原因。
如果是 删除或修改操作 我们要更新数据库的同时,也要更新缓存。如果不删除缓存的话就造成了原本这个用户不存在,但是可以还是登录进去访问,就造成了数据不一致问题。严重的话可能会导致系统级的报错!(比如关联查询时,不存在数据error)
所以Redis,MySQL都要保证原子性,
- 先删缓存,后更新数据库:缓存删除成功,数据更新失败,导致用户向缓存读数据时,没有发现缓存key就会打向数据库,而数据库数据没有更新成功导致读到旧值
- 先更新数据库,后删缓存: 更新数据库成功时,缓存删除失败,就会导致数据库保留了最新的值,用户向Redis读数据时,发现缓存key存在,直接返回了就拿到了上一个旧值。
如何解决
1. 重试机制
我们可以采用消息队列的思想去改进。如果一条命令执行成功,另一条命令执行失败时,写入消息队列,进入二次消费。当消费成功时,我们再把消息队列的数据自行删除。以免重复操作!
这样就保证了数据库和缓存的一致性了!
这种情况的确可以解决数据的一致性,但是如果这个数据被并发访问的话,失败的那一刻就造成了旧值的产生!我们再来分析一下
- 先删缓存,后更新数据库
假设缓存删除成功之后,还没进行数据库的更新操作,这时有个用户请求打了过来。它发现缓存中不存在这个数据,就会打到数据库。它从数据库中取到了数据。这就造成了读到了旧值。而且读到旧值的同时还会把旧值缓存到Redis中。
随后数据库的更新操作开始进行了。。。。
缓存的值出现了,数据库的值又没了。。。。
这两者不就不一样了嘛!
我们在解决时,一般都会让他先sleep一小段时间,再进行缓存删除操作。sleep的睡眠参数取决于(线程读数据和写缓存的操作时间作为估值)
- 先更新数据库,后删缓存
如果数据库的值修改了,删除缓存的那一刻并发来了,用户就会从缓存汇中读取旧值直接返回给用户。
一击要害!。。。。。
删除缓存值或更新数据库失败而导致数据不一致,你可以使用重试机制确保删除或更新操作成功。
在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,导致其他线程读取到旧值,应对方案是延迟双删。
缓存雪崩,击穿,穿透
雪崩
雪崩这个东西,见名思意。我们可以理解成一大堆雪冲了过来,一面墙无法抵挡猛烈的攻击。于是冲垮了墙,直接冲向了你们家的卧室!
一般造成缓存雪崩主要是有如下几个原因
- 发大洪水:系统设计问题,明明有100万流量,程序只设计了10万
- 洪水从天而降的意外:缓存key刚好大面积失效,过期时间设计的不合理
- 被瓦匠工糊弄了一下墙倒了:Redis实例宕机
我们在平时解决时,可以避免缓存写入的时间大面积相同,可以在后面加一个随机函数,让过期时间分布的频段多一些。还可以通过服务降级来解决缓存雪崩。
比如在去年新冠疫情的那会。严查所有过往的路人,一旦有咳嗽,发烧一律不予通行。如果没有咳嗽,发烧等情况还持有体检报告的可以回家自行隔离。
在Redis中也是同样道理,如果访问的是核心数据,我们可以放行,如果是访问附加属性我们可以直接返回初始数据,或者网络波动问题。这样就可以过滤一部分附加属性的请求了。
还有一种情况就是,洪水还没来,墙自己倒了。你看这不是赤裸裸的求干吗,你这不就是挑衅洪水的嘛。
一般为了系统业务能正常运行,我们会提前最好做好如下应对措施
- 实现服务熔断
- 实现请求限流机制。
- 高可用集群
服务熔断的话我们可以理解成,为了防止引发连锁反应(积分服务挂了,还能影响订单嘛)我们关掉了用户的积分服务。等修复成功之后再重新开启服务。这样就可以避免其他服务受此牵连。
在业务系统运行时,我们可以监测 Redis 缓存所在机器和数据库所在机器的负载指标,例如每秒请求数、CPU 利用率、内存利用率等。如果我们发现 Redis 缓存实例宕机了,而数据库所在机器的负载压力突然增加(例如每秒请求数激增),此时,就发生缓存雪崩了。大量请求被发送到数据库进行处理。我们可以启动服务熔断机制,暂停业务应用对缓存服务的访问,从而降低对数据库的访问压力
服务限流的话,我们可以理解成早晨上班的警察道路调配。一个路口是流入量是不变的,如果我们想正常运行,就必须把流入速率慢下来。
回到系统的话就是每秒1万个请求,限流之后,每秒1千个请求。再多的请求我们拒之门外排队等候。
高可用集群 的话也算是提前预防了。就好比在双十一或者流量超级春运的时候。流量超级大。我们提前在节日之前把对应的机器设施架设起来。一旦大屏面板监测到大批流量引入我们可以开启备选方案,通过增加机器来解决并发需求。这样也可以达到节省硬件成本的需求。
击穿
缓存击穿主要就是 热点数据失效 。双十一期间如果榜一的商品缓存失效了,恐怕就有悲剧了。一时间所有的请求都打到的数据库上。这是由于热点数据的过期时间设计不符导致的。
我们一般对这类数据会进行提前预热,比如热榜前100的数据,我们会预热30分钟。这样在秒杀的时候,就不会造成在短时间内大量请求打入数据库了。
穿透
缓存穿透顾名思义,就是在玩刺剑时,攒足力气,直冲一处。
回到系统中是这样的意思。黑客 在黑入我们系统时,往往会猜想一些缓存中没有的数据,使大量请求打到数据库,造成缓存穿透。当下次再次请求时,缓存中还是没有查询到,因为从数据库查询时,本来就没有所以也无法写入缓存。
我们的解决方案就是:不管数据库是否存在当前数据,我们都缓存的一个key,给这个key的value中 写入一个null。
还可以利用Redis的布隆过滤器来解决。下面我们来聊一下什么是布隆过滤器
布隆过滤器
布隆过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,可以用来快速判断某个数据是否存在。当我们想标记某个数据存在时(例如,数据已被写入数据库),布隆过滤器会通过三个操作完成标记:
- 首先,使用 N 个哈希函数,分别计算这个数据的哈希值,得到 N 个哈希值。
- 然后,我们把这 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。
- 最后,我们把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作。
如果数据不存在(例如,数据库里没有写入数据),我们也就没有用布隆过滤器标记过数据,那么,bit 数组对应 bit 位的值仍然为 0。
当需要查询某个数据时,我们就执行刚刚说的计算过程,先得到这个数据在 bit 数组中对应的 N 个位置。紧接着,我们查看 bit 数组中这 N 个位置上的 bit 值。只要这 N 个 bit 值有一个不为 1,这就表明布隆过滤器没有对该数据做过标记,所以,查询的数据一定没有在数据库中保存。
缓存污染
什么是缓存污染呢?
在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。这种情况,就是缓存污染。
如何解决?
原本的计划我是想把8种淘汰策略全部聊一遍的,但是看了下时间。芭比Q了WC。
速战速决吧,通过前面的几篇文章我们介绍了详细的8种策略。这里的话我就直接省略了。
- noeviction策略不进行数据淘汰所以不能用于缓存污染
- volatile-random与allkeys-random都是随机淘汰,虽然可以用于淘汰数据但是不好,而且如果淘汰了热点数据反而适得其反。
- volatile-ttl属于按照时间淘汰,和随机淘汰一样,用于解决缓存污染,不是很好,也会造成适得其反。
剩下的四个volatile-lru、volatile-lfu、allkeys-lru、allkeys-lfu策略。其实我们可以看成2个策略,只不过一个是局部的一个是全局的。为了理解我们下面就用 LRU 和 LFU 算法表示。
LRU
废话不多说,省略一些,想看详细的可以去(3万聊聊什么是Redis五)。LRU算法是比较好的,但是唯一的缺点就是 受访问时间影响,因为只看数据的访问时间,使用 LRU 策略在处理扫描式单次查询操作时,无法解决缓存污染。所谓的扫描式单次查询操作,就是指应用对大量的数据进行一次全体读取,每个数据都会被读取,而且只会被读取一次。此时,因为这些被查询的数据刚刚被访问过,所以 lru 字段值都很大。
LRU会把这些数据在留在缓存中很长时,造成缓存污染。如果有新数据访问时,还要把旧数据替换出去,换新的值进来,这样会影响缓存的性能。
LFU
在LRU的基础上,诞生了LFU算法。
LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。
和那些被频繁访问的数据相比,扫描式单次查询的数据因为不会被再次访问,所以它们的访问次数不会再增加。因此,LFU 策略会优先把这些访问次数低的数据淘汰出缓存。这样一来,LFU 策略就可以避免这些数据对缓存造成污染了。
LFU为了造成链表的开销,使用了两个近似的方案
- 都可以RedisObject 保存数据,在结构内设置了一个lru字段记录数据的时间戳
- 采用随机采样的方式选取一定数据量放入候选集合,后续在候选集合中根据lru字段值的大小进行筛选。
Redis 在实现 LFU 策略的时候,只是把原来 24bit 大小的 lru 字段,又进一步拆分成了两部分。
- ldt 值:lru 字段的前 16bit,表示数据的访问时间戳;
- counter 值:lru 字段的后 8bit,表示数据的访问次数。
LFU这里用到了一个很有意思的设计。它采用的是8字段存储访问次数。我们得知8字节可以存放255次,如果超过255Redis如何响应呢?
Redis写满之后,会使用LFU策略进行数据淘汰。当两个值都是255时,再去比较时间戳。但是以Redis的访问请求量远远不够。因此,在实现 LFU 策略时,Redis 并没有采用数据每被访问一次,就给对应的 counter 值加 1 的计数规则,而是采用了一个更优化的计数规则。
每当数据被访问一次时,首先,用计数器当前的值乘以配置项 lfu_log_factor 再加 1,再取其倒数,得到一个 p 值;然后,把这个 p 值和一个取值范围在(0,1)间的随机数 r 值比大小,只有 p 值大于 r 值时,计数器才加 1。
当 lfu_log_factor
为1时,实际访问次数为 100K 后,counter 值就达到 255 了,无法再区分实际访问次数更多的数据了。
当 lfu_log_factor 取值为 10 时,百、千、十万级别的访问次数对应的 counter 值已经有明显的区分了(一般应用时,我们可以设置为10)
当 lfu_log_factor
为100时,当实际访问次数为 10M 时,counter 值才达到 255,此时,实际访问次数小于 10M 的不同数据都可以通过 counter 值区分出来。
结尾
大概总结了
- 缓存和数据不一致性引发的问题
- 不同的缓存类型以及解决访问。
- Redis常见的生产问题,缓存雪崩,击穿,穿透,
- 由穿透又聊到了布隆过滤器
- 缓存污染以及应用措施,顺带的聊了一下LFU和LRU的经典之处
学完之后,我只能惊叹一下Redis作者太牛了,这种设计的思路也值得我们日后的系统中借鉴一下。
这篇文章应该没有前几篇好一些,这周我们公司的总监临时有事,然后我自己扛起了一面小旗,所有的学习时间都是每天晚上零散一些。周六白天又在给电商那套系统开发新的功能。晚上回忆了一下本周的知识碎片,敖到凌晨3点才完工的一篇。
每个知识点都是自己整理浓缩表达出来的,部分有些不容易懂的地方请及时指出,我们一起共同进步!
因为确实比较累的缘故,部分知识出自蒋德均老师,Redis设计与实现,Redis深度历险。尊重原创!