深入了解Redis内存淘汰策略中的LRU算法应用

发布于:2024-04-28 ⋅ 阅读:(25) ⋅ 点赞:(0)

LRU算法简析

LRU(Least Recently Used,最近最少使用)算法是一种常见的内存淘汰策略,它根据数据的访问时间来决定哪些数据会被淘汰。LRU算法的核心思想是:最久未被访问的数据,被认为是最不常用的数据,应该被优先淘汰。

LRU 算法广泛应用在诸多系统内,例如 Linux 内核页表交换,MySQL Buffer Pool 缓存页替换,以及 Redis 数据淘汰策略。

简单来说,可以认为LRU算法是在维护一个链表,每次操作某个数据,就把数据移到链表的头部。

例如:

  1. 向一个缓存空间依次插入三个数据 A/B/C,填满了缓存空间;

  2. 读取数据 A 一次,按照访问时间排序,数据 A 被移动到缓存头部;

  3. 插入数据 D 的时候,由于缓存空间已满,触发了 LRU 的淘汰策略,数据 B 被移出,缓存空间只保留了 D/A/C。
    在这里插入图片描述

但是一般而言,LRU 算法的数据结构不会如上边那样,仅使用简单的队列或链表去缓存数据,而是会采用 Hash 表 + 双向链表的结构类似于hsahMap的结构,利用 Hash 表确保数据查找的时间复杂度是 O(1),双向链表又可以使数据插入 / 删除等操作也是 O(1)。

Redis中的LRU算法

Redis中为何使用近似LRU算法

按照官方文档的介绍,Redis 所实现的是一种近似的 LRU 算法。

因为若严格按LRU实现,假设Redis保存的数据较多,还要在代码中实现为:

Redis使用最大内存时,可容纳的所有数据维护一个链表需额外内存空间来保存链表

每当有新数据插入或现有数据被再次访问,需执行多次链表操作在访问数据的过程中,让Redis受到数据移动和链表操作的开销影响,最终导致降低Redis访问性能。

所以,无论是为节省内存 or 保持Redis高性能,Redis并未严格按LRU基本原理实现,而是提供了一个近似LRU算法实现。

LRU算法要求删除最近最少使用的kv,所以redis需要维护每一个kv的使用时间来判断数据访问的时效性。这就是LRU 时钟。

LRU 时钟

LRU时钟:记录数据每次访问的时间戳。

下面的这段代码是获取LRU时钟的方法接口:

#define LRU_BITS 24
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1)
#define LRU_CLOCK_RESOLUTION 1000
 
unsigned int getLRUClock(void) {
    return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX;
}

通过当前的 unix 时间戳获取 LRU 时钟。unix 时间戳通过接口 mstime ()获取,得到的是从 1970年1月1日早上8点到当前时刻的时间间隔,以毫秒为单位(mstime底层实现用的是 c 的系统函数 gettimeofday)。
其中,LRU_BITS 表示 LRU 时钟的位数;
LRU_CLOCK_MAX 为 LRU 时钟的最大值;LRU_CLOCK_RESOLUTION 则表示每个 LRU 基本单位对应到自然时钟的毫秒数,即精度,按照这个宏定义,LRU 时钟的最小刻度为 1000 毫秒。

将自然时钟和 LRU 时钟作对比:

    a)自然时钟最大值为 11:59:59,LRU 时钟最大值为 LRU_CLOCK_MAX = 2^24 - 1;
   
    b)自然时钟的最小刻度为 1秒, LRU 时钟的最小刻度为 1000 毫秒; 		
   
    c)自然时钟的一个轮回是 12小时,LRU 时钟的一个轮回是 2^24 * 1000 毫秒(一轮的计算方式是:( 时钟最大值 + 1 ) * 最小刻度);

因为 LRU_CLOCK_MAX 是 2 的幂减 1,即它的二进制表示全是 1,所以这里的 & 其实是取模的意思。
那么 getLRUClock 函数的含义就是定位到 LRU 时钟的某个刻度。

Redis中的LRU时钟

