Redis底层详解(八) LRU 算法

一、LRU 算法概述

         1、LRU 概述

         LRU 是 Least Recently Used 的缩写,即最近最少使用,是内存管理的一种页面置换算法。算法的核心是:如果一个数据在最近一段时间内没有被访问到,那么它在将来被访问的可能性也很小。换言之,当内存达到极限时,应该把内存中最久没有被访问的数据淘汰掉。
         那么,如何表示这个最久呢?Redis 在实现上引入了一个 LRU 时钟来代替 unix 时间戳,每个对象的每次被访问都会记录下当前服务器的 LRU 时钟,然后用服务器的 LRU 时钟减去对象本身的时钟,得到的就是这个对象没有被访问的时间间隔(也称空闲时间),空闲时间最大的就是需要淘汰的对象。

         2、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 时钟

         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 定时器 
        Redis 中有一个全局的定时器函数 serverCron,用于刷新服务器的 LRU 时钟,函数大致实现如下:

int serverCron(...) {
    ...
    server.lruclock = getLRUClock();
    ...
}

        其中,server.lruclock 代表服务器的 LRU 时钟,这个时钟的刷新频率由 server.hz 决定,即每秒钟会调用 server.hz (默认值为 10)次 serverCron 函数。那么,服务器每 1 / server.hz 秒就会调用一次定时器函数 serverCron。

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

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

        正如上文所提到的,1 / server.hz 代表了 serverCron 这个定时器函数两次调用之间的最小时间间隔(以秒为单位),那么 1000 / server.hz 就是以毫秒为单位了。如果这个最小时间间隔小于等于 LRU 时钟的精度,那么不需要重新计算 LRU时钟,直接用服务器 LRU时钟做近似值即可,因为时间间隔越小,server.lruclock 刷新的越频繁;相反,当时间间隔很大的时候,server.lruclock 的刷新可能不及时,所以需要用 getLRUClock 重新计算准确的 LRU 时钟。
        如图所示,以  server.hz = 10 为例,sc 代表每次 serverCron 调用的时间结点,两次调用间隔 100ms,每次调用就会利用 getLRUClock 函数计算一次 LRU 时钟。由于 LRU时钟的最小刻度为 1000ms,所以图中 LRU_x 和 LRU_y 之间是没有其它刻度的,那么所有落在 LRU_x 和 LRU_y 之间计算出来的 LRU时钟 的值都为 LRU_x,于是为了避免重复计算,减少调用系统函数 gettimeofday 的时间,可以用最近一次计算得到的 LRU 时钟作为近似值,即 server.lruclock。

        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;
}

         这里调用 LRU_CLOCK() 对 Redis 的对象成员 lru 进行 LRU 时钟的设置,其中 robj 是 redisObject 的别名,见上文的定义。
        b) lookupKey 不会直接被 redis 命令调用,往往是通过lookupKeyRead()、lookupKeyWrite() 、lookupKeyReadWithFlags() 间接调用的,这个函数的作用是通过传入的 key 查找对应的 redis 对象,并且会在条件满足时设置上 LRU 时钟。为了便于阐述,这里简化了代码,源码实现在 db.c 中:

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 内存回收 

       1、内存回收策略
       当内存达到极限,就要开始利用回收策略对内存进行回收释放。回收的配置在 redis.conf 中填写,如下:

maxmemory 1073741824
maxmemory-policy noeviction
maxmemory-samples 5

        这三个配置项决定了 Redis 内存回收时的机制,maxmemory 指定了内存使用的极限,以字节为单位。当内存达到极限时,他会尝试去删除一些键值。删除的策略由 maxmemory-policy 配置来指定。如果根据指定的策略无法删除键或者策略本身就是 'noeviction',那么,Redis 会根据命令的类型做出不同的回应:会给需要更多内存的命令返回一个错误,例如 SET、LPUSH 等等;而像 GET 这样的只读命令则可以继续正常运行。
        maxmemory :当你的 Redis 是主 Redis 时 (Redis 采用主从模式时),需要预留一部分系统内存给同步队列缓存。当然,如果设置的删除策略 'noeviction',则不需要考虑这个问题。
        maxmemory-policy : 当内存达到 maxmemory 时,采用的回收策略,总共有如下六种:
             a) volatile-lru : 针对设置了过期时间的键采用 LRU 算法进行回收;
             b) allkeys-lru : 对所有键采用 LRU 算法进行回收;

             c) volatile-random : 针对设置了过期时间的键采用随机回收;
             d) allkeys-random : 对所有键随机回收;
             e) volatile-ttl: 过期时间最近 (TTL 最小) 的键进行回收;
             f) noeviction :不进行任何回收,对写操作返回错误;

