Redis 缓存
缓存一致性
对于缓存和数据库的更新操作,主要分为以下两种
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
首先可能会带来疑惑的点是,为什么这里是删除缓存而不是更新缓存?
按照常理来说,更新的效率通常都会比删除高,因为我们在删除了缓存后当有读操作到来时,当其查询缓存不存在时,就会去查询数据库,并将读取到的值写入到缓存中,这样的效率明显比更新低。
但是我们还需要考虑一个问题,即缓存的使用率问题。如果在短时间内对数据库进行了10000次更新操作,那么缓存也必定会进行10000次的更新操作,那这个缓存它真的有用到那么多次吗?如果它仅仅是一个冷门数据,可能在这期间内只进行了仅仅几次的查询操作,那我们的这些更新操作不是会显得很多余吗?
所以,我们才会去使用删除。因为在我们删除缓存后,只有在其真正使用到这个数据的时候,才会将其写入缓存,因此我们就不用每次都对缓存进行更新操作,从而保证效率。
先删除缓存,再更新数据库
对于这种情况,能够保证缓存的一致性吗?
答案是否定的,例如下面这种场景:
- 线程A写入数据,此时先删除缓存
- 线程B读取数据,查询缓存不存在,直接去查询数据库
- 线程B将查询到的旧值写入至缓存中
- 线程A将新数据更新至数据库中
对于上述这种情况,线程B在线程A更新数据库之前就提前读取了数据库,从而读取到了旧值,而后线程B将读取到的旧值再次写入缓存中,就出现了缓存不一致的情况。
那么这个问题如何解决呢?
这时候就需要引入延时双删的机制
延时双删
为了避免在更新数据库的时候,其他的线程读取到了数据库中的旧值并将其写入缓存这种情况,我们会在数据库更新完后等待一段时间,再次删除缓存,来保证下一个到来的线程能够将正确的缓存更新回去。 流程如下
- 线程A写入数据,此时先删除缓存
- 线程B读取数据,查询缓存不存在,直接去查询数据库
- 线程B将查询到的旧值写入至缓存中
- 线程A将新数据更新至数据库中,休眠一段时间
- 线程A将缓存再次删除,来确保缓存的一致性
- 其他线程查询数据库,将正确的值更新至缓存中
那么,为了保证我们能够将错误的缓存删除,所以我们的sleep时间只需要大于线程读写缓存的时间即可
先更新数据库,再删除缓存
那么如果我们先更新数据库,再更新缓存呢? 对于这种操作,缓存不一致的情况就更加明显了。由于磁盘I/O速度慢,在更新数据库、删除缓存这段操作之前,其他线程读取到的都是原本缓存中的旧值。甚至可能会由于缓存删除失败(如缓存服务当前不可用的情况)从而导致严重的缓存不一致问题。
那么如何解决这个问题呢?可以使用以下几种方法
修改缓存过期时间
这是解决这个问题最简单的方法,同时也是治标不治本的方法。
我们可以将缓存过期时间变短,使其每隔一段时间就会去数据库中加载数据,对于更新不频繁的数据来说,就可以很好的解决不一致的问题,但若是更新特别频繁的热点数据,这个方法则失去了作用。
由于这个方法的适用面小,且实时性和一致性不高,所以我们通常都会选择使用消息队列来解决这个问题。
消息队列
我们可以引入一个消息队列来解决这个问题,在更新数据库后,我们往消息队列中写入数据,等到消费者从消息队列中取出数据时,再将缓存删除。借助消息队列的消息重试机制来保证我们一定能够成功删除缓存,从而确保缓存的一致性。 但是这种方法也存在几个问题
- 引入消息队列后可能会因为消息的处理导致一定程度的延迟,从而引起短期内的消息不一致
- 引入消息队列后导致问题整体复杂化
所以我们只有在对实时性和一致性要求不高的情况下才会选择这种做法
缓存淘汰策略
redis中缓存的数据是有过期时间的,当缓存数据失效时,redis会删除过期数据以节省内存,那redis是怎样怎样的策略来删除过期数据的呢?
过期键删除策略
过期删除策略通常有以下三种
- 定时删除:在为键设置过期时间的同时创建一个定时器,当过期时间到来时就会触发定时器中的处理函数,立即执行过期键的删除操作
- 定期删除:每隔一段时间就对数据库进行一次检查,删除其中的过期键。检查的数据库数量及删除的过期键数量由算法决定
- 惰性删除:不会主动去删除过期键。每次获取键时都会判断获取的键是否过期,如果过期则删除,没过期则返回
其中前两种为主动删除策略,最后一种为被动删除策略。下面就来谈谈这三种策略的优缺点以及Redis中究竟使用的哪一种
定时删除
定时删除策略对于内存来说十分友好,通过定时器能够保证过期键能够在第一时间被删除,而不会一直占用内存。
但是同样的,它对CPU时间非常不友好。在过期键比较多的时候,维护大量的定时器会给CPU带来巨大的压力,即使过期键少的时候,它也会将宝贵的CPU时间用在维护定时器,以及删除和当前任务无关的过期键上,对服务器的响应时间与吞吐量造成了一定的影响。
惰性删除
从开始的描述可以看出,惰性删除对于CPU时间来说是最为友好的,因为我们只会在取出键的时候才会对其进行删除操作,这也就保证了我们不会在执行其他任务的时候又背地里去删除无关的过期键,合理的利用了CPU时间。
但是!!!也正是因为这个原因,使得它对内存极度不友好。如果一个键已经过期,而只要我们不去获取这个键,就不会触发过期检查,那也就意味着他会一直占用这一块内存而不释放。
这意味着什么呢?如果我们有非常多的过期键,而这些过期键又恰好因为版本迭代、项目组交替,在后续版本中并没有对其进行访问,那么它可能永远也不会被删除。我们可以将这种情况当成内存泄漏中的一种,对于Redis这种内存数据库来说,这种情况造成的后果十分严重
定期删除
定期删除策略其实是上述两种策略的折中选择。
定期删除策略相对于定时删除策略来说,由于其每隔一段时间才进行一次删除操作,通过限制了删除操作的时常和频率,大大减少了删除操作对CPU时间的影响。
相比于惰性删除,并且由于定期删除过期键,有效地减少了过期键带来的空间浪费。即兼顾了CPU,又避免了内存浪费,是两者的折中选择。
但是上述这些优点的前提,就是我们必须要确定一个合理的删除操作的时长和频率。
- 如果删除操作过于频繁,则又退化成了定时删除策略,浪费了大量的CPU时间
- 如果删除操作执行过少,则又会像惰性删除一样,出现大量的内存浪费问题。
Redis的选择
下面给出三种的效率对比
CPU:惰性删除 > 定期删除 > 定时删除 内存利用率:定时删除 > 定期删除 > 惰性删除
- 定时删除占用太多CPU时间,影响服务器的吞吐量和性能,但是很好的避免了内存浪费
- 惰性删除浪费太多内存,有内存泄漏的风险,但是却保证了CPU的效率
- 定期删除属于前两种的折中,既保证了CPU时间的合理利用,又避免了内存的浪费
为了能够在合理利用CPU时间与避免浪费内存空间之间取得平衡,Redis同时使用了惰性删除和定期删除。
这样的搭配虽然保证了Redis强大的吞吐量以及响应速度,但是却存在因为没有定时删除机制,所以存在着内存浪费问题。
由于Redis中通常存储的数据量十分庞大,这就导致了定期删除每次只能抽取其中的一部分进行删除,倘若有一部分过期键一直没有被抽取到,并且我们也一直没有访问它来触发惰性删除,这个过期键就会一直存在内存中,如果不进行处理,就可能导致内存耗尽。
为了解决这个问题,Redis又引入了内存淘汰机制
内存淘汰机制
当Redis的内存占用过高时,如果内存不足以容纳新写入的数据,就会通过某种机制来删除一部分键,来减少当前占用的内存,这就是内存淘汰机制。
当前Redis提供了8种内存淘汰策略,除却之前的6种,还有两种Redis4.0后新增的LFU模式:volatile-lfu以及allkeys-lfu
名称 | 作用 |
---|---|
volatile-lru | 在已设置过期时间的key中 ,挑选最近最少使用 的key淘汰 |
volatile-lfu | 在已设置过期时间的key中 ,挑选最不经常 使用的key淘汰 |
volatile-ttl | 在已设置过期时间的key中 ,挑选将要过期 的key淘汰 |
volatile-random | 在已设置过期时间的key中 ,随机挑选 key淘汰 |
allkeys-lru | 在所有key中 ,挑选最近最少使用 的key淘汰 |
allkeys-lfu | 在所有key中 , 挑选最不经常 使用的key淘汰 |
allkeys-random | 在所有key中 ,随机挑选 key淘汰 |
no-eviction | 当内存不足以写入新数据时,写入操作会报错,并且不会淘汰数据 (不常用) |
乍一看策略很多很难记,其实总共就是四种不同的淘汰策略,以及两种key的选择范围
选择范围
- allkeys:淘汰的范围为所有的key
- volatile:淘汰的范围为已设置过期时间的key
淘汰策略
- LRU:Least recently used,即淘汰最近最少使用的key
- LFU:Least Frequently Used,即淘汰最不经常使用的key
- TTL:Time To Live,即淘汰生命时间最短,即将要过期的key
- Random:随机淘汰
其中LRU和LFU较为常用,如果有想了解其算法原理的,可以看看我的往期博客 高级数据结构与算法 | LRU缓存机制(Least Recently Used) 高级数据结构与算法 | LFU缓存机制(Least Frequently Used)
缓存常见问题
缓存雪崩
缓存雪崩指的是在短时间内,有大量缓存的键同时过期,由于缓存过期,导致此时所有的请求就直接查询数据库,而数据库很难抵挡这样巨大的压力,严重情况下就会导致数据库被大流量打死,直接宕机。
下面是正常的查询流程以及缓存雪崩后的查询流程
缓存雪崩的解决方法有以下几种
- 随机化过期时间,为了避免缓存同时过期,在设置缓存时在原有时间上添加随机时间,使失效时间分散开来
- 加锁排队,加锁排队可以起到缓冲的作用,防止大量请求同时操作数据库,但是也正因为如此也减少了吞吐量,导致响应时间变慢,用户体验变差。
- 设置二级缓存,即加入一个本地缓存作为备案,当Redis缓存失效后就暂时使用本地缓存进行代替,避免直接访问数据库。
- 设置热点数据永不过期,有更新操作时直接更新缓存即可
缓存击穿
缓存击穿与缓存雪崩很像,不过一个是针对大量缓存一个是针对热点缓存。
缓存击穿即当某个热点缓存突然失效,而正好对其有着大量的请求,此时这些请求就会直接向数据库进行查询,导致数据库面临巨大的压力
缓存击穿的解决方法有以下几种
- 设置热点数据永不过期,有更新操作时直接更新缓存即可
- 加锁排队,通过加锁来减少同一时间的访问量,缓解压力
缓存穿透
缓存穿透是指查询的数据在缓存中和数据库中都不存在,此时请求就会直接绕过缓存抵达数据库,导致数据库压力过大。(由于主键通常都是从1开始自增,此时大量查询负数或者特别大的数据就会导致缓存穿透)。 出于容错考虑,由于这些数据在数据库中不存在,所以不会将结果保存到缓存中。而又因为缓存中没有这些数据,所以每次请求都会绕过缓存,直接向数据库查询,这就是缓存穿透。 缓存穿透的解决方法有以下几种
- 参数校验,对于那些不合法的请求就直接返回空结果,不进行查询
- 布隆过滤器,可以根据布隆过滤器来判断数据在不在数据库,虽然布隆过滤器查询存在不一定准确,但是如果布隆过滤器中查不到,则一定说明不存在,就不会进入数据库查询
- 缓存空结果,将每次查询的结果进行缓存,即使查询不到的也缓存一个空结果,当有非法请求时就直接返回空结果
缓存预热
与上面三种不同,缓存预热并不是一个需要解决的问题,而是一种优化的策略,通过这种策略能够更快的响应用户的查询。
缓存预热指的是在启动系统的时候,提前将查询的结果预存到缓存中,这样用户查询时就可以直接从缓存中读取,减少了用户的等待时间 缓存预热的实现方法有以下三种
- 把需要缓存的函数写入到系统的构造函数中,这样系统就会在启动的时候自动的加载数据并缓存数据
- 把需要缓存的函数挂载到前端页面或者后端的接口上,手动触发缓存预热
- 设置定时任务,定时自动进行缓存预热