Redis的过期键的管理策略

393 阅读8分钟

前记

Redis作为一款优秀的开源缓存中间件,早已成为我们平时开发当中必不可少的一大利器。通过使用Redis,我们可以帮助DB抗住更高的并发流量;也可以在业务体量不大且没有可靠性要求的场景下实现异步队列(PS:比如日志类型的数据入库,统计信息的异步处理这些需求);如果你使用的是4.0以上版本的Redis,那么恭喜你,你可以使用许许多多的Redis Module来为你的服务提供更多的支撑(PS:说实话,虽然功能多了,但是我还是觉得Redis老老实实做缓存挺合适的。像一些比如布隆过滤器,限流这些功能都有现成的组件给我们用它不香么?)。

但是话说回来,我们对于Redis缓存的过期删除策略又有多少了解呢?如果你也尚处学习阶段对这个知识点还不太清楚的话,那么就请你继续坚持看下去,希望接下来的内容能够让你有所收获。

Redis的删除策略

其实Redis在存储内存键值对的时候,使用了字典(也就是hash)这种数据结构。具体来说就是Redis将所有的键都存储在一个字典当中,将所有设置了过期时间的键也都存放另一个过期键字典中进行管理。且Redis在管理这些键的时候使用了:定时删除和惰性删除两种策略。

定时删除

定时删除从字面上就很好理解,就是Redis会在固定的时间(PS:这里默认是100ms一次)里面去扫描过期字典当中的键,如果碰到过期的键就直接删除。但是大家仔细品,Redis的工作线程是单线程的的,如果过期字典当中存储的键很多,那么不久直接会阻塞线上业务(PS:这里我想了一下,最终的结论是大佬们设计出来的Redis不能这么辣鸡)?

果不其然,大佬们还是对这个问题进行了解决,那就是采用一种贪心的策略来进行过期键的回收(具体如下):

  • 从过期字典当中选取20个key
  • 检查这20个key当中的过期时间,并且回收
  • 如果回收的键超过1/4就重复这一过程

其实你在品,你会一定会在心里狂呼:我去!好像还是有点问题。如果在某一段时间内过期的键特别多,那么不是会一直重复这个过程?线上业务又阻塞?垃圾中间件! emmmmm其实兄弟你不用愁了,设计Redis的那些大佬也已经解决了这个问题,我设置个阈值不久好了么。这样当定时回收这个动作持续了超过一定的时间(默认是25ms哈),我就把这个动作停下来继续线上业务就成了。

不过兄弟,别想劝你别这么早高兴,其实你在想象另一个场景。如果你的业务请求到Redis当中正好碰到了Redis在定时删除过期键这个动作,那么你很幸运,先等25ms再说。然后你的客户端的等待时间设置的比较小(这里我先假设为20ms吧),然后你会收到请求超时的异常。你随手看了一眼Redis实例的情况,发现IO和CPU,内存占用率都不高,然后你怀疑是你的Redis操作一定是一个slow的操作,结果回头一看我一个set qmy shuai 也耗时?接着你心中又出现了一个声音:???我是谁?我在那?我在干什么?

通过上面那个事例我是想给大家提个醒,当我们在设置缓存当中键的过期时间的时候,一定要避免大量的键在同一时间失效。你问我这样做有什么好处?我告诉你这样做能让你少几个事故,然后不用那么早回家休息呀。

  • 首先大量的键同时失效有可能会造成过期键的回收阻塞线上业务
  • 其次那些键是一些热点键同时失效(那不就是缓存雪崩了兄弟)?然后DB被打挂,然后服务不可用,然后把锅背起来就可以回家了。

好了讲到这里Redis过期键的定时回收策略大家应该已经有一定的了解了,那么接下来我们说一说惰性回收。

惰性回收

惰性回收,顾名思义啊兄弟们,那不就是懒么。懒那不就是能不干就不干么。那什么时候做?那就等到我访问这个键的时候做一下判断,如果这个键已经过期了我就给它删掉,如果没过期我就返回给客户端。

什么,你问我为什么这样设计?

