Redis 过期删除, 缓存淘汰, 缓存穿透, 缓存击穿, 缓存雪崩, 以及解决方案

一.缓存淘汰策略

当redis内存超出物理内存限制时,会和磁盘产生swap,这种情况性能极差,一般是不允许的。通过设置 maxmemory 限制最大使用内存。超出限制时,根据redis提供的几种内存淘汰机制让用户自己决定如何腾出新空间以提供正常的读写服务。

noeviction:当内存使用超过配置的时候会返回错误,不会驱逐任何键(默认策略,不建议使用)
allkeys-lru:加入键的时候,如果过限,首先通过LRU算法驱逐最久没有使用的键
volatile-lru:加入键的时候如果过限,首先从设置了过期时间的键集合中驱逐最久没有使用的键(不建议使用)
allkeys-random:加入键的时候如果过限,从所有key随机删除
volatile-random:加入键的时候如果过限,从过期键的集合中随机驱逐(不建议使用)
volatile-ttl:从配置了过期时间的键中驱逐马上就要过期的键
volatile-lfu:从所有配置了过期时间的键中驱逐使用频率最少的键
allkeys-lfu:从所有键中驱逐使用频率最少的键

二. 过期删除策略

Redis采用的过期策略
惰性删除+定期删除
1. 定期删除
Redis过期Key清理的机制对清理的频率和最大时间都有限制,在尽量不影响正常服务的情况下,进行过期Key的清理,以达到长时间服务的性能最优。redis会把设置了过期时间的key放在单独的字典中,每隔一段时间执行一次删除(在redis.conf配置文件设置hz,1s刷新的频率)过期key的操作。

具体的算法如下:

Redis配置项hz定义了serverCron任务的执行周期,默认为10,即CPU空闲时每秒执行10次;
每次过期key清理的时间不超过CPU时间的25%,即若hz=1,则一次清理时间最大为250ms,若hz=10,则一次清理时间最大为25ms;
清理时依次遍历所有的db;
从db中随机取20个key,判断是否过期,若过期,则逐出;
若有5个以上key过期,则重复步骤4,否则遍历下一个db;
在清理过程中,若达到了25%CPU时间,退出清理过程;
这是一个基于概率的简单算法,基本的假设是抽出的样本能够代表整个key空间,redis持续清理过期的数据直至将要过期的key的百分比降到了25%以下。这也意味着在长期来看任何给定的时刻已经过期但仍占据着内存空间的key的量最多为每秒的写操作量除以4。

由于算法采用的随机取key判断是否过期的方式,故几乎不可能清理完所有的过期Key;
调高hz参数可以提升清理的频率,过期key可以更及时的被删除,但hz太高会增加CPU时间的消耗,为了保证不会循环过度,导致卡顿,扫描时间上限默认不超过25ms。
根据以上原理,系统中应避免大量的key同时过期,给要过期的key设置一个随机范围。
优点:通过限制删除操作的时长和频率,来减少删除操作对CPU时间的占用,处理"定时删除"的缺点,定期删除过期key,处理"惰性删除"的缺点
缺点:在内存友好方面,不如"定时删除" 在CPU时间友好方面,不如"惰性删除"
难点:合理设置删除操作的执行时长(每次删除执行多长时间)和执行频率(每隔多长时间做一次删除),这个要根据服务器运行情况来定了

2. 惰性删除
过期的key并不一定会马上删除,还会占用着内存。 当你真正查询这个key时,redis会检查一下,这个设置了过期时间的key是否过期了? 如果过期了就会删除,返回空。这就是惰性删除。

优点:删除操作只发生在从数据库取出key的时候发生,而且只删除当前key,所以对CPU时间的占用是比较少的,而且此时的删除是已经到了非做不可的地步(如果此时还不删除的话,我们就会获取到了已经过期的key了)
缺点:若大量的key在超出超时时间后,很久一段时间内,都没有被获取过,那么可能发生内存泄露(无用的垃圾占用了大量的内存)
3.定时删除
在设置key的过期时间的同时,为该key创建一个定时器,让定时器在key的过期时间来临时,对key进行删除。

优点:保证内存被尽快释放
缺点:若过期key很多,删除这些key会占用很多的CPU时间,在CPU时间紧张的情况下,CPU不能把所有的时间用来做要紧的事儿,还需要去花时间删除这些key,定时器的创建耗时,若为每一个设置过期时间的key创建一个定时器(将会有大量的定时器产生),性能影响严重
结论:此方法基本上没人用

三. 缓存穿透

缓存穿透的概念很简单,用户想要查询一个数据,发现redis内存数据库没有,也就是缓存没有命中,于是向持久层数据库查询。发现也没有,于是本次查询失败。当用户很多的时候,缓存都没有命中,于是都去请求了持久层数据库。这会给持久层数据库造成很大的压力,这时候就相当于出现了缓存穿透。
解决方案
1.布隆过滤器
布隆过滤器是一个 bit 向量或者说 bit 数组,长这样:

Redis 过期删除, 缓存淘汰, 缓存穿透, 缓存击穿, 缓存雪崩, 以及解决方案
如果我们要映射一个值到布隆过滤器中,我们需要使用多个不同的哈希函数生成多个哈希值,并对每个生成的哈希值指向的 bit 位置 1,例如针对值 “baidu” 和三个不同的哈希函数分别生成了哈希值 1、4、7,则上图转变为:

Redis 过期删除, 缓存淘汰, 缓存穿透, 缓存击穿, 缓存雪崩, 以及解决方案
Ok,我们现在再存一个值 “tencent”,如果哈希函数返回 3、4、8 的话,图继续变为:

Redis 过期删除, 缓存淘汰, 缓存穿透, 缓存击穿, 缓存雪崩, 以及解决方案
值得注意的是,4 这个 bit 位由于两个值的哈希函数都返回了这个 bit 位,因此它被覆盖了。现在我们如果想查询 “dianping” 这个值是否存在,哈希函数返回了 1、5、8三个值,结果我们发现 5 这个 bit 位上的值为 0,说明没有任何一个值映射到这个 bit 位上,因此我们可以很确定地说 “dianping” 这个值不存在。而当我们需要查询 “baidu” 这个值是否存在的话,那么哈希函数必然会返回 1、4、7,然后我们检查发现这三个 bit 位上的值均为 1,那么我们可以说 “baidu” 存在了么?答案是不可以,只能是 “baidu” 这个值可能存在。

这是为什么呢?答案跟简单,因为随着增加的值越来越多,被置为 1 的 bit 位也会越来越多,这样某个值 “taobao” 即使没有被存储过,但是万一哈希函数返回的三个 bit 位都被其他值置位了 1 ,那么程序还是会判断 “taobao” 这个值存在。
2、缓存空对象

当存储层不命中后,即使返回的空对象也将其缓存起来,同时会设置一个过期时间,之后再访问这个数据将会从缓存中获取,保护了后端数据源;

但是这种方法会存在两个问题:

如果空值能够被缓存起来,这就意味着缓存需要更多的空间存储更多的键,因为这当中可能会有很多的空值的键;
即使对空值设置了过期时间,还是会存在缓存层和存储层的数据会有一段时间窗口的不一致,这对于需要保持一致性的业务会有影响。

四.缓存击穿

这里需要注意和缓存击穿的区别,缓存击穿,是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。
解决方案:
1.方案一

后台刷新

后台定义一个job(定时任务)专门主动更新缓存数据.比如,一个缓存中的数据过期时间是30分钟,那么job每隔29分钟定时刷新数据(将从数据库中查到的数据更新到缓存中).

这种方案比较容易理解,但会增加系统复杂度。比较适合那些 key 相对固定,cache 粒度较大的业务,key 比较分散的则不太适合,实现起来也比较复杂。
方案二

检查更新

将缓存key的过期时间(绝对时间)一起保存到缓存中(可以拼接,可以添加新字段,可以采用单独的key保存…不管用什么方式,只要两者建立好关联关系就行).在每次执行get操作后,都将get出来的缓存过期时间与当前系统时间做一个对比,如果缓存过期时间-当前系统时间<=1分钟(自定义的一个值),则主动更新缓存.这样就能保证缓存中的数据始终是最新的(和方案一一样,让数据不过期.)

这种方案在特殊情况下也会有问题。假设缓存过期时间是12:00,而 11:59
到 12:00这 1 分钟时间里恰好没有 get 请求过来,又恰好请求都在 11:30 分的时
候高并发过来,那就悲剧了。这种情况比较极端,但并不是没有可能。因为“高
并发”也可能是阶段性在某个时间点爆发。

方案三
分级缓存

采用 L1 (一级缓存)和 L2(二级缓存) 缓存方式,L1 缓存失效时间短,L2 缓存失效时间长。 请求优先从 L1 缓存获取数据,如果 L1缓存未命中则加锁,只有 1 个线程获取到锁,这个线程再从数据库中读取数据并将数据再更新到到 L1 缓存和 L2 缓存中,而其他线程依旧从 L2 缓存获取数据并返回。

这种方式,主要是通过避免缓存同时失效并结合锁机制实现。所以,当数据更
新时,只能淘汰 L1 缓存,不能同时将 L1 和 L2 中的缓存同时淘汰。L2 缓存中
可能会存在脏数据,需要业务能够容忍这种短时间的不一致。而且,这种方案
可能会造成额外的缓存空间浪费。
方案四: 加锁
比如,当30个请求一起并发过来,在双重判断时,第一个请求去数据库查询并更新缓存数据,剩下的29个请求则是依次排队取缓存中取数据.请求排在后面的用户的体验会不爽.

五. 缓存雪崩

缓存雪崩是指,缓存层出现了错误,不能正常工作了。于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会挂掉的情况
解决方案:
1.在缓存的时候给过期时间加上一个随机值,这样就会大幅度的减少缓存在同一时间过期。
2. 对与“Redis挂掉了,请求全部走数据库” 这种情况,我们可以有以下几种思路:
3.事发前,实现Redis的高可用(主从架构+sentinel或者Redis cluster),尽量避免Redis挂掉这种情况发生。
4. 事发中,万一redis真的挂掉,我们可以设置本地缓存+限流(hystrix),尽量避免我们的数据库被干掉(起码保证我们的服务还是正常工作的)。
5.事发后,redis持久化,重启后自动从磁盘上加载数据,快速恢复缓存数据。