redis夺命连环问10--说说Redis是怎么做旁路缓存的?

相关前置知识文章


前置篇redis夺命连环问4–Redis内存满了怎么办?怎么优化?


说说Redis是怎么做旁路缓存的?


先谈缓存大概怎么做

Redis 缓存时,有三个操作:
应用读取数据时,需要先读取 Redis;
发生缓存缺失时,需要从数据库读取数据;
发生缓存缺失时,还需要更新缓存。


再谈旁路缓存两种模式

只读缓存
当 Redis 用作只读缓存时,应用要读取数据的话,会先调用 Redis GET 接口,查询数据是否存在。而所有的数据写请求,会直接发往后端的数据库,在数据库中增删改。对于删改的数据来说,如果 Redis 已经缓存了相应的数据,应用需要把这些缓存的数据删除,Redis 中就没有这些数据了。
当应用再次读取这些数据时,会发生缓存缺失,应用会把这些数据从数据库中读出来,并写到缓存中。这样一来,这些数据后续再被读取时,就可以直接从缓存中获取了,能起到加速访问的效果。
在这里插入图片描述
优点是,数据库和缓存可以保证完全一致,并且缓存中永远保留的是经常访问的热点数据。缺点是每次修改操作都会把缓存中的数据删除,之后访问时都会先触发一次缓存缺失,然后从后端数据库加载数据到缓存中,这个过程访问延迟会变大。

读写缓存
对于读写缓存来说,除了读请求会发送到缓存进行处理(直接在缓存中查询数据是否存在),所有的写请求也会发送到缓存,在缓存中直接对数据进行增删改操作。
根据业务应用对数据可靠性和缓存性能的不同要求,我们会有同步直写和异步写回两种策略。
在这里插入图片描述
同步直写模式侧重于保证数据可靠性,而异步写回模式则侧重于提供低延迟访问,我们要根据实际的业务场景需求来进行选择。

优点是,被修改后的数据永远在缓存中存在,下次访问时,能够直接命中缓存,不用再从后端数据库中查询,这个过程拥有比较好的性能,比较适合先修改又立即访问的业务场景。但缺点是在高并发场景下,如果存在多个操作同时修改同一个值的情况,可能会导致缓存和数据库的不一致。


redis和mysql如何保证数据一致性?

一般情况下redis是用来充当数据库和应用中间的一个读操作的缓存层的,它的主要目的呢是减少数据库的IO,提升数据的IO性能。
当应用程序需要取某数据的时候首先尝试redis里面去加载,如果命中则直接返回。如果没命中则直接去数据库里面去查询,查询到数据再把数据缓存到redis里面。
会出现一个问题就是一份数据同时保存在数据库和redis里面,当数据发生变化的时候需要同时去更新mysql和redis,由于更新操作是有先后顺序的,它并不像mysql的多表事务操作,可以满足acid的特性,所以会出现数据一致性的问题。
这个情况下能选择的方法只有几种:
第一:先更数据库再更新缓存。
第二:先删缓存,再更新数据库。

如果先更数据库再更新缓存。
如果缓存更新失败,就会导致数据库和redis中的数据是不一致的。
如果是先删除缓存再更新数据库,理想情况下是应用下次访问redis的时候发现redis里面数据是空的,那么就会从数据库加载保存到redis里面。数据理论上是一致的,但是在极端情况下,由于删除redis和更新数据库这两个操作并不是原子操作,所以在这个过程中如果出现其他线程来访问,还是会存在数据不一致的问题。所以在极端情况下仍然去保证redis和mysql的数据一致性就只能采用最终一致性的一个方案:
比如基于消息队列的可靠性消息通信来实现数据的最终一致性。
还可直接通过canal组件监控mysql中binlog的日志,把更新后的数据同步到redis里面。
因为这里是基于最终一致性来实现的,如果业务场景不能去接受数据的短一致性,那么就不能用这个方案实现。


那怎么解决缓存和数据库的数据不一致问题?

啥是数据一致性?
“一致性”包含了两种情况:
缓存中有数据,那么,缓存的数据值需要和数据库中的值相同;
缓存中本身没有数据,那么,数据库中的值必须是最新值。

对于读写缓存来说
如果我们采用同步写回策略,那么可以保证缓存和数据库中的数据一致。
在有些场景下,我们对数据一致性的要求可能不是那么高,可以使用异步写回策略。

