Redis LRU缓存淘汰算法

前言

内存不是无限大的,当我们向 Redis 写入的数据量超过了最大内存限制,Redis 就会启用缓存淘汰策略。

首先,我们可以配置 Redis 实例最大的内存限制:

maxmemory 100MB

然后,再配置缓存淘汰策略:

maxmemory-policy volatile-lru

Redis LRU 淘汰策略有两种:

  • allkeys-lru:针对所有的 Key 执行 LRU 淘汰算法
  • volatile-lru:仅针对设置了过期时间的 Key 执行 LRU 淘汰算法

配置缓存淘汰策略是有必要的,在使用缓存数据库时,我们要重点关注的一个指标就是缓存命中率,如果数据只会被访问一次,那它的缓存是没有意义的。所以我们要尽可能的提高缓存数据库的性价比,也就是提高缓存命中率。

提高缓存命中率最直接的方式,就是尽可能的保证缓存里的总是热数据,冷数据随着时间推移慢慢淘汰掉。所以我们需要一个缓存淘汰算法,那就是 LRU。

LRU

LRU 是 Least Recently Used 缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰,也常被用在缓存数据库。

LRU 最简单的实现方式就是把元素按照访问时间串联成一条链表,链头放的是最近一次访问的元素,链尾放的是最久没被访问的元素,链表中的元素只要被访问就把它移动到链头位置。然后再限制链表的长度,当有新元素被访问时,就把它加入到链头,然后把链尾的元素移除掉即可。

Redis LRU实现

如果 Redis 按照严格的 LRU 算法来实现缓存淘汰,那它要针对所有的 Key 维护一条 LRU 链表,这会带来两个问题:

  • LRU 链表本身会占用大量内存空间
  • 维护 LRU 链表需要额外的开销

本来内存就不够用,现在还要维护一条 LRU 链表;高访问量下,LRU 链表节点势必会频繁移动,开销不容忽视,这会影响到 Redis 的吞吐量。

所以,Redis 并没有实现严格的 LRU 算法,而是提供了一种近似 LRU 算法实现。

Redis 是键值对数据库,为了更好的管理键值对,它会为每个键值对创建一个 RedisObject 对象,其中有 24 Bit 用来存储 LRU 时间戳。

c 复制代码
typedef struct redisObject {
    unsigned type:4; // 对外的对象类型 例如:string list hash
    unsigned encoding:4; // 对象的编码类型 例如:ziplist skiplist intset
    unsigned lru:LRU_BITS; // 对象最近一次访问的时间戳 用于LRU缓存淘汰
    int refcount; // 对象引用次数
    void *ptr; // 指向底层数据结构的指针
} robj;

RedisObject 在创建时就会写入 LRU 时间戳(除了 LRU,Redis 还支持 LFU):

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;
    if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
        // LFU淘汰策略
        o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
    } else {
        // 写入LRU时间戳
        o->lru = LRU_CLOCK();
    }
    return o;
}

LRU 时间戳是通过 LRU 时钟获取的,方法是getLRUClock() 。Redis 会以 100ms/次 的频率记录 Unix 时间戳,然后除以 LRU 时钟单位 1000,即以秒为单位,保留低 24 位时间戳。

c 复制代码
unsigned int getLRUClock(void) {
    return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX;
}

为什么不直接获取系统时间,而是定时任务按照一定的频率记录时间?

这是因为获取系统时间要发起一次系统调用,有一定的开销。而读取 LRU 时间戳又是非常频繁的操作,出于对性能的考虑,Redis 选择周期性的读取一次系统时间。

除了创建对象时记录 LRU 时间戳,后续每次访问也会更新时间戳,访问 Key 最终会调用lookupKey() 。Redis 考虑的非常周到,如果有活跃的子进程,就会跳过更新时间戳,因为会导致 Linux 发生大量的写时复制。

