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

简介: Redis的LRU算法实现源码解析

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

近似 LRU 算法的执行可以分成三大步骤,分别是

  • 判断当前内存使用情况
  • 更新待淘汰的候选键值对集合
  • 选择被淘汰的键值对并删除

下面我们就依次来看下。

判断当前内存使用情况
  • 首先,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 函数的基本执行逻辑代码放在这里,你可以看下。

/* Get the memory status from the point of view of the maxmemory directive:
 * if the memory used is under the maxmemory setting then C_OK is returned.
 * Otherwise, if we are over the memory limit, the function returns
 * C_ERR.
 *
 * The function may return additional info via reference, only if the
 * pointers to the respective arguments is not NULL. Certain fields are
 * populated only when C_ERR is returned:
 *
 *  'total'     total amount of bytes used.
 *              (Populated both for C_ERR and C_OK)
 *
 *  'logical'   the amount of memory used minus the slaves/AOF buffers.
 *              (Populated when C_ERR is returned)
 *
 *  'tofree'    the amount of memory that should be released
 *              in order to return back into the memory limits.
 *              (Populated when C_ERR is returned)
 *
 *  'level'     this usually ranges from 0 to 1, and reports the amount of
 *              memory currently used. May be > 1 if we are over the memory
 *              limit.
 *              (Populated both for C_ERR and C_OK)
 */
int getMaxmemoryState(size_t *total, size_t *logical, size_t *tofree, float *level) {
    size_t mem_reported, mem_used, mem_tofree;
    /* Check if we are over the memory usage limit. If we are not, no need
     * to subtract the slaves output buffers. We can just return ASAP. */
    // 计算已使用的内存量
    mem_reported = zmalloc_used_memory();
    if (total) *total = mem_reported;
    /* We may return ASAP if there is no need to compute the level. */
    int return_ok_asap = !server.maxmemory || mem_reported <= server.maxmemory;
    if (return_ok_asap && !level) return C_OK;
    /* Remove the size of slaves output buffers and AOF buffer from the
     * count of used memory. */
    // 将用于主从复制的复制缓冲区大小和AOF缓冲区大小从已使用内存量中扣除
    mem_used = mem_reported;
    size_t overhead = freeMemoryGetNotCountedMemory();
    mem_used = (mem_used > overhead) ? mem_used-overhead : 0;
    /* Compute the ratio of memory usage. */
    // 计算内存使用率。
    if (level) {
        if (!server.maxmemory) {
            *level = 0;
        } else {
            *level = (float)mem_used / (float)server.maxmemory;
        }
    }
    if (return_ok_asap) return C_OK;
    /* Check if we are still over the memory limit. */
    // 检查我们是否仍然超过内存限制。
    if (mem_used <= server.maxmemory) return C_OK;
    // 计算需要释放的内存量
    /* Compute how much memory we need to free. */
    mem_tofree = mem_used - server.maxmemory;
    if (logical) *logical = mem_used;
    if (tofree) *tofree = mem_tofree;
    return C_ERR;
}

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

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

struct evictionPoolEntry {
    // 待淘汰的键值对的空闲时间
    unsigned long long idle;    /* Object idle time (inverse frequency for LFU) */
    // 待淘汰的键值对的key
    sds key;                    /* Key name. */
    // 缓存的SDS对象
    sds cached;                 /* Cached SDS object for key name. */
    // 待淘汰键值对的key所在的数据库ID
    int dbid;                   /* Key DB number. */
};
static struct evictionPoolEntry *EvictionPoolLRU;

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

#define EVPOOL_SIZE 16
/* Create a new eviction pool. */
void evictionPoolAlloc(void) {
    struct evictionPoolEntry *ep;
    int j;
    ep = zmalloc(sizeof(*ep)*EVPOOL_SIZE);
    for (j = 0; j < EVPOOL_SIZE; j++) {
        ep[j].idle = 0;
        ep[j].key = NULL;
        ep[j].cached = sdsnewlen(NULL,EVPOOL_CACHED_SDS_SIZE);
        ep[j].dbid = 0;
    }
    EvictionPoolLRU = ep;
}

那么,freeMemoryIfNeeded 函数在淘汰数据的循环流程中,就会更新这个待淘汰的候选键值对集合,也就是 EvictionPoolLRU 数组。下面我就来给你具体介绍一下。

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

首先,freeMemoryIfNeeded 函数会调用 evictionPoolPopulate 函数(在 evict.c 文件中),而 evictionPoolPopulate 函数会先调用 dictGetSomeKeys 函数(在 dict.c 文件中),从待采样的哈希表中随机获取一定数量的 key。不过,这里还有两个地方你需要注意下。

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

以下代码是 freeMemoryIfNeeded 函数中对 evictionPoolPopulate 函数的调用过程,你可以看下。

/* We don't want to make local-db choices when expiring keys,
 * so to start populate the eviction pool sampling keys from
 * every DB. */