#define MAXMEMORY_VOLATILE_LRU 0
#define MAXMEMORY_VOLATILE_TTL 1
#define MAXMEMORY_VOLATILE_RANDOM 2
#define MAXMEMORY_ALLKEYS_LRU 3
#define MAXMEMORY_ALLKEYS_RANDOM 4
#define MAXMEMORY_NO_EVICTION 5

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

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

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;
    }
}

       由于时钟是循环的,所以需要考虑服务器当前时钟和对象本身时钟的相对大小,从而计算出对象的空闲时间。然后通过对这个空闲时间的排序,就能筛选出空闲时间最长的进行回收了。

       3、LRU 回收流程
       Redis 的数据库是一个巨大的字典,最上层是由键值对组成的。当内存使用超过最大使用数时,就需要采用回收策略进行内存回收。如果回收策略采用 LRU,那么就会在这个大字典里面随机采样,挑选出空闲时间最大的键进行删除。而回收池会存在于整个服务器的生命周期中,所以它是一个全局变量。
       1) 这个删除操作发生在每一次处理客户端命令时。当 server.maxmemory 的值非 0,则检测是否有需要回收的内存。如果有则执行 2) ;
       2) 随机从大字典中取出 server.maxmemory_samples 个键(实际取到的数量取决于大字典原本的大小),然后用一个长度为 16 (由宏 MAXMEMORY_EVICTION_POOL_SIZE 指定) 的 evictionPool (回收池)对这几个键进行筛选,筛选出 idletime (空闲时间)最长的键,并且按照 idletime 从小到大的顺序排列在 evictionPool  中;
       3) 从 evictionPool 池中取出 idletime 最大且在字典中存在的键作为 bestkey 执行删除,并且从 evictionPool 池中移除;

       以上 evictionPool 扮演的是大顶堆的角色,并且在 Redis 服务器启动后一直存在。最后,看下 LRU 回收算法的实际执行流程:

#define MAXMEMORY_EVICTION_POOL_SIZE 16
struct evictionPoolEntry {                                                /* a */
    unsigned long long idle;
    sds key;
};
int processCommand(client *c) {
    ...
    if (server.maxmemory) freeMemoryIfNeeded();                           /* b */
    ...
}
int freeMemoryIfNeeded(void) {
    ...
    if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_LRU ||
        server.maxmemory_policy == MAXMEMORY_VOLATILE_LRU) {
        struct evictionPoolEntry *pool = db->eviction_pool;              /* c */
        while(bestkey == NULL) {
            evictionPoolPopulate(dict, db->dict, db->eviction_pool);     /* d */
            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);                            /* e */
                    break;
                } else {
                    continue;
                }
            }
        }
    }
    ...
}

       a) evictionPoolEntry 是回收池中的元素结构体,由一个空闲时间 idle 和 键名key 组成;
       b) freeMemoryIfNeeded(...) 接口用于收集 evictionPool 元素并且找出空闲时间最大的键并进行释放;
       c) eviction_pool 是数据库对象 db 的成员,代表回收池,是 a) 中提到的 evictionPoolEntry 类型的数组,数组长度由宏  MAXMEMORY_EVICTION_POOL_SIZE 指定,默认值为 16;
       d) evictionPoolPopulate(...) 接口用于随机采样数据库中的键,并且逐一和回收池中的键的空闲时间进行比较,筛选出空闲时间最大的键留在回收池中,这个接口的实现下文会具体讲述;
       e) 找出空闲时间最大且存在的键,等待执行删除操作;

       4、回收池更新  
       evictionPoolPopulate 的实现在 server.c, 主要是利用采样出来的键对回收池进行更新筛选,源码如下:

#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 个以前的元素往前挪一个位置,然后执行插入操作;
       下图中的四个子图分别代表上文提到的四种情况,其中红色箭头代表 k 的位置,红色方块代表插入的元素:

四、参考资料

https://www.cnblogs.com/nazhizq/p/8494651.html

https://blog.csdn.net/zdy0_2004/article/details/44685615

https://blog.csdn.net/qq_29347295/article/details/79060604

猜你喜欢

转载自blog.csdn.net/WhereIsHeroFrom/article/details/86501571