Redis的LRU算法实现源码解析
LRU 算法的基本原理
首先,我们需要理解 LRU 算法的基本原理。LRU 算法就是指最近最少使用(Least Recently Used,LRU)算法,这是一个经典的缓存算法。
从基本原理上来说,LRU 算法会使用一个链表来维护缓存中每一个数据的访问情况,并根据数据的实时访问,调整数据在链表中的位置,然后通过数据在链表中的位置,来表示数据是最近刚访问的,还是已经有一段时间没有访问了。
而具体来说,LRU 算法会把链表的头部和尾部分别设置为 MRU 端和 LRU 端。其中,MRU 是 Most Recently Used 的缩写,MRU 端表示这里的数据是刚被访问的。而 LRU 端则表示,这里的数据是最近最少访问的数据。
LRU 算法的执行,可以分成三种情况来掌握。
- 情况一:当有新数据插入时,LRU 算法会把该数据插入到链表头部,同时把原来链表头部的数据及其之后的数据,都向尾部移动一位。
- 情况二:当有数据刚被访问了一次之后,LRU 算法就会把该数据从它在链表中的当前位置,移动到链表头部。同时,把从链表头部到它当前位置的其他数据,都向尾部移动一位。
- 情况三:当链表长度无法再容纳更多数据时,若再有新数据插入,LRU 算法就会去除链表尾部的数据,这也相当于将数据从缓存中淘汰掉。
下图就展示了 LRU 算法执行过程的第二种情况,你可以看下。其中,链表长度为 5,从链表头部到尾部保存的数据分别是 5,33,9,10,8。假设数据 9 被访问了一次,那么 9 就会被移动到链表头部,同时,数据 5 和 33 都要向链表尾部移动一位。
所以你其实可以发现,如果要严格按照 LRU 算法的基本原理来实现的话,你需要在代码中实现如下内容:
- 要为 Redis 使用最大内存时,可容纳的所有数据维护一个链表;
- 每当有新数据插入或是现有数据被再次访问时,需要执行多次链表操作。
而假设 Redis 保存的数据比较多的话,那么,这两部分的代码实现,就既需要额外的内存空间来保存链表,还会在访问数据的过程中,让 Redis 受到数据移动和链表操作的开销影响,从而就会降低 Redis 访问性能。
所以说,无论是为了节省宝贵的内存空间,还是为了保持 Redis 高性能,Redis 源码并没有严格按照 LRU 算法基本原理来实现它,而是提供了一个近似 LRU 算法的实现。
而假设 Redis 保存的数据比较多的话,那么,这两部分的代码实现,就既需要额外的内存空间来保存链表,还会在访问数据的过程中,让 Redis 受到数据移动和链表操作的开销影响,从而就会降低 Redis 访问性能。
所以说,无论是为了节省宝贵的内存空间,还是为了保持 Redis 高性能,Redis 源码并没有严格按照 LRU 算法基本原理来实现它,而是提供了一个近似 LRU 算法的实现。
那么接下来,我们就来了解下这种近似 LRU 算法究竟是如何实现的。
Redis 中近似 LRU 算法的实现
不过,在了解 Redis 对近似 LRU 算法的实现之前,我们需要先来看下,Redis 的内存淘汰机制是如何启用近似 LRU 算法的,这可以帮助我们了解和近似 LRU 算法相关的配置项。
实际上,这和 Redis 配置文件 redis.conf 中的两个配置参数有关:
- maxmemory,该配置项设定了 Redis server 可以使用的最大内存容量,一旦 server 使用的实际内存量超出该阈值时,server 就会根据 maxmemory-policy 配置项定义的策略,执行内存淘汰操作;
- maxmemory-policy,该配置项设定了 Redis server 的内存淘汰策略,主要包括近似 LRU 算法、LFU 算法、按 TTL 值淘汰和随机淘汰等几种算法。
所以,一旦我们设定了 maxmemory 选项,并且将 maxmemory-policy 配置为 allkeys-lru 或是 volatile-lru 时,近似 LRU 算法就被启用了。这里,你需要注意的是,allkeys-lru 和 volatile-lru 都会使用近似 LRU 算法来淘汰数据,它们的区别在于:
- 采用 allkeys-lru 策略淘汰数据时,它是在所有的键值对中筛选将被淘汰的数据;
- 采用 volatile-lru 策略淘汰数据时,它是在设置了过期时间的键值对中筛选将被淘汰的数据。
好,了解了如何启用近似 LRU 算法后,我们就来具体学习下 Redis 是如何实现近似 LRU 算法的。这里,为了便于你理解,我把 Redis 对近似 LRU 算法的实现分成了三个部分。
- 全局 LRU 时钟值的计算:这部分包括,Redis 源码为了实现近似 LRU 算法的效果,是如何计算全局 LRU 时钟值的,以用来判断数据访问的时效性;
- 键值对 LRU 时钟值的初始化与更新:这部分包括,Redis 源码在哪些函数中对每个键值对对应的 LRU 时钟值,进行初始化与更新;
- 近似 LRU 算法的实际执行:这部分包括,Redis 源码具体如何执行近似 LRU 算法,也就是何时触发数据淘汰,以及实际淘汰的机制是怎么实现的。
那么下面,我们就先来看下全局 LRU 时钟值的计算。
全局 LRU 时钟值的计算
虽然 Redis 使用了近似 LRU 算法,但是,这个算法仍然需要区分不同数据的访问时效性,也就是说,Redis 需要知道数据的最近一次访问时间。因此,Redis 就设计了 LRU 时钟来记录数据每次访问的时间戳。
我们在前面中已经了解到,Redis 在源码中对于每个键值对中的值,会使用一个 redisObject 结构体来保存指向值的指针。那么,redisObject 结构体除了记录值的指针以外,它其实还会使用 24 bits 来保存 LRU 时钟信息,对应的是 lru 成员变量。所以这样一来,每个键值对都会把它最近一次被访问的时间戳,记录在 lru 变量当中。
redisOjbect 结构体的定义是在server.h中,其中就包含了 lru 成员变量的定义,你可以看下。
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;
那么,每个键值对的 LRU 时钟值具体是如何计算的呢?其实,Redis server 使用了一个实例级别的全局 LRU 时钟,每个键值对的 LRU 时钟值会根据全局 LRU 时钟进行设置。
这个全局 LRU 时钟保存在了 Redis 全局变量 server 的成员变量 lruclock 中。当 Redis server 启动后,调用 initServerConfig 函数初始化各项参数时,就会对这个全局 LRU 时钟 lruclock 进行设置。具体来说,initServerConfig 函数是调用 getLRUClock 函数,来设置 lruclock 的值,如下所示:
// 调用getLRUClock函数计算全局LRU时钟值 unsigned int lruclock = getLRUClock(); //设置lruclock为刚计算的LRU时钟值 atomicSet(server.lruclock,lruclock);
所以,全局 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 秒的不同时间戳。
好了,了解了宏定义 LRU_CLOCK_RESOLUTION 的含义之后,我们再来看下 getLRUClock 函数中的计算。
- 首先,getLRUClock 函数将获得的 UNIX 时间戳,除以 LRU_CLOCK_RESOLUTION 后,就得到了以 LRU 时钟精度来计算的 UNIX 时间戳,也就是当前的 LRU 时钟值。
- 紧接着,getLRUClock 函数会把 LRU 时钟值和宏定义 LRU_CLOCK_MAX 做与运算,其中宏定义 LRU_CLOCK_MAX 表示的是 LRU 时钟能表示的最大值。
evict.c文件中查看
/* Return the LRU clock, based on the clock resolution. This is a time * in a reduced-bits format that can be used to set and check the * object->lru field of redisObject structures. */ unsigned int getLRUClock(void) { return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX; }
server.h文件中查看
#define LRU_BITS 24 #define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) /* Max value of obj->lru */ #define LRU_CLOCK_RESOLUTION 1000 /* LRU clock resolution in ms */
所以现在,你就知道了在默认情况下,全局 LRU 时钟值是以 1 秒为精度来计算的 UNIX 时间戳,并且它是在 initServerConfig 函数中进行了初始化。那么接下来,你可能还会困惑的问题是:在 Redis server 的运行过程中,全局 LRU 时钟值是如何更新的呢?
这就和 Redis server 在事件驱动框架中,定期运行的时间事件所对应的 serverCron 函数有关了。
serverCron 函数作为时间事件的回调函数,本身会按照一定的频率周期性执行,其频率值是由 Redis 配置文件 redis.conf 中的 hz 配置项决定的。hz 配置项的默认值是 10,这表示 serverCron 函数会每 100 毫秒(1 秒 /10 = 100 毫秒)运行一次。
这样,在 serverCron 函数中,全局 LRU 时钟值就会按照这个函数的执行频率,定期调用 getLRUClock 函数进行更新,如下所示:
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) { ... unsigned int lruclock = getLRUClock(); //默认情况下,每100毫秒调用getLRUClock函数更新一次全局LRU时钟值 atomicSet(server.lruclock,lruclock); //设置lruclock变量 ... }
所以这样一来,每个键值对就可以从全局 LRU 时钟获取最新的访问时间戳了。
好,那么接下来,我们就来了解下,对于每个键值对来说,它对应的 redisObject 结构体中的 lru 变量,是在哪些函数中进行初始化和更新的。
键值对 LRU 时钟值的初始化与更新
首先,对于一个键值对来说,它的 LRU 时钟值最初是在这个键值对被创建的时候,进行初始化设置的,这个初始化操作是在 createObject 函数中调用的。createObject 函数实现在object.c文件当中,当 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; /* Set the LRU to the current lruclock (minutes resolution), or * alternatively the LFU counter. */ if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) { o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL; } else { o->lru = LRU_CLOCK(); } return o; }
而 createObject 函数除了会给 redisObject 结构体分配内存空间之外,它还会根据我刚才提到的 maxmemory_policy 配置项的值,来初始化设置 redisObject 结构体中的 lru 变量。
具体来说,就是如果 maxmemory_policy 配置为使用 LFU 策略,那么 lru 变量值会被初始化设置为 LFU 算法的计算值(关于 LFU 算法的代码实现,我会在下篇文章给你介绍)。而如果 maxmemory_policy 配置项没有使用 LFU 策略,那么,createObject 函数就会调用 LRU_CLOCK 函数来设置 lru 变量的值,也就是键值对对应的 LRU 时钟值。
LRU_CLOCK 函数是在 evict.c 文件中实现的,它的作用就是返回当前的全局 LRU 时钟值。因为一个键值对一旦被创建,也就相当于有了一次访问,所以它对应的 LRU 时钟值就表示了它的访问时间戳。
/* This function is used to obtain the current LRU clock. * If the current resolution is lower than the frequency we refresh the * LRU clock (as it should be in production servers) we return the * precomputed value, otherwise we need to resort to a system call. */ unsigned int LRU_CLOCK(void) { unsigned int lruclock; if (1000/server.hz <= LRU_CLOCK_RESOLUTION) { atomicGet(server.lruclock,lruclock); } else { lruclock = getLRUClock(); } return lruclock; }
那么到这里,又出现了一个新的问题:一个键值对的 LRU 时钟值又是在什么时候被再次更新的呢?
其实,只要一个键值对被访问了,它的 LRU 时钟值就会被更新。而当一个键值对被访问时,访问操作最终都会调用 lookupKey 函数。
lookupKey 函数是在db.c文件中实现的,它会从全局哈希表中查找要访问的键值对。如果该键值对存在,那么 lookupKey 函数就会根据 maxmemory_policy 的配置值,来更新键值对的 LRU 时钟值,也就是它的访问时间戳。
而当 maxmemory_policy 没有配置为 LFU 策略时,lookupKey 函数就会调用 LRU_CLOCK 函数,来获取当前的全局 LRU 时钟值,并将其赋值给键值对的 redisObject 结构体中的 lru 变量,如下所示:
db.c文件中查看
/* Low level key lookup API, not actually called directly from commands * implementations that should instead rely on lookupKeyRead(), * lookupKeyWrite() and lookupKeyReadWithFlags(). */ robj *lookupKey(redisDb *db, robj *key, int flags) { dictEntry *de = dictFind(db->dict,key->ptr); if (de) { // 获取键值对对应的redisObject结构体 robj *val = dictGetVal(de); /* Update the access time for the ageing algorithm. * Don't do it if we have a saving child, as this will trigger * a copy on write madness. */ if (!hasActiveChildProcess() && !(flags & LOOKUP_NOTOUCH)){ if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) { // 如果使用了LFU策略,更新LFU计数值 updateLFU(val); } else { // 否则,调用LRU_CLOCK函数获取全局LRU时钟值 val->lru = LRU_CLOCK(); } } return val; } else { return NULL; } }
这样一来,每个键值对一旦被访问,就能获得最新的访问时间戳了。不过现在,你可能要问了:这些访问时间戳最终是如何被用于近似 LRU 算法,来进行数据淘汰的呢?接下来,我们就来学习下近似 LRU 算法的实际执行过程。
近似 LRU 算法的实际执行
现在我们已经知道,Redis 之所以实现近似 LRU 算法的目的,是为了减少内存资源和操作时间上的开销。那么在这里,我们其实可以从两个方面来了解近似 LRU 算法的执行过程,分别是:
- 何时触发算法执行?
- 算法具体如何执行?
何时触发算法执行?
首先,近似 LRU 算法的主要逻辑是在 freeMemoryIfNeeded 函数中实现的,而这个函数本身是在 evict.c 文件中实现。
freeMemoryIfNeeded 函数是被 freeMemoryIfNeededAndSafe 函数(在 evict.c 文件中)调用,而 freeMemoryIfNeededAndSafe 函数又是被 processCommand 函数所调用的。你可以参考下面的图,展示了这三者的调用关系。
所以,我们看到 processCommand 函数,就应该知道这个函数是 Redis 处理每个命令时都会被调用的。
那么,processCommand 函数在执行的时候,实际上会根据两个条件来判断是否调用 freeMemoryIfNeededAndSafe 函数。
- 条件一:设置了 maxmemory 配置项为非 0 值。
- 条件二:Lua 脚本没有在超时运行。
如果这两个条件成立,那么 processCommand 函数就会调用 freeMemoryIfNeededAndSafe 函数,如下所示:
... if (server.maxmemory && !server.lua_timedout) { int out_of_memory = freeMemoryIfNeededAndSafe() == C_ERR; ...
也就是说,只有在这两个条件都不成立的情况下,freeMemoryIfNeeded 函数才会被调用。下面的代码展示了 freeMemoryIfNeededAndSafe 函数的执行逻辑,你可以看下。
- 条件一:Lua 脚本在超时运行。
- 条件二:Redis server 正在加载数据。
也就是说,只有在这两个条件都不成立的情况下,freeMemoryIfNeeded 函数才会被调用。下面的代码展示了 freeMemoryIfNeededAndSafe 函数的执行逻辑,你可以看下。
int freeMemoryIfNeededAndSafe(void) { if (server.lua_timedout || server.loading) return C_OK; return freeMemoryIfNeeded(); }
这样,一旦 freeMemoryIfNeeded 函数被调用了,并且 maxmemory-policy 被设置为了 allkeys-lru 或 volatile-lru,那么近似 LRU 算法就开始被触发执行了。接下来,我们就来看下近似 LRU 算法具体是如何执行的,也就是来了解 freeMemoryIfNeeded 函数的主要执行流程。