在redis中,引入了LRU 时钟来记录使用时间,每个对象的每次被访问都会记录下当前服务器的 LRU 时钟,然后用服务器的 LRU 时钟减去对象本身的时钟,得到的就是这个对象没有被访问的时间间隔(也称空闲时间),空闲时间最大的就是需要淘汰的对象。

具体实现:

Redis Server会使用一个实例级别的全局LRU时钟,每个KV对的LRU time会根据全局LRU时钟进行设置

1、Redis 对象(数据结构)

Redis 中的所有对象定义为 redisObject 结构体,也正是这些对象采用了 LRU 算法进行内存回收,所以每个对象需要一个成员来用来记录该对象的最近一次被访问的时间(即 lru 成员),由于时钟的最大值只需要 24 个比特位就能表示,所以结构体定义时采用了位域。定义如下:

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS;
    int refcount;
    void *ptr;
} robj;
2、Redis 定时器(全局LRU时钟)

Redis 中有一个全局的定时器函数 serverCron,用于刷新服务器的 LRU 时钟,函数大致实现如下:

int serverCron(...) {
    ...
    server.lruclock = getLRUClock();
    ...
}
3、Redis 对象的 LRU 时钟(根据全局LRU时钟进行设置)

每个 Redis 对象的 LRU 时钟的计算方式由 LRU_CLOCK 给出,实现如下:

#define LRU_CLOCK() ((1000/server.hz <= LRU_CLOCK_RESOLUTION) ? server.lruclock : getLRUClock())

server.lruclock 代表服务器的 LRU 时钟

server.hz 决定这个时钟的刷新频率,即每秒钟会调用 server.hz 次 serverCron 函数。默认值为 10。

那么,服务器每 1 / server.hz 秒就会调用一次定时器函数 serverCron。

1 / server.hz 代表了 serverCron 这个定时器函数两次调用之间的最小时间间隔(以秒为单位),那么 1000 / server.hz 就是以毫秒为单位的定时器函数两次调用之间的最小时间间隔。

以上代码的逻辑就是:如果这个最小时间间隔小于等于 LRU 时钟的精度,那么不需要重新计算 LRU时钟,直接用服务器 LRU时钟做近似值即可。

因为时间间隔越小,server.lruclock 刷新的越频繁;相反,当时间间隔很大的时候,server.lruclock 的刷新可能不及时,所以需要用 getLRUClock 重新计算准确的 LRU 时钟。

4、Redis 对象更新 LRU 时钟时机

Redis 对象更新 LRU 时钟的地方有两个:

a)     对象创建时;
b)     对象被使用时;
a) 对象创建时

createObject 函数用于创建一个 Redis 对象,代码实现在 object.c 中:

robj *createObject(int type, void *ptr) {
    robj *o = zmalloc(sizeof(*o));
    o->type = type;
    o->encoding = OBJ_ENCODING_RAW;
    o->ptr = ptr;
    o->refcount = 1;
    o->lru = LRU_CLOCK();
    return o;
}
b) 对象被使用时

lookupKey 不会直接被 redis 命令调用,往往是通过lookupKeyRead()、lookupKeyWrite() 、lookupKeyReadWithFlags() 间接调用的,这个函数的作用是通过传入的 key 查找对应的 redis 对象,并且会在条件满足时设置上 LRU 时钟。

这是简化代码:

robj *lookupKey(redisDb *db, robj *key, int flags) {
    dictEntry *de = dictFind(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);
        ...
        val->lru = LRU_CLOCK();
        ...
        return val;
    } else {
        return NULL;
    }
}

Redis 中的 LRU 内存回收 (内存淘汰)

LRU 淘汰策略配置
maxmemory 1073741824
maxmemory-policy allkeys-lru
maxmemory-samples 5

这三个配置项决定了 Redis 内存回收时的机制。

maxmemory 指定了内存使用的极限,以字节为单位。当内存达到极限时,他会尝试去删除一些键值。
设置maxmemory时,如果你的 Redis 是主 Redis 时 (Redis 采用主从模式时),需要预留一部分系统内存给同步队列缓存。