对于只读缓存来说

  • 如果是新增数据,数据会直接写到数据库中,不用对缓存做任何操作,此时,缓存中本身就没有新增数据,而数据库中是最新值。

  • 如果发生删改操作,应用既要更新数据库,也要在缓存中删除数据。这两个操作如果无法保证原子性,也就是说,要不都完成,要不都没完成,此时,就会出现数据不一致问题了。
    在这里插入图片描述
    如何解决数据不一致问题?
    分情况:

  • 删除失败导致的情况
    重试机制
    具体来说,可以把要删除或者要更新的值暂存到消息队列中(例如使用 Kafka 消息队列)。当应用没有能够成功地删除或更新值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
    如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,如果重试超过的一定次数没成功,就报错。
    在这里插入图片描述

  • 删除没失败高并发导致的情况
    情况一:先删除缓存,再更新数据库。
    在这里插入图片描述
    解决方案
    一:延迟双删:在线程 A 更新完数据库值以后,我们可以让它先 sleep 一小段时间,再进行一次缓存删除操作。之所以要加上 sleep 的这段时间,就是为了让线程 B 能够先从数据库读取数据,再把缺失的数据写入缓存,然后,线程 A 再进行删除。
    二:更新与读取操作进行异步串行化:–这个我看不懂,拉倒吧这个

  • 1、异步串行化
    我在系统内部维护n个内存队列,更新数据的时候,根据数据的唯一标识,将该操作路由之后,发送到其中一个jvm内部的内存队列中(对同一数据的请求发送到同一个队列)。读取数据的时候,如果发现数据不在缓存中,并且此时队列里有更新库存的操作,那么将重新读取数据+更新缓存的操作,根据唯一标识路由之后,也将发送到同一个jvm内部的内存队列中。然后每个队列对应一个工作线程,每个工作线程串行地拿到对应的操作,然后一条一条的执行。
    这样的话,一个数据变更的操作,先执行删除缓存,然后再去更新数据库,但是还没完成更新的时候,如果此时一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中积压,排在刚才更新库的操作之后,然后同步等待缓存更新完成,再读库。

  • 2、读操作去重
    多个读库更新缓存的请求串在同一个队列中是没意义的,因此可以做过滤,如果发现队列中已经有了该数据的更新缓存的请求了,那么就不用再放进去了,直接等待前面的更新操作请求完成即可,待那个队列对应的工作线程完成了上一个操作(数据库的修改)之后,才会去执行下一个操作(读库更新缓存),此时会从数据库中读取最新的值,然后写入缓存中。
    如果请求还在等待时间范围内,不断轮询发现可以取到值了,那么就直接返回;如果请求等待的时间超过一定时长,那么这一次直接从数据库中读取当前的旧值。(返回旧值不是又导致缓存和数据库不一致了么?那至少可以减少这个情况发生,因为等待超时也不是每次都是,几率很小吧。这里我想的是,如果超时了就直接读旧值,这时候仅仅是读库后返回而不放缓存)

情况二:先更新数据库值,再删除缓存值。
在这里插入图片描述
除非redis挂了,否则这种场景还是很少的。
解决
也可用重试机制(Kafka消息队列),
但是这个方案会有一个缺点就是会对业务代码造成大量的侵入,深深的耦合在一起,所以这时会有一个优化的方案,我们知道对 Mysql 数据库更新操作后再 binlog 日志中我们都能够找到相应的操作,那么我们可以订阅 Mysql 数据库的 binlog 日志对缓存进行操作
在这里插入图片描述


建议是,优先使用先更新数据库再删除缓存的方法,原因主要有两个:
1.先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力;
2.如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。

在这里插入图片描述


如何保证缓存和后端数据库的一致性问题?操作缓存或数据库发生异常时如何处理?

当一个系统引入缓存时,需要面临最大的问题就是,如何保证缓存和后端数据库的一致性问题,最常见的3个解决方案分别是Cache Aside、Read/Write Throught和Write Back缓存更新策略。

1、Cache Aside策略:就是文章所讲的只读缓存模式。读操作命中缓存直接返回,否则从后端数据库加载到缓存再返回。写操作直接更新数据库,然后删除缓存。这种策略的优点是一切以后端数据库为准,可以保证缓存和数据库的一致性。缺点是写操作会让缓存失效,再次读取时需要从数据库中加载。这种策略是我们在开发软件时最常用的,在使用Memcached或Redis时一般都采用这种方案。