c 复制代码
robj *lookupKey(redisDb *db, robj *key, int flags) {
    dictEntry *de = dictFind(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);
        // 如果有子进程则跳过更新,会导致大量的写时复制
        if (!hasActiveChildProcess() && !(flags & LOOKUP_NOTOUCH)){
            // 更新 LRU/LFU 时间戳
            if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
                updateLFU(val);
            } else {
                val->lru = LRU_CLOCK();
            }
        }
        return val;
    } else {
        return NULL;
    }
}

至此,我们知道 Redis 会在 RedisObject 里用 24 Bit 空间来记录键值对访问的 LRU 时间戳。我们再重新审视一下这个 LRU 时间戳,它只有 24 Bit,就算以秒为单位,也最多能表示 16777215 秒,约 194 天。一旦超过这个范围,就会发生数据溢出,此时就会出现:一个对象明明已经闲置 194 天了,但是 Redis 认为它刚刚才被访问过。这种情况下,LRU 算法就会出现问题,好在发生的概率不高。

有了 LRU 时间戳,接下来就是查找闲置时间最久的 Key 然后删除了。

因为没有维护 LRU 链表,如果 Redis 真的要严格按照闲置时间来淘汰缓存,就需要遍历整个库,这个开销太大了。Redis 的做法是,随机采样一批 Key,然后把闲置时间最久的 Key 删除掉。采样数越大,淘汰的结果就越准确,但是开销也会越大。默认的采样数是 5,可配置:

maxmemory-samples 5

缓存淘汰的方法是performEvictions() ,它会在processCommand() 里被触发,即 Redis 执行命令都会触发。Redis 首先判断当前是否可以安全淘汰?即 Server 没有在加载数据,lua脚本也没超时:

c 复制代码
if (!isSafeToPerformEvictions()) return EVICT_OK;

接着会评估内存使用情况,同时计算需要释放的内存大小mem_tofree ,只有当使用内存超出限制时才会继续往下走:

c 复制代码
if (getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) == C_OK)
    return EVICT_OK;

如果内存确实超出了限制,再判断配置的缓存淘汰策略,如果是不淘汰,则停止,否则继续往下走。

c 复制代码
if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION)
    return EVICT_FAIL;

接着,Redis 会计算一下缓存淘汰执行的一个超时时间,避免要释放的内存比较大时,这个操作阻塞太久。如果超出了时间限制,Redis 会提交一个异步事件来继续执行,因为内存总是要释放的。

c 复制代码
unsigned long eviction_time_limit_us = evictionTimeLimitUs();

接着 Redis 会在 while 循环内不断的淘汰 Key,直到满足释放的内存大小。因为 Redis 是随机采样的,所以需要有一个容器来承接采样到的一批 Key,为此 Redis 设计了一个结构体:

c 复制代码
struct evictionPoolEntry {
    unsigned long long idle; // 闲置时间
    sds key; // 键名
    sds cached; // 缓存的键名
    int dbid; // 所属库
};

Redis 默认会创建长度 16 的 evictionPoolEntry 数组来承接被淘汰的候选键值对:

c 复制代码
static struct evictionPoolEntry *EvictionPoolLRU;

接下来就是填充 EvictionPoolLRU,Redis 会遍历数据库,然后对哈希表随机采样,填充 EvictionPoolLRU。针对不同的淘汰策略,采样的哈希表也不一样。如果是针对所有 Key,采样的是db->dict 全局哈希表;如果针对设置了过期时间的 Key,采样的就是db->expires 设置了过期时间的 Key 的哈希表。被填充的 EvictionPoolLRU 最终会按照 Key 的闲置时间升序排列。

c 复制代码
for (i = 0; i < server.dbnum; i++) {
    db = server.db+i;
    /**
     * 根据淘汰策略 访问不同的哈希表
     * db->dict: 全局哈希表 所有Key
     * db->expires: 设置了过期时间的Key的哈希表
     */
    dict = (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) ?
        db->dict : db->expires;
    if ((keys = dictSize(dict)) != 0) {
        // 对哈希表随机采样,填充EvictionPoolLRU数组,按照空闲时间升序
        evictionPoolPopulate(i, dict, db->dict, pool);
        total_keys += keys;
    }
}