maxmemory-policy 配置来指定删除的策略。如果根据指定的策略无法删除键或者策略本身就是 ‘noeviction’,那么,Redis 会根据命令的类型做出不同的回应:会给需要更多内存的命令返回一个错误,例如 SET、LPUSH 等等;而像 GET 这样的只读命令则可以继续正常运行。

maxmemory-samples :指定了在进行删除时的键的采样数量。LRU 和 TTL 都是近似算法,所以可以根据参数来进行取舍,到底是要速度还是精确度。默认值一般填 5。10 的话已经非常近似正式的 LRU 算法了,但是会多一些 CPU 消耗;3 的话执行更快,然而不够精确。

空闲时间

LRU 算法的执行依据是将空闲时间最大的淘汰掉,每个对象知道自己上次使用的时间,那么就可以计算出自己空闲了多久,可以通过 estimateObjectIdleTime 接口得出 idletime.

unsigned long long estimateObjectIdleTime(robj *o) {
    unsigned long long lruclock = LRU_CLOCK();
    if (lruclock >= o->lru) {
        return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
    } else {
        return (lruclock + (LRU_CLOCK_MAX - o->lru)) * LRU_CLOCK_RESOLUTION;
    }
}

从代码可以看出,因为LRU时钟只有24位,所以LRU时钟最大值是LRU_CLOCK_MAX,也就是2^24 - 1,换算下大概是190天左右,所以LRU时钟大概是190天一轮,当某个kv的LRU时钟比全局LRU时钟还要大时,说明kv已经超过190天没使用,这时他的 空闲时间是对象的LRU时钟加上全局的LRU时钟。

近似LRU具体执行过程
过程简述

Redis 的数据库是一个巨大的字典,最上层是由键值对组成的。当内存使用超过最大使用数时,就需要采用回收策略进行内存回收。

如果回收策略采用 LRU,那么就会在这个大字典里面随机采样,挑选出空闲时间最大的键进行删除。

而回收池会存在于整个服务器的生命周期中,所以它是一个全局变量。

近似LRU过程详解
  1. 删除操作发生在每一次处理客户端命令时。当 server.maxmemory 的值非 0,则检测是否有需要回收的内存。如果有则执行 2) ;

  2. 随机从大字典中取出 server.maxmemory_samples 个键(实际取到的数量取决于大字典原本的大小),然后用一个长度为 16 (由 MAXMEMORY_EVICTION_POOL_SIZE 指定) 的 evictionPool (回收池)对这几个键进行筛选,筛选出 idletime (空闲时间)最长的键,并且按照 idletime 从小到大的顺序排列在 evictionPool 中;

  3. 从 evictionPool 池中取出 idletime 最大且在字典中存在的键作为 bestkey 执行删除,并且从 evictionPool 池中移除;

LRU 回收算法的实际执行流程
//eviction_pool数组长度
#define MAXMEMORY_EVICTION_POOL_SIZE 16
struct evictionPoolEntry {                                   
	//空闲时间
    unsigned long long idle;
    //键名key
    sds key;
};
int processCommand(client *c) {
    ...
    if (server.maxmemory) freeMemoryIfNeeded();                    
    ...
}

