redis-缓存回收策略


Redis 缓存使用了内存保存数据,使数据的存储和读取都得到了极大的提升,但是由于计算机中“内存”的造价却在磁盘的数百倍之上;这也导致我们无法使用 Redis 缓存所有的数据;

那样也衍生出一个问题:当Redis中缓存的数据大小,达到上限后,redis 会作出怎样的操作?

为了解决这个问题,redis 有着自身维护的一套 缓存数据的淘汰机制;其实简单来说 就是分成两步:

  1. 根据指定规则筛选出可以"放弃"的 key值
  2. 释放对应key值的空间,用于保存新的Key值

如何设置 redis 的内存暂用值

Redis 中有一个 maxmemory 的概念,主要是为了将 redis 的使用内存限定在一个固定的大小,当使用内存超出限定值后,根据 maxmemory-policy 配置的策略进行内存回收;

maxmemory 的设定值有如下两种方式:

第一种:通过 congfig set 命令进行设置:

127.0.0.1:6379> config get maxmemory
1) "maxmemory"
2) "0"
127.0.0.1:6379> config set maxmemory 1024MB
OK
127.0.0.1:6379> config get maxmemory
1) "maxmemory"
2) "1073741824"

第二种:通过 redis.conf 文件修改

maxmemory 1024MB

注意: 不配置 maxmemory 的值时,将默认为0;

64 bit系统:maxmemory 设置为 0 表示不限制 Redis 内存使用

32 bit系统:maxmemory 设置为 0 表示限制 Redis 内存 不能超过 3G。

(造成这个现在的其实是系统对内存使用的限制造成的,并不是redis造成的,有兴趣可以了解下 不同 bit 系统对内存的利用)

Redis 缓存有哪些策略

Redis4.0 之前共实现了6种内存策略,之后又增加了两种内存策略;主要可以分成两类:

  • 不进行数据淘汰的策略,只有 noeviction 这一种
  • 会淘汰key值的7种

会进行淘汰的 7 种策略,我们可以再进一步根据淘汰候选数据集的范围把它们分成两类:

  • 在设置了过期时间的数据中进行淘汰,包括 volatile-random、volatile-ttl、volatile-lru、volatile-lfu(Redis 4.0 后新增)四种。
  • 在所有数据范围内进行淘汰,包括 allkeys-lru、allkeys-random、allkeys-lfu(Redis 4.0 后新增)三种。

如下图:

1、redis内存淘汰策略

具体解析下:

  • noeviction 策略 : 当 Redis 缓存达到了 maxmemory 配置的值后,再有写入请求到来时,redis 将不再提供写入服务,直接响应错误
  • volatile-ttl 策略 : 在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除
  • volatile-random 策略 : 在设置了过期时间的键值对中,进行随机删除
  • volatile-lru 策略 :使用 LRU 算法筛选设置了过期时间的键值对
  • volatile-lfu 策略 : 使用 LFU 算法选择设置了过期时间的键值对
  • allkeys-random 策略,从所有键值对中随机选择并删除数据;
  • allkeys-lru 策略,使用 LRU 算法在所有数据中进行筛选。
  • allkeys-lfu 策略,使用 LFU 算法在所有数据中进行筛选。

注意: volatile-random、volatile-ttl、volatile-lru 和 volatile-lfu 这四种淘汰策略。它们筛选的候选数据范围,被限制在已经设置了过期时间的键值对上。也正因为此,即使缓存没有写满,这些数据如果过期了,也会被删除。反之:allkeys-lru、allkeys-random、allkeys-lfu 这3种淘汰策略。他们的筛选的候选数据范围是全部key,所以他们淘汰的key可能是没到期的。

浅谈下LRU 算法

由上面可知 volatile-lruallkeys-lru 策略都是使用了LRU算法。

1. 那什么是LRU算法呢?

LRU(least recently used) 其实是一种缓存置换的算法,在linux内存管理也常有体现。即在缓存有限的情况下,如果有新的数据需要加入缓存,则我们需要先将最不可能被访问到的数据移除,以腾出空间缓存新数据;但是我们无法预知到缓存的数据哪些将不会被访问。。。所以我们只能基于如下的规则来实现算法:

如果一个KEY经常被访问,那么这个KEY的idle time(空闲时间)应该是最小的,而且再次访问的概率应该是最大的;但是另外一个角度解读,这个不是必然事件,idle time 最大不能代表这个 key 就不会再被访问。

2. 那具体是如何筛选出 idle time 最大的key 呢?

我们举一个例子(其中一种较为简单的方式),来看看 LRU 算法是如何做到的:

假设:我们的内存页能存储的数据为 5 个;我们将存储的数据结构用 双向链表hashmap(这个是为了保证存储和读取都可以实现 O(1) ), 双向链表两端 分别标记为 MRU端 和 LRU 端

processon-Redis-LRU算法

如上图所示:

我们数据页分别保存有 KEY1KEY5 5个数据如果KEY4被访问时候,我们遍历 Hashmap 确认了当前数据已经存在,于是把他的 KEY4 节点移动到 MRU端 (链表头部);(因为是双线链表+hashmap机构,所以更改关系非常方便。不需要整个连进行遍历移动)生成新的双线链表; 然后,当 新数据 KEY6 写入时,因为内存页已经满了,所以我们把 LRU端 (链表尾部) 的一个元素移除,然后再在 MRU端 插入新的元素

缺点:

上面的 LRU 算法实现方式不是很复杂,但是通过双向链表 和 hashmap 来管理所有的缓存数据,显然有着一些致命的缺陷;这会带来大量的额外的内存空间损耗,而且我们都知道在 redis 中是“单线程”的。如果没次的读写都需要维护这个链表的移动和hashmap关系,这会大大加大了 Redis 服务的计算时长,进而降低了 Redis 缓存的性能,这也是我们不能接受的。