采样完成后,Redis 会倒序遍历 EvictionPoolLRU,优先淘汰闲置时间最久的 Key,最终会选择一个淘汰的 Key 并赋值给bestkey ,然后把候选键值对从 EvictionPoolLRU 移除。

c 复制代码
for (k = EVPOOL_SIZE-1; k >= 0; k--) {
    if (pool[k].key == NULL) continue;
    bestdbid = pool[k].dbid;
    // 查找键值对 对应的哈希表节点
    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);
    }
    // 从池子里移除
    if (pool[k].key != pool[k].cached)
        sdsfree(pool[k].key);
    pool[k].key = NULL;
    pool[k].idle = 0;
    if (de) {
        // 最终选择淘汰的Key
        bestkey = dictGetKey(de);
        break;
    } else {
        /* Ghost... Iterate again. */
    }
}

最后就是删除被选中的bestkey ,释放内存,Redis 支持配置是否要惰性删除。

lazyfree-lazy-eviction no

然后根据配置来选择是同步删除,还是异步删除。对于 BigKey 同于删除会有阻塞风险。

c 复制代码
if (server.lazyfree_lazy_eviction)
    dbAsyncDelete(db,keyobj);
else
    dbSyncDelete(db,keyobj);

最后,每淘汰 16 个 Key,Redis 就会检查一下执行是否超时。一旦超出时间限制,为了不阻塞主线程,Redis 会中断执行,然后提交一个异步事件继续处理。

c 复制代码
if (keys_freed % 16 == 0) {
    if (elapsedUs(evictionTimer) > eviction_time_limit_us) {
        if (!isEvictionProcRunning) {
            isEvictionProcRunning = 1;
            aeCreateTimeEvent(server.el, 0,
                            evictionTimeProc, NULL, NULL);
        }
        break;
    }
}

尾巴

出于对内存占用和性能的考虑,Redis 没有实现严格的 LRU 缓存淘汰算法,而是提供了一种近似实现。Redis 会在 RedisObject 用 24 Bit 记录 LRU 时间戳,当内存使用量超出限制时,Redis 将执行缓存淘汰。根据配置的淘汰策略,在全局哈希表或设置了过期时间的 Key 的哈希表里随机采样一批键值对,然后根据空闲时间排序,最后把空闲时间最久的 Key 给淘汰掉。

相关推荐
我是哈哈hh1 小时前
专题十_穷举vs暴搜vs深搜vs回溯vs剪枝_二叉树的深度优先搜索_算法专题详细总结
服务器·数据结构·c++·算法·机器学习·深度优先·剪枝
Tisfy1 小时前
LeetCode 2187.完成旅途的最少时间:二分查找
算法·leetcode·二分查找·题解·二分
Mephisto.java2 小时前
【力扣 | SQL题 | 每日四题】力扣2082, 2084, 2072, 2112, 180
sql·算法·leetcode
robin_suli2 小时前
滑动窗口->dd爱框框
算法
丶Darling.2 小时前
LeetCode Hot100 | Day1 | 二叉树:二叉树的直径
数据结构·c++·学习·算法·leetcode·二叉树
labuladuo5202 小时前
Codeforces Round 977 (Div. 2) C2 Adjust The Presentation (Hard Version)(思维,set)
数据结构·c++·算法
jiyisuifeng19912 小时前
代码随想录训练营第54天|单调栈+双指针
数据结构·算法
꧁༺❀氯ྀൢ躅ྀൢ❀༻꧂2 小时前
实验4 循环结构
c语言·算法·基础题
新晓·故知3 小时前
<基于递归实现线索二叉树的构造及遍历算法探讨>
数据结构·经验分享·笔记·算法·链表
总裁余(余登武)3 小时前
算法竞赛(Python)-万变中的不变“随机算法”
开发语言·python·算法