2、Read/Write Throught策略:应用层读写只需要操作缓存,不需要关心后端数据库。应用层在操作缓存时,缓存层会自动从数据库中加载或写回到数据库中,这种策略的优点是,对于应用层的使用非常友好,只需要操作缓存即可,缺点是需要缓存层支持和后端数据库的联动。

3、Write Back策略:类似于文章所讲的读写缓存模式+异步写回策略。写操作只写缓存,比较简单。而读操作如果命中缓存则直接返回,否则需要从数据库中加载到缓存中,在加载之前,如果缓存已满,则先把需要淘汰的缓存数据写回到后端数据库中,再把对应的数据放入到缓存中。这种策略的优点是,写操作飞快(只写缓存),缺点是如果数据还未来得及写入后端数据库,系统发生异常会导致缓存和数据库的不一致。这种策略经常使用在操作系统Page Cache中,或者应对大量写操作的数据库引擎中。

除了以上提到的缓存和数据库的更新策略之外,还有一个问题就是操作缓存或数据库发生异常时如何处理?例如缓存操作成功,数据库操作失败,或者反过来,还是有可能会产生不一致的情况。

比较简单的解决方案是,根据业务设计好更新缓存和数据库的先后顺序来降低影响,或者给缓存设置较短的有效期来降低不一致的时间。如果需要严格保证缓存和数据库的一致性,即保证两者操作的原子性,这就涉及到分布式事务问题了,常见的解决方案就是我们经常听到的两阶段提交(2PC)、三阶段提交(3PC)、TCC、消息队列等方式来保证了,方案也会比较复杂,一般用在对于一致性要求较高的业务场景中。


如何解决缓存雪崩?

缓存雪崩:是指大量的应用请求无法在 Redis 缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增。

原因
第一个原因是:缓存中有大量数据同时过期,导致大量请求无法得到处理。
在这里插入图片描述

解决

  • 微调过期时间:业务层的确要求有些数据同时失效,可以在用 EXPIRE 命令给每个数据设置过期时间时,给这些数据的过期时间增加一个较小的随机数,既避免了大量数据同时过期,同时也保证了这些数据基本在相近的时间失效,仍然能满足业务需求。
  • 数据预热:可以通过缓存 reload 机制,预先去更新缓存,再即将发生大并发访问前手动触发加载缓存不同的 key
  • 服务降级:
    当业务应用访问的是非核心数据时,暂时停止从缓存中查询这些数据,而是直接返回预定义信息、空值或是错误信息;
    当业务应用访问的是核心数据时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取,数据库压力没那么大。
    在这里插入图片描述

第二个原因:Redis 缓存实例发生故障宕机了,无法处理请求
解决

  • 第一个建议,是在业务系统中实现服务熔断或请求限流机制。(事后诸葛亮)
    加锁排队:在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个 key 只允许一个线程查询数据和写缓存,其他线程等待;
    在这里插入图片描述

在这里插入图片描述

  • 第二个建议:通过主从节点的方式构建 Redis 缓存高可靠集群。如果 Redis 缓存的主节点故障宕机了,从节点还可以切换成为主节点,继续提供缓存服务,避免了由于缓存实例宕机而导致的缓存雪崩问题。(预防)

其他解决方案

  • 做二级缓存,或者双缓存策略:Cache1 为原始缓存,Cache2 为拷贝缓存,Cache1 失效时,可以访问 Cache2,Cache1 缓存失效时间设置为短期,Cache2 设置为长期。

如何解决缓存击穿?

缓存击穿:是指针对某个访问非常频繁的热点数据的请求,无法在缓存中进行处理,紧接着访问该数据的大量请求,一下子都发送到了后端数据库,导致了数据库压力激增,会影响数据库处理其他请求。缓存击穿的情况,经常发生在热点数据过期失效时。
在这里插入图片描述
解决
对于访问特别频繁的热点数据,不设置过期时间。


如何解决缓存穿透?

缓存穿透:是指要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。一般是业务层误删操作或恶意攻击导致。
在这里插入图片描述
解决
第一种方案是,缓存空值或缺省值。

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

第二种方案是,布隆过滤器:将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力。

使用布隆过滤器快速判断数据是否存在,避免从数据库中查询数据是否存在,减轻数据库压力。

第三种方案是,在请求入口的前端进行请求检测。把恶意的请求直接过滤掉,不让它们访问后端缓存和数据库。


刚刚你说到了布隆过滤器,能具体说说吗?

