Redis源码解析-LRU

总结

  1. 实现一个严格的 LRU 算法,需要额外的内存构建 LRU 链表,同时维护链表也存在性能开销,Redis 对于内存资源和性能要求极高,所以没有采用严格 LRU 算法,而是采用「近似」LRU 算法实现数据淘汰策略
  2. 触发数据淘汰的时机,是每次处理「请求」时判断的。也就是说,执行一个命令之前,首先要判断实例内存是否达到 maxmemory,是的话则先执行数据淘汰,再执行具体的命令
  3. 淘汰数据时,会「持续」判断 Redis 内存是否下降到了 maxmemory 以下,不满足的话会继续淘汰数据,直到内存下降到 maxmemory 之下才会停止
  4. 可见,如果发生大量淘汰的情况,那么处理客户端请求就会发生「延迟」,影响性能
  5. Redis 计算实例内存时,不会把「主从复制」的缓冲区计算在内,也就是说不管一个实例后面挂了多少个从库,主库不会把主从复制所需的「缓冲区」内存,计算到实例内存中,即这部分内存增加,不会对数据淘汰产生影响
  6. 但如果 Redis 内存已达到 maxmemory,要谨慎执行 MONITOR 命令,因为 Redis Server 会向执行 MONITOR 的 client 缓冲区填充数据,这会导致缓冲区内存增长,进而引发数据淘汰
  7. 键值对的 LRU 时钟值,不是直接通过调用 getLRUClock 函数来获取,本质上是为了「性能」。 Redis 这种对性能要求极高的数据库,在系统调用上的优化也做到了极致。 获取机器时钟本质上也是一个「系统调用」,对于 Redis 这种动不动每秒上万的 QPS,如果每次都触发一次系统调用,这么频繁的操作也是一笔不小的开销。 所以,Redis 用一个定时任务(serverCron 函数),以固定频率触发系统调用获取机器时钟,然后把机器时钟挂到 server 的全局变量下,这相当于维护了一个「本地缓存」,当需要获取时钟时,直接从全局变量获取即可,节省了大量的系统调用开销。

LRU 算法的基本原理

LRU 算法是指最近最少使用(Least Recently Used,LRU)

从基本原理上来说,LRU 算法会使用一个链表来维护缓存中每一个数据的访问情况,并根据数据的实时访问,调整数据在链表中的位置,然后通过数据在链表中的位置,来表示数据是最近刚访问的,还是已经有一段时间没有访问了。

LRU 算法会把链表的头部和尾部分别设置为 MRU 端和 LRU 端。其中,MRU 是 Most Recently Used 的缩写,MRU 端表示这里的数据是刚被访问的。而 LRU 端则表示,这里的数据是最近最少访问的数据。

LRU 算法的执行,可以分成三种情况来掌握。

  • 情况一:当有新数据插入时,LRU 算法会把该数据插入到链表头部,同时把原来链表头部的数据及其之后的数据,都向尾部移动一位。

  • 情况二:当有数据刚被访问了一次之后,LRU 算法就会把该数据从它在链表中的当前位置,移动到链表头部。同时,把从链表头部到它当前位置的其他数据,都向尾部移动一位。

  • 情况三:当链表长度无法再容纳更多数据时,若再有新数据插入,LRU 算法就会去除链表尾部的数据,这也相当于将数据从缓存中淘汰掉。

如果要严格按照 LRU 算法的基本原理来实现的话,需要在代码中实现如下内容:

  • 要为 Redis 使用最大内存时,可容纳的所有数据维护一个链表;
  • 每当有新数据插入或是现有数据被再次访问时,需要执行多次链表操作。

而假设 Redis 保存的数据比较多的话,那么,这两部分的代码实现,就既需要额外的内存空间来保存链表,还会在访问数据的过程中,让 Redis 受到数据移动和链表操作的开销影响,从而就会降低 Redis 访问性能。

Redis 中近似 LRU 算法的实现

Redis 配置文件 redis.conf 中的两个配置参数有关:

  • maxmemory,该配置项设定了 Redis server 可以使用的最大内存容量,一旦 server 使用的实际内存量超出该阈值时,server 就会根据
  • maxmemory-policy 配置项定义的策略,执行内存淘汰操作;maxmemory-policy,该配置项设定了 Redis server 的内存淘汰策略,主要包括近似 LRU 算法、LFU 算法、按 TTL 值淘汰和随机淘汰等几种算法。

Redis中两种lru策略:allkeys-lru 和 volatile-lru 都会使用近似 LRU 算法来淘汰数据,它们的区别在于:

采用 allkeys-lru 策略淘汰数据时,它是在所有的键值对中筛选将被淘汰的数据;