//收集 evictionPool 元素并且找出空闲时间最大的键并进行释放;
int freeMemoryIfNeeded(void) {
    ...
    if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_LRU ||
        server.maxmemory_policy == MAXMEMORY_VOLATILE_LRU) {
        //eviction_pool 是数据库对象 db 的成员,代表回收池,是evictionPoolEntry 类型的数组,数组长度由MAXMEMORY_EVICTION_POOL_SIZE 指定,默认值为 16;
        struct evictionPoolEntry *pool = db->eviction_pool;      
        while(bestkey == NULL) {
			//evictionPoolPopulate(...) 接口用于随机采样数据库中的键,并且逐一和回收池中的键的空闲时间进行比较,筛选出空闲时间最大的键留在回收池中
            evictionPoolPopulate(dict, db->dict, db->eviction_pool);   
            for (k = MAXMEMORY_EVICTION_POOL_SIZE-1; k >= 0; k--) {
                if (pool[k].key == NULL) continue;
                de = dictFind(dict,pool[k].key);
                sdsfree(pool[k].key);
                memmove(pool+k,pool+k+1,
                  sizeof(pool[0])*(MAXMEMORY_EVICTION_POOL_SIZE-k-1));
                pool[MAXMEMORY_EVICTION_POOL_SIZE-1].key = NULL;
                pool[MAXMEMORY_EVICTION_POOL_SIZE-1].idle = 0;
                if (de) {
                	//找出空闲时间最大且存在的键,等待执行删除操作
                    bestkey = dictGetKey(de);                        
                    break;
                } else {
                    continue;
                }
            }
        }
    }
    ...
}
回收池更新详解(evictionPoolPopulate)—LRU 算法的核心
#define EVICTION_SAMPLES_ARRAY_SIZE 16
void evictionPoolPopulate(dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) {
    int j, k, count;
    dictEntry *_samples[EVICTION_SAMPLES_ARRAY_SIZE];
    dictEntry **samples;
 
    if (server.maxmemory_samples <= EVICTION_SAMPLES_ARRAY_SIZE) {
        samples = _samples;
    } else {
        samples = zmalloc(sizeof(samples[0])*server.maxmemory_samples);
    }
 
    count = dictGetSomeKeys(sampledict,samples,server.maxmemory_samples);
    for (j = 0; j < count; j++) {
        unsigned long long idle;
        sds key;
        robj *o;
        dictEntry *de;
 
        de = samples[j];
        key = dictGetKey(de);
 
        if (sampledict != keydict) de = dictFind(keydict, key);
        o = dictGetVal(de);
        idle = estimateObjectIdleTime(o);
 
        k = 0;
        while (k < MAXMEMORY_EVICTION_POOL_SIZE &&
               pool[k].key &&
               pool[k].idle < idle) k++;
        if (k == 0 && pool[MAXMEMORY_EVICTION_POOL_SIZE-1].key != NULL) {
            continue;                                                           /* a */
        } else if (k < MAXMEMORY_EVICTION_POOL_SIZE && pool[k].key == NULL) {   /* b */
        } else {
            if (pool[MAXMEMORY_EVICTION_POOL_SIZE-1].key == NULL) {             /* c */
                memmove(pool+k+1,pool+k,
                    sizeof(pool[0])*(MAXMEMORY_EVICTION_POOL_SIZE-k-1));
            } else {
                k--;                                                            /* d */
                sdsfree(pool[0].key);
                memmove(pool,pool+1,sizeof(pool[0])*k);
            }
        }
        pool[k].key = sdsdup(key);
        pool[k].idle = idle;
    }
    if (samples != _samples) zfree(samples);
}

这是 LRU 算法的核心,首先从目标字典中随机采样出 server.maxmemory_samples 个键,缓存在 samples 数组中,然后一个一个取出来,并且和回收池中的已有的键对比空闲时间,从而更新回收池。更新的过程首先,利用遍历找到每个键的实际插入位置 k ,然后,总共涉及四种情况如下:

   a) 回收池已满,且当前插入的元素的空闲时间最小,则不作任何操作;
   b) 回收池未满,且将要插入的位置 k 原本没有键,则可直接执行插入操作;
   c) 回收池未满,且将要插入的位置 k 原本已经有键,则将当前第 k 个以后的元素往后挪一个位置,然后执行插入操作;
   d) 回收池已满,则将当前第 k 个以前的元素往前挪一个位置,然后执行插入操作;

Redis为何使用近似LRU算法

筛选规则,Redis 是随机抽取一批数据去按照淘汰策略排序,不再需要对所有数据排序;

性能问题,每次数据访问都可能涉及数据移位,性能会有少许损失;

内存问题,Redis 对内存的使用一向很 “抠门”,数据结构都很精简,尽量不使用复杂的数据结构管理数据;

策略配置,如果线上 Redis 实例动态修改淘汰策略会触发全部数据的结构性改变,这个 Redis 系统无法承受的。

致谢

部分内容援引地址:
英雄哪里出来 https://blog.csdn.net/WhereIsHeroFrom/article/details/86501571/


网站公告

今日签到

点亮在社区的每一天
去签到