for (i = 0; i < server.dbnum; i++) {
    // 对Redis server上的每一个数据库都执行
    db = server.db+i;
    // 根据淘汰策略,决定使用全局哈希表还是设置了过期时间的key的哈希表
    dict = (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) ?
            db->dict : db->expires;
    // 将选择的哈希表dict传入evictionPoolPopulate函数,同时将全局哈希表也传给evictionPoolPopulate函数
    if ((keys = dictSize(dict)) != 0) {
        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,执行一个循环。

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。

这个过程的基本执行逻辑如下所示:

// 从数组最后一个key开始查找
/* Go backward from best to worst element to evict. */
for (k = EVPOOL_SIZE-1; k >= 0; k--) {
    // 当前key为空值,则查找下一个key
    if (pool[k].key == NULL) continue;
    bestdbid = pool[k].dbid;
    // 从全局哈希表或是expire哈希表中,获取当前key对应的键值对;并将当前key从EvictionPoolLRU数组删除
    if (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) {
        de = dictFind(server.db[pool[k].dbid].dict,
            pool[k].key);
    } else {
        de = dictFind(server.db[pool[k].dbid].expires,
            pool[k].key);
    }
    /* Remove the entry from the pool. */
    if (pool[k].key != pool[k].cached)
        sdsfree(pool[k].key);
    pool[k].key = NULL;
    pool[k].idle = 0;
    /* If the key exists, is our pick. Otherwise it is
     * a ghost and we need to try the next element. */
    // 如果当前key对应的键值对不为空,选择当前key为被淘汰的key
    if (de) {
        bestkey = dictGetKey(de);
        break;
    } else {
        //否则,继续查找下个key
        /* Ghost... Iterate again. */
    }
}

最后,一旦选到了被淘汰的 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 的过程,直到满足待释放空间的大小要求。

下图就展示了 freeMemoryIfNeeded 函数涉及的基本流程,你可以再来整体回顾下。

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

小结

今天这节课我给你介绍了 Redis 中,是如何实现 LRU 算法来进行缓存数据替换的。其中,我们根据 LRU 算法的基本原理,可以发现如果严格按照原理来实现 LRU 算法,那么开发的系统就需要用额外的内存空间来保存 LRU 链表,而且系统运行时也会受到 LRU 链表操作的开销影响。

而对于 Redis 来说,内存资源和性能都很重要,所以 Redis 实现了近似 LRU 算法。而为了实现近似 LRU 算法,Redis 首先是设置了全局 LRU 时钟,并在键值对创建时获取全局 LRU 时钟值作为访问时间戳,以及在每次访问时获取全局 LRU 时钟值,更新访问时间戳。

然后,当 Redis 每处理一个命令时,都会调用 freeMemoryIfNeeded 函数来判断是否需要释放内存。如果已使用内存超出了 maxmemory,那么,近似 LRU 算法就会随机选择一些键值对,组成待淘汰候选集合,并根据它们的访问时间戳,选出最旧的数据,将其淘汰。

Redis 计算实例内存时,不会把「主从复制」的缓冲区计算在内,也就是说不管一个实例后面挂了多少个从库,主库不会把主从复制所需的「缓冲区」内存,计算到实例内存中,即这部分内存增加,不会对数据淘汰产生影响。

每日一问:为什么键值对的 LRU 时钟值,不是直接通过调用 getLRUClock 函数来获取?

本质上是为了性能。Redis 这种对性能要求极高的数据库,在系统调用上的优化也做到了极致。

获取机器时钟本质上也是一个「系统调用」,对于 Redis 这种动不动每秒上万的 QPS,如果每次都触发一次系统调用,这么频繁的操作也是一笔不小的开销。

所以,Redis 用一个定时任务(serverCron 函数),以固定频率触发系统调用获取机器时钟,然后把机器时钟挂到 server 的全局变量下,这相当于维护了一个「本地缓存」,当需要获取时钟时,直接从全局变量获取即可,节省了大量的系统调用开销。

版本变动

6.2的版本中,增加了maxmemory-eviction-tenacity配置,目的是控制大量淘汰内存空间阻塞客户端的时间

6.0版本evict.c的结构

6.2版本evict.c的结构

6.2的版本中没有了freeMemoryIfNeeded 和 freeMemoryIfNeededAndSafe函数,而是被performEvictions替代,然后在执行内存释放期间,耗时统计,超过限制时间,新增时间事件,然后结束循环,流程继续往下执行。

/* After some time, exit the loop early - even if memory limit
 * hasn't been reached.  If we suddenly need to free a lot of
 * memory, don't want to spend too much time here.  */
// 如果执行释放空间超过限制时间,则添加一个时间事件,时间事件中继续释放内存
if (elapsedUs(evictionTimer) > eviction_time_limit_us) {
    // We still need to free memory - start eviction timer proc
    if (!isEvictionProcRunning) {
        isEvictionProcRunning = 1;
        // 新增时间事件
        aeCreateTimeEvent(server.el, 0,
                evictionTimeProc, NULL, NULL);
    }
    break;
}
// 如果一次时间事件没结束,则该时间事件不结束,等待一段时间,继续执行
// 如果返回值不是AE_NOMORE,则继续把当前事件间隔retval毫秒后继续执行
if (retval != AE_NOMORE) {
    te->when = now + retval * 1000;
} else {
    te->id = AE_DELETED_EVENT_ID;
}
相关文章
|
12月前
|
人工智能 算法 NoSQL
LRU算法的Java实现
LRU(Least Recently Used)算法用于淘汰最近最少使用的数据,常应用于内存管理策略中。在Redis中,通过`maxmemory-policy`配置实现不同淘汰策略,如`allkeys-lru`和`volatile-lru`等,采用采样方式近似LRU以优化性能。Java中可通过`LinkedHashMap`轻松实现LRUCache,利用其`accessOrder`特性和`removeEldestEntry`方法完成缓存淘汰逻辑,代码简洁高效。
556 0
|
10月前
|
算法 数据可视化 数据挖掘
基于EM期望最大化算法的GMM参数估计与三维数据分类系统python源码
本内容展示了基于EM算法的高斯混合模型(GMM)聚类实现,包含完整Python代码、运行效果图及理论解析。程序使用三维数据进行演示,涵盖误差计算、模型参数更新、结果可视化等关键步骤,并附有详细注释与操作视频,适合学习EM算法与GMM模型的原理及应用。
|
11月前
|
缓存 人工智能 算法
lru算法设计与实现
本文详细介绍了LRU(Least Recently Used,最近最少使用)缓存淘汰策略的原理与实现。LRU的核心思想是:越近被访问的数据,未来被再次访问的可能性越大。文章通过Java语言实现了一个支持O(1)时间复杂度操作的LRU缓存
449 0
|
监控 算法 安全
基于 C# 的内网行为管理软件入侵检测算法解析
当下数字化办公环境中,内网行为管理软件已成为企业维护网络安全、提高办公效率的关键工具。它宛如一位恪尽职守的网络守护者,持续监控内网中的各类活动,以确保数据安全及网络稳定。在其诸多功能实现的背后,先进的数据结构与算法发挥着至关重要的作用。本文将深入探究一种应用于内网行为管理软件的 C# 算法 —— 基于二叉搜索树的入侵检测算法,并借助具体代码例程予以解析。
214 4
|
JavaScript 算法 前端开发
JS数组操作方法全景图,全网最全构建完整知识网络!js数组操作方法全集(实现筛选转换、随机排序洗牌算法、复杂数据处理统计等情景详解,附大量源码和易错点解析)
这些方法提供了对数组的全面操作,包括搜索、遍历、转换和聚合等。通过分为原地操作方法、非原地操作方法和其他方法便于您理解和记忆,并熟悉他们各自的使用方法与使用范围。详细的案例与进阶使用,方便您理解数组操作的底层原理。链式调用的几个案例,让您玩转数组操作。 只有锻炼思维才能可持续地解决问题,只有思维才是真正值得学习和分享的核心要素。如果这篇博客能给您带来一点帮助,麻烦您点个赞支持一下,还可以收藏起来以备不时之需,有疑问和错误欢迎在评论区指出~
|
存储 监控 算法
关于员工上网监控系统中 PHP 关联数组算法的学术解析
在当代企业管理中,员工上网监控系统是维护信息安全和提升工作效率的关键工具。PHP 中的关联数组凭借其灵活的键值对存储方式,在记录员工网络活动、管理访问规则及分析上网行为等方面发挥重要作用。通过关联数组,系统能高效记录每位员工的上网历史,设定网站访问权限,并统计不同类型的网站访问频率,帮助企业洞察员工上网模式,发现潜在问题并采取相应管理措施,从而保障信息安全和提高工作效率。
232 7
|
监控 算法 安全
内网桌面监控软件深度解析:基于 Python 实现的 K-Means 算法研究
内网桌面监控软件通过实时监测员工操作,保障企业信息安全并提升效率。本文深入探讨K-Means聚类算法在该软件中的应用,解析其原理与实现。K-Means通过迭代更新簇中心,将数据划分为K个簇类,适用于行为分析、异常检测、资源优化及安全威胁识别等场景。文中提供了Python代码示例,展示如何实现K-Means算法,并模拟内网监控数据进行聚类分析。
379 10
|
存储 算法 安全
控制局域网上网软件之 Python 字典树算法解析
控制局域网上网软件在现代网络管理中至关重要,用于控制设备的上网行为和访问权限。本文聚焦于字典树(Trie Tree)算法的应用,详细阐述其原理、优势及实现。通过字典树,软件能高效进行关键词匹配和过滤,提升系统性能。文中还提供了Python代码示例,展示了字典树在网址过滤和关键词屏蔽中的具体应用,为局域网的安全和管理提供有力支持。
294 17
|
机器学习/深度学习 自然语言处理 算法
生成式 AI 大语言模型(LLMs)核心算法及源码解析:预训练篇
生成式 AI 大语言模型(LLMs)核心算法及源码解析:预训练篇
3888 1

推荐镜像

更多
  • DNS