采用 volatile-lru 策略淘汰数据时,它是在设置了过期时间的键值对中筛选将被淘汰的数据。

Redis 对近似 LRU 算法的实现

  • 全局 LRU 时钟值的计算:这部分包括,Redis 源码为了实现近似 LRU 算法的效果,是如何计算全局 LRU 时钟值的,以用来判断数据访问的时效性;
  • 键值对 LRU 时钟值的初始化与更新:这部分包括,Redis 源码在哪些函数中对每个键值对对应的 LRU 时钟值,进行初始化与更新;
  • 近似 LRU 算法的实际执行:这部分包括,Redis 源码具体如何执行近似 LRU 算法,也就是何时触发数据淘汰,以及实际淘汰的机制是怎么实现的。

全局 LRU 时钟值的计算

Redis 设计了 LRU 时钟来记录数据每次访问的时间戳。Redis 在源码中对于每个键值对中的值,会使用一个 redisObject 结构体来保存指向值的指针。其中除了记录值的指针以外,它其实还会使用 24 bits 来保存 LRU 时钟信息,对应的是 lru 成员变量。每个键值对都会把它最近一次被访问的时间戳,记录在 lru 变量当中。

redisOjbect 结构体的定义是在server.h

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS;  //记录LRU信息,宏定义LRU_BITS是24 bits
    int refcount;
    void *ptr;
} robj;

Redis server 使用了一个实例级别的全局 LRU 时钟,每个键值对的 LRU 时钟值会根据全局 LRU 时钟进行设置。

这个全局 LRU 时钟保存在了 Redis 全局变量 server 的成员变量 lruclock 中。当 Redis server 启动后,调用 initServerConfig 函数初始化各项参数时,就会对这个全局 LRU 时钟 lruclock 进行设置。具体来说,initServerConfig 函数是调用 getLRUClock 函数,来设置 lruclock 的值

void initServerConfig(void) {
...
unsigned int lruclock = getLRUClock(); //调用getLRUClock函数计算全局LRU时钟值
atomicSet(server.lruclock,lruclock);//设置lruclock为刚计算的LRU时钟值
...
}

全局 LRU 时钟值是通过 getLRUClock 函数计算得到的。

getLRUClock 函数是在evict.c文件中实现的,它会调用 mstime 函数(在server.c文件中)获得以毫秒为单位计算的 UNIX 时间戳,然后将这个 UNIX 时间戳除以宏定义 LRU_CLOCK_RESOLUTION。宏定义 LRU_CLOCK_RESOLUTION 是在 server.h 文件中定义的,它表示的是以毫秒为单位的 LRU 时钟精度,也就是以毫秒为单位来表示的 LRU 时钟最小单位。

因为 LRU_CLOCK_RESOLUTION 的默认值是 1000,所以,LRU 时钟精度就是 1000 毫秒,也就是 1 秒。如果一个数据前后两次访问的时间间隔小于 1 秒,那么这两次访问的时间戳就是一样的。因为 LRU 时钟的精度就是 1 秒,它无法区分间隔小于 1 秒的不同时间戳。

getLRUClock 函数执行逻辑:

首先,getLRUClock 函数将获得的 UNIX 时间戳,除以 LRU_CLOCK_RESOLUTION 后,就得到了以 LRU 时钟精度来计算的 UNIX 时间戳,也就是当前的 LRU 时钟值。紧接着,getLRUClock 函数会把 LRU 时钟值和宏定义 LRU_CLOCK_MAX 做与运算,其中宏定义 LRU_CLOCK_MAX 表示的是 LRU 时钟能表示的最大值。

所以,在默认情况下,全局 LRU 时钟值是以 1 秒为精度来计算的 UNIX 时间戳,并且它是在 initServerConfig 函数中进行了初始化。

#define LRU_BITS 24  
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1)  //LRU时钟的最大值
#define LRU_CLOCK_RESOLUTION 1000 //以毫秒为单位的LRU时钟精度

unsigned int getLRUClock(void) {
    return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX;
}

在 Redis server 的运行过程中,全局 LRU 时钟值的更新和 Redis server 在事件驱动框架中,定期运行的时间事件所对应的 serverCron 函数有关。

serverCron 函数作为时间事件的回调函数,本身会按照一定的频率周期性执行,其频率值是由 Redis 配置文件 redis.conf 中的 hz 配置项决定的。hz 配置项的默认值是 10,这表示 serverCron 函数会每 100 毫秒(1 秒 /10 = 100 毫秒)运行一次。在 serverCron 函数中,全局 LRU 时钟值就会按照这个函数执行频率,定期调用 getLRUClock 函数进行更新。这样每个键值对就可以从全局 LRU 时钟获取最新的访问时间戳了。