Redis 中 LRU 的实现

根据上述,显然 Redis 并没有使用双线链表实现LRU算法。

首先我们可以看看 redisObj 结构体(其实 redis 整体上是一个大的dict,key是一个string,而value是一个redisObj)

/*
 * Redis 对象
 */
typedef struct redisObject {

    // 类型
    unsigned type:4;

    // 对齐位
    unsigned notused:2;

    // 编码方式
    unsigned encoding:4;

    // LRU 时间(相对于 server.lruclock)
    unsigned lru:LRU_BITS;

    // 引用计数
    int refcount;

    // 指向对象的值
    void *ptr;

} robj;

我们可以看到有一个LRU 的字段,而这个字段是通过调用 lookupKey 方法来获取值的

robj *lookupKey(redisDb *db, robj *key, int flags) {
    ...
            if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) { //如果配置的是lfu方式,则更新lfu
                updateLFU(val);
            } else {
                val->lru = LRU_CLOCK();//否则按lru方式更新
            }
    ...
}

其实可以简单理解,redis 内部维护了一个 公共的 LRU 时钟,定时刷新对应的值(就类比:我们的时钟的秒针,一直在走);当 Redis的dict中来获取数值的时候,就会把当前lru时钟的值返回回去,作为当前的值返回回去。

那么我们缓存的数据中每一个都有一个自己访问的的时间值,换句话说,根据这个 LRU 时钟的时间戳,我们可以清楚的定位到每一个key的最后访问时间,我们也可以算出他的 idle time;最后我们只需要变量 所有Key 的idle time 我们就可以知道 应该淘汰哪个Key了。

然而淘汰时总不能挨个遍历dict中的所有槽,逐个比较 LRU 值大小吧,每一次都遍历,单进程的redis 读写估计会跟乌龟一样慢。

我们上面有说过 LRU 算法本质其实也是概率性的,那么实现的时候索性就将概率性贯彻到底。。

Redis初始的实现算法很简单,随机从dict中取出五个key,淘汰一个lru字段值最小的。(随机选取的key是个可配置的参数maxmemory-samples,默认值为5).

后来在redis3.0中又改进了一版,引入了一个Pool的概念,第一次随机选取的key都会放入一个pool中,pool中的key是按lru大小顺序排列的。接下来每次随机选取的keylru值必须小于pool中最小的lru才会继续放入,直到将pool放满。放满之后,每次如果有新的key需要放入,需要将pool中lru最大的一个key取出。

最后,淘汰的时候,直接从pool中退出lru 最小的 淘汰即可

有了解的应该知道Redis执行命令的时候,会调用processCommand 函数 (有兴趣可以看下)

int processCommand(redisClient *c) {
   ....
   
    /* Handle the maxmemory directive.
     *
     * First we try to free some memory if possible (if there are volatile
     * keys in the dataset). If there are not the only thing we can do
     * is returning an error. */
    // 如果设置了最大内存,那么检查内存是否超过限制,并做相应的操作
    if (server.maxmemory) {
        // 如果内存已超过限制,那么尝试通过删除过期键来释放内存 
        int retval = freeMemoryIfNeeded();
        // 如果即将要执行的命令可能占用大量内存(REDIS_CMD_DENYOOM)
        // 并且前面的内存释放失败的话
        // 那么向客户端返回内存错误
        if ((c->cmd->flags & REDIS_CMD_DENYOOM) && retval == REDIS_ERR) {
            flagTransaction(c);
            addReply(c, shared.oomerr);
            return REDIS_OK;
        }
    }
    ....

}

其实上面可以看到 最后释放内存 是通过了 freeMemoryIfNeeded 函数来操作的

具体的分析可以看下这个文章 Redis源码解析(11) 内存淘汰策略

maxmemory-samples 该如何配置

因为 LRU 算法本身就具有概率性的,而Redis 作者在实现的方式也是基于概率的;那么 在两个都是概率性的情况下,LRU 算法 在实际使用中是否会极大的偏离了我们的预想期呢?

作者做了个试验,结果如下图:

https://redis.io/images/redisdoc/lru_comparison.png

上图分为3个不同的色带:

  • 浅灰色带是被逐出的对象。
  • 灰带是未逐出的对象。
  • 绿带是添加的对象。

如果左上图1,我们定义为 LRU 算法 的最理想情况,新增加的key和最近被访问的key都不应该被逐出;

我们可以看到 maxmemory-samples 设置为5的时候 Redis 2.8 服务展现出来的结果,还是有点差强人意的,

但是当作者在 Redis 3.0 后引入了pool 的概念后,展现出来的结果有了较大的提升。至少新加入的 key 被回收回去的情况有了很大的提升。

当我们在 Redis 3.0 服务中 将 maxmemory-samples 的值提升到 10 的时候,已经非常接近理想值了。。

但是如果你认真阅读了上文,你会清楚知道 maxmemory-samples 越大 对服务器的 cpu 都会有较大的消耗的;

所以作者在最后也给出了自己的建议:

However you can raise the sample size to 10 at the cost of some additional CPU usage in order to closely approximate true LRU, and check if this makes a difference in your cache misses rate.

但其实 如果服务器性能不是特别优秀,其实使用默认值:5; 就够了。。。

参考:

Redis LRU算法的随机说明 :http://antirez.com/news/109

源码分析查考: https://blog.csdn.net/weixin_...

Redis用作LRU缓存 : https://redis.io/topics/lru-c...

LRU原理和Redis实现——一个今日头条的面试题:https://zhuanlan.zhihu.com/p/...


一只小蜗牛
318 声望12 粉丝