redis 缓存淘汰

redis 最大运行内存

redis 最大运行内存可以是多少呢,这个主要看 redis.conf 配置中的maxmemory <bytes>选项,设定最大运行内存的单位是字节。只有当我们的 redis 内存占用达到这个阈值时,才会开启缓存淘汰。

  • 在 64 位机器上,maxmemory 的默认值是 0。表示不限制内存大小使用,也就是说,redis 不会对可用内存大小进行检查,直到 redis 因内存不足而崩溃也无作为。
  • 在 32 位机器上,maxmemory 的默认值是 3G。因为 32 位的机器最大只支持 4GB 的内存,最大使用 3G 内存是比较合理的,这样可用避免内存使用过大,导致 redis 崩溃。

查看当前 redis 内存使用情况

  • config get maxmemory 查看最大可使用内存是多少,64位机器默认为0
  • info memory 查看当前 redis 使用的内存情况

生产上内存设置:一般推荐 redis 可使用内存为最大物理内存的四分之三

缓存淘汰策划

大体上分为 不进行数据淘汰,和 进行数据淘汰 两大部分。

不进行数据淘汰

noeviction(默认的内存淘汰策略)就是在内存满了的时候,不淘汰任何数据,并且客户端发送的写请求命令,会被拒绝处理,并返回错误。但是如果是单纯对 redis 读取数据,或者删除数据的请求,还是能正常工作。

进行数据淘汰

又可以细分为两大部分,一部分是对设置了过期时间的数据进行淘汰,另一部分是在所有数据范围内进行淘汰

在设置了过期时间的数据进行淘汰的策略有:

  1. volatile-random:随机淘汰键值对。
  2. volatile-ttl:优先淘汰更早过期的键值对,就是优先淘汰剩余时间(TTL)最短的。
  3. volatile-lru: 淘汰最久未访问的键值对
  4. volatile-lfu: 淘汰那些最近最少使用的键值对

在所有数据范围进行淘汰的策略有:

  1. allkeys-ramdom: 对所有键值对进行随机淘汰
  2. allkeys-lru: 对最久未使用的键值对进行淘汰
  3. allkeys-lfu: 对访问次数最少的键值对进行淘汰

其中 lru,和 lfu,lru 主要是针对时间,lfu 针对的是次数。

查看当前 redis 使用的内存淘汰策略,config get maxmemory-policy 命令:

127.0.0.1:6379> CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"

可以看出当前 redis 使用的是默认不淘汰数据的策略,不对任意数据进行淘汰,但不能执行新的写入命令。

缓存淘汰实现

其中,lru 和 lfu 是用的最多的两种缓存淘汰策略,主要介绍下这两种策略的具体实现。

lru 和 lfu 在传统中的实现

lru 一般都是基于链表结构的,新元素插入或者旧元素被访问时,旧会插入到头部,当链表数量达到上限后,就会从尾部删除元素。lfu 一般是用 map 来存储 key 以及访问频率 frequency count。在 map 数量达到上限后,会按访问频率进行排序,然后删除频率最低的那个元素。

lru 和 lfu 在 redis 中的实现

redis 每个值对象都有一个 lru 字段,占24个字节,如果是使用 lru 策略,那么这个值存的是当前时间,单位为秒(每194天溢出一次)。

如果采用的是 lfu 策略,那么字段又会细分为两部分,高16个字节存储时间,单位为分钟(每 45 天溢出一次),低8位存储访问次数(即访问频率),最大值 255。

lru 字段更新

每当我们去访问 key 的时候,都会去查找这个 key 对应的值 robj 对象,然后更新这个对象的 robj->lru 字段,如果是使用 lru 策略的话,那么就只需要简单的更新为当前时间就可以了。

先额外补充下:

一般情况下,获取当前时间戳,会调用系统函数 gettimeofday(),本身这个系统调用也是非常快的,但是在高并发,高 QPS 的情况下,每次都要系统调用来获取时间戳,那么这个时间消耗就被放大很多了。所以,redis 用 redisServer.lruclock 字段来缓存了系统时间戳作为 LRU 时钟。也就是说,我们在访问 key,更新值对象 robj->lru 时间字段时,是用redisServer.lruclock 的,而不是通过系统调用来更新。

这个是一个很好的优化思路,在对系统时间精度要求不是特么高,不需要太精确的地方,我们可以在应用层,通过某个字段来缓存系统时间戳,然后在其他地方,想要使用时间戳的时候,直接读取这个字段就好了,而不用系统调用来获取时间戳。在 redis 中,这种优化思路,还应用到 日志打印,是否进行持久化等地方。

robj *lookupKey(redisDb *db, robj *key, int flags) {
    ...
    robj *val = NULL;
    ...
    if (val) {
        ...
        if (!hasActiveChildProcess() && !(flags & LOOKUP_NOTOUCH)){
            if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
                updateLFU(val);
            } else {
                val->lru = LRU_CLOCK();
            }
        }
        ...
}
    