int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
...
unsigned long lruclock = getLRUClock(); //默认情况下,每100毫秒调用getLRUClock函数更新一次全局LRU时钟值
atomicSet(server.lruclock,lruclock); //设置lruclock变量
...
}

键值对 LRU 时钟值的初始化与更新

对于一个键值对来说,它的 LRU 时钟值最初是在这个键值对被创建的时候,进行初始化设置的,这个初始化操作是在 createObject 函数中调用的。createObject 函数实现在object.c文件当中,当 Redis 要创建一个键值对时,就会调用这个函数。

而 createObject 函数除了会给 redisObject 结构体分配内存空间之外,它还会根据maxmemory_policy 配置项的值,来初始化设置 redisObject 结构体中的 lru 变量。

如果 maxmemory_policy 配置为使用 LFU 策略,那么 lru 变量值会被初始化设置为 LFU 算法的计算值。
如果 maxmemory_policy 配置项没有使用 LFU 策略,那么,createObject 函数就会调用 LRU_CLOCK 函数来设置 lru 变量的值,也就是键值对对应的 LRU 时钟值。

LRU_CLOCK 函数是在 evict.c 文件中实现的,它的作用就是返回当前的全局 LRU 时钟值。因为一个键值对一旦被创建,也就相当于有了一次访问,所以它对应的 LRU 时钟值就表示了它的访问时间戳。

robj *createObject(int type, void *ptr) {
    robj *o = zmalloc(sizeof(*o));
    ...
    //如果缓存替换策略是LFU,那么将lru变量设置为LFU的计数值
    if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
        o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
    } else {
        o->lru = LRU_CLOCK();   //否则,调用LRU_CLOCK函数获取LRU时钟值
    }
    return o;
}

只要一个键值对被访问了,它的 LRU 时钟值就会被更新。而当一个键值对被访问时,访问操作最终都会调用 lookupKey 函数

lookupKey 函数是在db.c文件中实现的,它会从全局哈希表中查找要访问的键值对。如果该键值对存在,那么 lookupKey 函数就会根据 maxmemory_policy 的配置值,来更新键值对的 LRU 时钟值,也就是它的访问时间戳。

当 maxmemory_policy 没有配置为 LFU 策略时,lookupKey 函数就会调用 LRU_CLOCK 函数,来获取当前的全局 LRU 时钟值,并将其赋值给键值对的 redisObject 结构体中的 lru 变量

robj *lookupKey(redisDb *db, robj *key, int flags) {
    dictEntry *de = dictFind(db->dict,key->ptr); //查找键值对
    if (de) {
        robj *val = dictGetVal(de); 获取键值对对应的redisObject结构体
        ...
        if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
                updateLFU(val);  //如果使用了LFU策略,更新LFU计数值
        } else {
                val->lru = LRU_CLOCK();  //否则,调用LRU_CLOCK函数获取全局LRU时钟值
        }
       ...
}}

每个键值对一旦被访问,就能获得最新的访问时间戳了。

近似 LRU 算法的实际执行

实现近似 LRU 算法的目的:为了减少内存资源和操作时间上的开销。

何时触发算法执行

近似 LRU 算法的主要逻辑是在 freeMemoryIfNeeded 函数中实现的,而这个函数本身是在 evict.c 文件中实现。

freeMemoryIfNeeded 函数是被 freeMemoryIfNeededAndSafe 函数(在 evict.c 文件中)调用,而 freeMemoryIfNeededAndSafe 函数又是被 processCommand 函数( Redis 处理每个命令时都会被调用的)所调用的。三者的调用关系如下:

processCommand 函数在执行的时候,实际上会根据两个条件来判断是否调用 freeMemoryIfNeededAndSafe 函数。

  • 条件一:设置了 maxmemory 配置项为非 0 值。
  • 条件二:Lua 脚本没有在超时运行。

如果这两个条件成立,那么 processCommand 函数就会调用 freeMemoryIfNeededAndSafe 函数,如下所示:

...
if (server.maxmemory && !server.lua_timedout) {
        int out_of_memory = freeMemoryIfNeededAndSafe() == C_ERR;
...

然后,freeMemoryIfNeededAndSafe 函数还会再次根据两个条件,来判断是否调用 freeMemoryIfNeeded 函数。

条件一:Lua 脚本在超时运行。

条件二:Redis server 正在加载数据。

只有在这两个条件都不成立的情况下,freeMemoryIfNeeded 函数才会被调用。

int freeMemoryIfNeededAndSafe(void) {
    if (server.lua_timedout || server.loading) return C_OK;
    return freeMemoryIfNeeded();
}

一旦 freeMemoryIfNeeded 函数被调用了,并且 maxmemory-policy 被设置为了 allkeys-lru 或 volatile-lru,那么近似 LRU 算法就开始被触发执行了

近似 LRU 算法具体如何执行?

近似 LRU 算法并没有使用耗时耗空间的链表,而是使用了固定大小的待淘汰数据集合,每次随机选择一些 key 加入待淘汰数据集合中。最后,再按照待淘汰集合中 key 的空闲时间长度,删除空闲时间最长的 key。这样一来,Redis 就近似实现了 LRU 算法的效果了。

freeMemoryIfNeeded 函数涉及的基本流程:

首先,freeMemoryIfNeeded 函数会调用 getMaxmemoryState 函数,评估当前的内存使用情况。getMaxmemoryState 函数是在 evict.c 文件中实现的,它会判断当前 Redis server 使用的内存容量是否超过了 maxmemory 配置的值。

如果当前内存使用量没有超过 maxmemory,那么,getMaxmemoryState 函数会返回 C_OK,紧接着,freeMemoryIfNeeded 函数也会直接返回了。

int freeMemoryIfNeeded(void) {
...
if (getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) == C_OK)
        return C_OK;
...
}

getMaxmemoryState 函数在评估当前内存使用情况的时候,如果发现已用内存超出了 maxmemory,它就会计算需要释放的内存量。这个释放的内存大小等于已使用的内存量减去 maxmemory。不过,已使用的内存量并不包括用于主从复制的复制缓冲区大小,这是 getMaxmemoryState 函数,通过调用 freeMemoryGetNotCountedMemory 函数来计算的。

getMaxmemoryState 函数的基本执行逻辑代码:

int getMaxmemoryState(size_t *total, size_t *logical, size_t *tofree, float *level) {
...
mem_reported = zmalloc_used_memory(); //计算已使用的内存量
...
//将用于主从复制的复制缓冲区大小从已使用内存量中扣除
mem_used = mem_reported;
size_t overhead = freeMemoryGetNotCountedMemory();
mem_used = (mem_used > overhead) ? mem_used-overhead : 0;
...
//计算需要释放的内存量
mem_tofree = mem_used - server.maxmemory;
...
}

如果当前 server 使用的内存量,的确已经超出 maxmemory 的上限了,那么 freeMemoryIfNeeded 函数就会执行一个 while 循环,来淘汰数据释放内存。

为了淘汰数据,Redis 定义了一个数组 EvictionPoolLRU,用来保存待淘汰的候选键值对。这个数组的元素类型是 evictionPoolEntry 结构体,该结构体保存了待淘汰键值对的空闲时间 idle、对应的 key 等信息。以下代码展示了 EvictionPoolLRU 数组和 evictionPoolEntry 结构体,它们都是在 evict.c 文件中定义的。

static struct evictionPoolEntry *EvictionPoolLRU;

struct evictionPoolEntry {
    unsigned long long idle;    //待淘汰的键值对的空闲时间
    sds key;                    //待淘汰的键值对的key
    sds cached;                 //缓存的SDS对象
    int dbid;                   //待淘汰键值对的key所在的数据库ID
};

Redis server 在执行 initSever 函数进行初始化时,会调用 evictionPoolAlloc 函数(在 evict.c 文件中)为 EvictionPoolLRU 数组分配内存空间,该数组的大小由宏定义 EVPOOL_SIZE(在 evict.c 文件中)决定,默认是 16 个元素,也就是可以保存 16 个待淘汰的候选键值对。