在各种系统设计当中惰性思想你见的少了么?比如在实现单例模式的时候,你可以使用静态内部类创建单例模式,这样只有在你获取该实例的时候才会加载该类进行初始化的工作;在比如Mysql当中使用Change Buffer可以将写操作缓存下来,在真正读到该数据的时候才进行merge操作;在在比如你天天CRUD时候使用到的HashMap,ArrayList也不是说在一开始new对象的时候就初始化把,而是在你第一次添加元素的时候才执行的初始化操作;在在在比如,你在使用Redis作为DB缓存的时候,当更新数据时,为什么要删除缓存然后更新数据库,而不是更新缓存更新数据库?其实就是为了节约CPU资源,提高系统的吞吐。不过这样做也有一个缺陷:那就是本已经过期的键长时间不能被回收,内存空间不能被复用或者释放。

Redis的LRU

LRU算法我们是一种我们都很熟悉的缓存管理算法,按照英文的直接原义就是Least Recently Used,即最近最久未使用法,它是按照一个非常著名的计算机操作系统基础理论得来的:最近使用的页面数据会在未来一段时期内仍然被使用,已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。基于这个思想,LRU算法每次从内存中找到最久未使用的数据然后置换出来,从而存入新的数据,它的主要衡量指标是使用的时间。

我们知道Redis是一款内存数据库,尽管有定时删除策略和惰性删除策略来管理过期键,Redis运行时所占用的内存空间也往往很有可能会超过其进程内存限制,在这样的情况下就会频繁的使用Swap分区来保证程序的运行(SWAP分区会频繁的进行磁盘和内存的数据置换,效率极低)。这样的情况属实是Redis这个公认的高性能靓仔没有办法忍受的。

因此Redis就通过设置maxmemory参数来限制其所使用的内存空间。当程序运行时占用的内存空间大小超过maxmemory之后,就会通过集中可选的缓存淘汰策略来管理腾出内存空间,常见的缓存淘汰策略如下:

  • noeviction 不会继续服务写请求 (DEL请求可以继续服务),读请求可以继续进行。这样可以保证不会丢失数据,但是会让线上的业务不能持续进行。这是默认的淘汰策略。

  • volatile-lru 尝试淘汰设置了过期时间的 key,最少使用的 key 优先被淘汰。没有设置过期时间的 key 不会被淘汰,这样可以保证需要持久化的数据不会突然丢失。

  • volatile-ttl 跟上面一样,除了淘汰的策略不是 LRU,而是 key 的剩余寿命 ttl 的值,ttl 越小越优先被淘汰。

  • volatile-random 跟上面一样,不过淘汰的 key 是过期 key 集合中随机的 key。

  • allkeys-lru 区别于 volatile-lru,这个策略要淘汰的 key 对象是全体的 key 集合,而不只是过期的 key 集合。这意味着没有设置过期时间的 key 也会被淘汰。

  • allkeys-random 跟上面一样,不过淘汰的策略是随机的 key。

其实allkeys-** 和volatile-**策略的区别也很显然,就是针对的对象不同。allkeys策略针对的是所有键,而volatile策略则只针对过期键。

通常情况下,我们实现LRU算法,是通过双向链表或数组加链表的形式(JAVA版本参考LinkedHashMap),而无论那种实现都会带来很多额外的存储开销,这也是Redis所不能接受的。因此在实现上,Redis使用了一种近似的LRU算法

即Redis会在redisObj这个结构体当中维护一个24bit的lru字段,来记录最近的访问时间。整个的缓存淘汰过程就是:

  • 如果当前的内存使用超过Maxmemory的设置,就进行一次缓存淘汰,首先随机采用m个key。
  • 淘汰掉最久没有被访问的key
  • 判断当前的内存使用情况是否超过Maxmemory,若超过则重复该过程

以上就是我对整个Redis过期键管理相关知识点的总结,能够阅读到这里的朋友希望我所写的内容能够给到你们一些帮助。也希望大家能够继续努力,在程序猿这条不归路上更加坚定的走下去。