布隆过滤器:由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,可以用来快速判断某个数据是否存在。当我们想标记某个数据存在时,布隆过滤器会通过三个操作完成标记:

  • 首先,使用 N 个哈希函数,分别计算这个数据的哈希值,得到 N 个哈希值。
  • 然后,我们把这 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。
  • 最后,我们把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作。

过程:当需要查询某个数据时,我们就执行刚刚说的计算过程,先得到这个数据在 bit 数组中对应的 N 个位置。紧接着,我们查看 bit 数组中这 N 个位置上的 bit 值。只要这 N 个 bit 值有一个不为 1,这就表明布隆过滤器没有对该数据做过标记,所以,查询的数据一定没有在数据库中保存。
在这里插入图片描述
当缓存缺失后,应用查询数据库时,可以通过查询布隆过滤器快速判断数据是否存在。如果不存在,就不用再去数据库中查询了。


从问题成因来看,缓存雪崩和击穿主要是因为数据不在缓存中了,而缓存穿透则是因为数据既不在缓存中,也不在数据库中。所以,缓存雪崩或击穿时,一旦数据库中的数据被再次写入到缓存后,应用又可以在缓存中快速访问数据了,数据库的压力也会相应地降低下来,而缓存穿透发生时,Redis 缓存和数据库会同时持续承受请求压力。
在这里插入图片描述
建议:尽量使用预防式方案:
针对缓存雪崩,合理地设置数据过期时间,以及搭建高可靠缓存集群;
针对缓存击穿,在缓存访问非常频繁的热点数据时,不要设置过期时间;
针对缓存穿透,提前在入口前端实现恶意请求检测,或者规范数据库的数据删除操作,避免误删除。


如何解决缓存污染?

缓存污染是啥:数据服务完访问请求后很少被访问了,却还留存在缓存中占用缓存空间。

除了在明确知道数据被再次访问的情况下,volatile-ttl 可以有效避免缓存污染。在其他情况下,volatile-random、allkeys-random、volatile-ttl 这三种策略并不能应对缓存污染问题。

LRU缓存策略:会在每个数据对应的 RedisObject 结构体中设置一个 lru 字段,用来记录数据的访问时间戳。在进行数据淘汰时,LRU 策略会在候选数据集中淘汰掉 lru 字段值最小的数据(也就是访问时间最久的数据)。

也正是因为只看数据的访问时间,使用 LRU 策略在处理扫描式单次查询操作时,无法解决缓存污染。

LFU 缓存策略的优化:LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器统计访问次数。筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。

LRU 策略更加关注数据的时效性,而 LFU 策略更加关注数据的访问频次。通常情况下,实际应用的负载具有较好的时间局部性,所以 LRU 策略的应用会更加广泛。但是,在扫描式查询的应用场景中,LFU 策略就可以很好地应对缓存污染问题了,建议你优先使用。

实现
为了避免操作链表的开销,Redis 在实现 LRU 策略时使用了两个近似方法:

  • Redis 是用 RedisObject 结构来保存数据的,RedisObject 结构中设置了一个 lru 字段,用来记录数据的访问时间戳;
  • Redis 并没有为所有的数据维护一个全局的链表,而是通过随机采样方式,选取一定数量(例如 10 个)的数据放入候选集合,后续在候选集合中根据 lru 字段值的大小进行筛选。

Redis 在实现 LFU 策略的时候,只是把原来 24bit 大小的 lru 字段,又进一步拆分成了两部分。

  • ldt 值:lru 字段的前 16bit,表示数据的访问时间戳;
  • counter 值:lru 字段的后 8bit,表示数据的访问次数。

当 LFU 策略筛选数据时,Redis 会在候选集合中,根据数据 lru 字段的后 8bit 选择访问次数最少的数据进行淘汰。当访问次数相同时,再根据 lru 字段的前 16bit 值大小,选择访问时间最久远的数据进行淘汰。

使用了非线性递增的计数器方法,即使缓存数据的访问次数成千上万,LFU 策略也可以有效地区分不同的访问次数,从而进行合理的数据筛选。
Redis 在实现 LFU 策略时,还设计了一个 counter 值的衰减机制。LFU 策略在数据不再被访问后,会较快地衰减它们的访问次数,尽早把它们从缓存中淘汰出去,避免缓存污染。

建议:你可以优先使用 volatile-lfu 策略,并根据这些数据的访问时限设置它们的过期时间,以免它们留存在缓存中造成污染。

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值