那么,freeMemoryIfNeeded 函数在淘汰数据的循环流程中,就会更新这个待淘汰的候选键值对集合,也就是 EvictionPoolLRU 数组。

  • 更新待淘汰的候选键值对集合

    首先,freeMemoryIfNeeded 函数会调用 evictionPoolPopulate 函数(在 evict.c 文件中),而 evictionPoolPopulate 函数会先调用 dictGetSomeKeys 函数(在 dict.c 文件中),从待采样的哈希表中随机获取一定数量的 key。两点需要注意:

    第一点,dictGetSomeKeys 函数采样的哈希表,是由 maxmemory_policy 配置项来决定的。如果 maxmemory_policy 配置的是 allkeys_lru,那么待采样哈希表就是 Redis server 的全局哈希表,也就是在所有键值对中进行采样;否则,待采样哈希表就是保存着设置了过期时间的 key 的哈希表。

    freeMemoryIfNeeded 函数中对 evictionPoolPopulate 函数的调用过程

    for (i = 0; i < server.dbnum; i++) {
           db = server.db+i;   //对Redis server上的每一个数据库都执行
           //根据淘汰策略,决定使用全局哈希表还是设置了过期时间的key的哈希表
           dict = (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) ? db->dict : db->expires;
           if ((keys = dictSize(dict)) != 0) {
               //将选择的哈希表dict传入evictionPoolPopulate函数,同时将全局哈希表也传给evictionPoolPopulate函数
               evictionPoolPopulate(i, dict, db->dict, pool);
               total_keys += keys;
            }
     }
    

    第二点,dictGetSomeKeys 函数采样的 key 的数量,是由 redis.conf 中的配置项 maxmemory-samples 决定的,该配置项的默认值是 5。下面代码就展示了 evictionPoolPopulate 函数对 dictGetSomeKeys 函数的调用:

    void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) {
        ...
        dictEntry *samples[server.maxmemory_samples];  //采样后的集合,大小为maxmemory_samples
        //将待采样的哈希表sampledict、采样后的集合samples、以及采样数量maxmemory_samples,作为参数传给dictGetSomeKeys
        count = dictGetSomeKeys(sampledict,samples,server.maxmemory_samples);
        ...
    }
    

    如此一来,dictGetSomeKeys 函数就能返回采样的键值对集合了。然后,evictionPoolPopulate 函数会根据实际采样到的键值对数量 count,执行一个循环。在这个循环流程中,evictionPoolPopulate 函数会调用 estimateObjectIdleTime 函数,来计算在采样集合中的每一个键值对的空闲时间,如下所示:

    for (j = 0; j < count; j++) {
    ...
    if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
                idle = estimateObjectIdleTime(o);
    }
    ...
    

    紧接着,evictionPoolPopulate 函数会遍历待淘汰的候选键值对集合,也就是 EvictionPoolLRU 数组。在遍历过程中,它会尝试把采样的每一个键值对插入 EvictionPoolLRU 数组,这主要取决于以下两个条件之一:

    ​ 一是,它能在数组中找到一个尚未插入键值对的空位;

    ​ 二是,它能在数组中找到一个空闲时间小于采样键值对空闲时间的键值对。

    这两个条件有一个成立的话,evictionPoolPopulate 函数就可以把采样键值对插入 EvictionPoolLRU 数组。等所有采样键值对都处理完后,evictionPoolPopulate 函数就完成对待淘汰候选键值对集合的更新了。接下来,freeMemoryIfNeeded 函数,就可以开始选择最终被淘汰的键值对了。

  • 选择被淘汰的键值对并删除

    因为 evictionPoolPopulate 函数已经更新了 EvictionPoolLRU 数组,而且这个数组里面的 key,是按照空闲时间从小到大排好序了。所以,freeMemoryIfNeeded 函数会遍历一次 EvictionPoolLRU 数组,从数组的最后一个 key 开始选择,如果选到的 key 不是空值,那么就把它作为最终淘汰的 key。

    for (k = EVPOOL_SIZE-1; k >= 0; k--) { //从数组最后一个key开始查找
       if (pool[k].key == NULL) continue; //当前key为空值,则查找下一个key
       
       ... //从全局哈希表或是expire哈希表中,获取当前key对应的键值对;并将当前key从EvictionPoolLRU数组删除
    
        //如果当前key对应的键值对不为空,选择当前key为被淘汰的key
        if (de) {
          bestkey = dictGetKey(de);
          break;
         } else {} //否则,继续查找下个key
    }
    

    最后,一旦选到了被淘汰的 key,freeMemoryIfNeeded 函数就会根据 Redis server 的惰性删除配置,来执行同步删除或异步删除,如下所示:

    if (bestkey) {
                db = server.db+bestdbid;
                robj *keyobj = createStringObject(bestkey,sdslen(bestkey));        //将删除key的信息传递给从库和AOF文件
                propagateExpire(db,keyobj,server.lazyfree_lazy_eviction);
                //如果配置了惰性删除,则进行异步删除
                if (server.lazyfree_lazy_eviction)
                    dbAsyncDelete(db,keyobj);
                else  //否则进行同步删除
                    dbSyncDelete(db,keyobj);
    }
    

    到这里,freeMemoryIfNeeded 函数就淘汰了一个 key。而如果此时,释放的内存空间还不够,也就是说没有达到前面介绍的待释放空间,那么 freeMemoryIfNeeded 函数还会重复执行前面所说的更新待淘汰候选键值对集合、选择最终淘汰 key 的过程,直到满足待释放空间的大小要求。

posted @ 2022-05-22 21:28  请务必优秀  阅读(1239)  评论(0编辑  收藏  举报