Redis的LRU算法实现源码解析(一)

本文涉及的产品
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
云解析 DNS,旗舰版 1个月
简介: Redis的LRU算法实现源码解析

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 函数的主要执行流程。


相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
26天前
|
存储 算法 Java
解析HashSet的工作原理,揭示Set如何利用哈希算法和equals()方法确保元素唯一性,并通过示例代码展示了其“无重复”特性的具体应用
在Java中,Set接口以其独特的“无重复”特性脱颖而出。本文通过解析HashSet的工作原理,揭示Set如何利用哈希算法和equals()方法确保元素唯一性,并通过示例代码展示了其“无重复”特性的具体应用。
41 3
|
28天前
|
搜索推荐 算法
插入排序算法的平均时间复杂度解析
【10月更文挑战第12天】 插入排序是一种简单直观的排序算法,通过不断将未排序元素插入到已排序部分的合适位置来完成排序。其平均时间复杂度为$O(n^2)$,适用于小规模或部分有序的数据。尽管效率不高,但在特定场景下仍具优势。
|
1天前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
9 2
|
1天前
|
存储 安全 Linux
Golang的GMP调度模型与源码解析
【11月更文挑战第11天】GMP 调度模型是 Go 语言运行时系统的核心部分,用于高效管理和调度大量协程(goroutine)。它通过少量的操作系统线程(M)和逻辑处理器(P)来调度大量的轻量级协程(G),从而实现高性能的并发处理。GMP 模型通过本地队列和全局队列来减少锁竞争,提高调度效率。在 Go 源码中,`runtime.h` 文件定义了关键数据结构,`schedule()` 和 `findrunnable()` 函数实现了核心调度逻辑。通过深入研究 GMP 模型,可以更好地理解 Go 语言的并发机制。
|
7天前
|
搜索推荐 算法 C语言
【排序算法】八大排序(上)(c语言实现)(附源码)
本文介绍了四种常见的排序算法:冒泡排序、选择排序、插入排序和希尔排序。通过具体的代码实现和测试数据,详细解释了每种算法的工作原理和性能特点。冒泡排序通过不断交换相邻元素来排序,选择排序通过选择最小元素进行交换,插入排序通过逐步插入元素到已排序部分,而希尔排序则是插入排序的改进版,通过预排序使数据更接近有序,从而提高效率。文章最后总结了这四种算法的空间和时间复杂度,以及它们的稳定性。
45 8
|
7天前
|
搜索推荐 算法 C语言
【排序算法】八大排序(下)(c语言实现)(附源码)
本文继续学习并实现了八大排序算法中的后四种:堆排序、快速排序、归并排序和计数排序。详细介绍了每种排序算法的原理、步骤和代码实现,并通过测试数据展示了它们的性能表现。堆排序利用堆的特性进行排序,快速排序通过递归和多种划分方法实现高效排序,归并排序通过分治法将问题分解后再合并,计数排序则通过统计每个元素的出现次数实现非比较排序。最后,文章还对比了这些排序算法在处理一百万个整形数据时的运行时间,帮助读者了解不同算法的优劣。
33 7
|
11天前
|
算法 Linux 定位技术
Linux内核中的进程调度算法解析####
【10月更文挑战第29天】 本文深入剖析了Linux操作系统的心脏——内核中至关重要的组成部分之一,即进程调度机制。不同于传统的摘要概述,我们将通过一段引人入胜的故事线来揭开进程调度算法的神秘面纱,展现其背后的精妙设计与复杂逻辑,让读者仿佛跟随一位虚拟的“进程侦探”,一步步探索Linux如何高效、公平地管理众多进程,确保系统资源的最优分配与利用。 ####
38 4
|
11天前
|
缓存 负载均衡 算法
Linux内核中的进程调度算法解析####
本文深入探讨了Linux操作系统核心组件之一——进程调度器,着重分析了其采用的CFS(完全公平调度器)算法。不同于传统摘要对研究背景、方法、结果和结论的概述,本文摘要将直接揭示CFS算法的核心优势及其在现代多核处理器环境下如何实现高效、公平的资源分配,同时简要提及该算法如何优化系统响应时间和吞吐量,为读者快速构建对Linux进程调度机制的认知框架。 ####
|
14天前
|
消息中间件 缓存 安全
Future与FutureTask源码解析,接口阻塞问题及解决方案
【11月更文挑战第5天】在Java开发中,多线程编程是提高系统并发性能和资源利用率的重要手段。然而,多线程编程也带来了诸如线程安全、死锁、接口阻塞等一系列复杂问题。本文将深度剖析多线程优化技巧、Future与FutureTask的源码、接口阻塞问题及解决方案,并通过具体业务场景和Java代码示例进行实战演示。
34 3
|
28天前
|
前端开发 算法 JavaScript
无界SaaS模式深度解析:算力算法、链接力、数据确权制度
私域电商的无界SaaS模式涉及后端开发、前端开发、数据库设计、API接口、区块链技术、支付和身份验证系统等多个技术领域。本文通过简化框架和示例代码,指导如何将核心功能转化为技术实现,涵盖用户管理、企业店铺管理、数据流量管理等关键环节。

推荐镜像

更多