unsigned int LRU_CLOCK(void) {
    unsigned int lruclock;
    ...
        atomicGet(server.lruclock,lruclock);
    ...
    return lruclock;
}

如果是使用 lfu 淘汰策略,则是调用 updateLFU() 来更新值对象 obj->lru 字段。

void updateLFU(robj *val) {
    unsigned long counter = LFUDecrAndReturn(val); // 对低8位访问次数进行衰减
    counter = LFULogIncr(counter); // 对低8位访问次数进行+1
    val->lru = (LFUGetTimeInMinutes()<<8) | counter; // 更新lru字段,当前分钟时间+访问次数
}

如果一个 key 长时间没被访问,那么是会对这个 key 的访问次数进行衰减操作的(即count-1),即根据时间流逝越多,那么就会衰减越多,默认一分钟衰减1。然后,这个 count 次数也不是随着每次访问 key 的时候,都会 +1 的,而是有一定几率触发 +1 操作。

// 获取当前时间(单位:分钟,而且只有低16位)
// 这个虽然不使用系统调用来获取时间戳,但用 unixtime 来获取分钟级别,还是可以做到比较比较准确的
// 这也是一个不错的优化思路
unsigned long LFUGetTimeInMinutes(void) {
    return (server.unixtime/60) & 65535;
}

// 计算当前时间与上一次时间差(单位:分钟)
unsigned long LFUTimeElapsed(unsigned long ldt) {
    unsigned long now = LFUGetTimeInMinutes();
    if (now >= ldt) return now-ldt;
    return 65535-ldt+now;
}

// 对 count 尝试衰减
unsigned long LFUDecrAndReturn(robj *o) {
    unsigned long ldt = o->lru >> 8; // 获取高16位
    unsigned long counter = o->lru & 255; // 获取低8位
    // server.lfu_decay_time 默认为1,也就是相差几分钟,count 就减几,最多减到0
    unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
    if (num_periods)
        counter = (num_periods > counter) ? 0 : counter - num_periods;
    return counter;
}

// 对 count 尝试进行+1
uint8_t LFULogIncr(uint8_t counter) {
    if (counter == 255) return 255; // 如果已经加到最大值了,直接返回;低8位,最大值为255
    double r = (double)rand()/RAND_MAX; // 随机一个小数
    double baseval = counter - LFU_INIT_VAL; // 以 count - 5 做为基数
    if (baseval < 0) baseval = 0;
    // 求反比例函数,如果当前的 counter 很大了,那么这次能+1的概率就会越小,当然也受 lfu_log_factor 因素影响
    double p = 1.0/(baseval*server.lfu_log_factor+1);
    if (r < p) counter++;
    return counter;
}

可以看到,在 lfu 策略下,robj->lru字段记录分钟时间的作用,只会影响 count 访问频率,对其进行衰减操作,如果不做衰减,那么只要某个 key 访问频率达很高,接近255,那么这个 key 在后面一段很长的时间内,即使不再被访问了,那么也很难被淘汰了。

在后面介绍中,真正进行数据淘汰时,是根据 count 大小排序进行淘汰的,count 值越小,访问频率越低,越优先被淘汰。

小结:

lru 和 lfu 淘汰策略,都是针对 robj->lru 字段进行操作的,这两种策略是互斥的,在 redis 运行的时候,只能使用其中一种,所以说,robj->lru 字段会根据不同的策略,有不同的含义,以及不同的处理方式。

 

 

 

 执行淘汰逻辑

redis 会在执行命令前,先检查当前内存使用,是否达到了最大值,并且没法淘汰数据,如果是的话,再看下请求的命令是否是写,或者修改操作的,如果是,那么就会返回一个 OOM 的错误信息。

思路:

只要当前使用内存超过了最大配置内存,就会根据淘汰策略,先从过期的字段或者从所有数据的字典中,随机选择一部分 key 做为样本(默认挑选5个key),放到一个数组中。然后对样本 key 进行排序,具体是按 lru 最久访问时间,或是 lfu 访问频率,或是 ttl 离现在最短的时间等不同的淘汰规则进行排序(采用插入排序),然后,删除队尾最优的那个 key。删除后,再检查下内存是否小于最大配置内存,如果小于,那么就跳出循环。否则继续执行上面重复步骤。在每淘汰16个 key 的时候,还会判断一下执行时间有没有超时,默认是 500 微妙。如果超时了,会开启一个定时器去定时执行淘汰逻辑,然后,中断本次执行。

相关代码主要在 evict.c 文件的 performEvictions() 函数中。

小结

和传统的 lru,lfu 实现相比,redis 为了尽可能减少内存空间的使用,不采用额外的链表或者字典来存储每个 key 的访问时间,或访问频率。而是在真正内存满了之后,才随机挑选一部分 key 放到一个固定长度数组中,进行排序,筛选出最应该被淘汰的 key。这样能节省内存空间占用,也不用每次访问数据的时候,移动链表。

 

参考:
https://xiaolincoding.com/redis/module/strategy.html#内存淘汰策略

posted @ 2024-08-15 13:49  墨色山水  阅读(47)  评论(0)    收藏  举报