• Redis LRU缓存淘汰算法


    前言

    内存不是无限大的,当我们向 Redis 写入的数据量超过了最大内存限制,Redis 就会启用缓存淘汰策略。
    首先,我们可以配置 Redis 实例最大的内存限制:

    maxmemory 100MB
    
    • 1

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

    maxmemory-policy volatile-lru
    
    • 1

    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 时间戳。

    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;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

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

    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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

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

    unsigned int getLRUClock(void) {
        return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX;
    }
    
    • 1
    • 2
    • 3

    为什么不直接获取系统时间,而是定时任务按照一定的频率记录时间?
    这是因为获取系统时间要发起一次系统调用,有一定的开销。而读取 LRU 时间戳又是非常频繁的操作,出于对性能的考虑,Redis 选择周期性的读取一次系统时间。
    除了创建对象时记录 LRU 时间戳,后续每次访问也会更新时间戳,访问 Key 最终会调用lookupKey() 。Redis 考虑的非常周到,如果有活跃的子进程,就会跳过更新时间戳,因为会导致 Linux 发生大量的写时复制。

    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;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

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

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

    maxmemory-samples 5
    
    • 1

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

    if (!isSafeToPerformEvictions()) return EVICT_OK;
    
    • 1

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

    if (getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) == C_OK)
        return EVICT_OK;
    
    • 1
    • 2

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

    if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION)
        return EVICT_FAIL;
    
    • 1
    • 2

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

    unsigned long eviction_time_limit_us = evictionTimeLimitUs();
    
    • 1

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

    struct evictionPoolEntry {
        unsigned long long idle; // 闲置时间
        sds key; // 键名
        sds cached; // 缓存的键名
        int dbid; // 所属库
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

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

    static struct evictionPoolEntry *EvictionPoolLRU;
    
    • 1

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

    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;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

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

    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. */
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

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

    lazyfree-lazy-eviction no
    
    • 1

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

    if (server.lazyfree_lazy_eviction)
        dbAsyncDelete(db,keyobj);
    else
        dbSyncDelete(db,keyobj);
    
    • 1
    • 2
    • 3
    • 4

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

    if (keys_freed % 16 == 0) {
        if (elapsedUs(evictionTimer) > eviction_time_limit_us) {
            if (!isEvictionProcRunning) {
                isEvictionProcRunning = 1;
                aeCreateTimeEvent(server.el, 0,
                                evictionTimeProc, NULL, NULL);
            }
            break;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    尾巴

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

  • 相关阅读:
    [附源码]计算机毕业设计springboot青栞系统
    Groovy(第五节) Groovy 之集合
    UNet - 预测数据predict(多个图像的分割)
    redis未授权访问漏洞的利用
    物联网开发笔记(18)- 使用Micropython开发ESP32开发板之点亮LED和操作PWM呼吸灯
    某某桥的检测和加固设计
    基于PyTorch的生成对抗网络实战(7)——利用Pytorch搭建SGAN(Semi-Supervised GAN)生成手写数字并分类
    10月10日星期二今日早报简报微语报早读
    [运维|中间件] Apache APISIX使用笔记
    苹果macOS系统M1、M2芯片关闭sip的方法
  • 原文地址:https://blog.csdn.net/qq_32099833/article/details/133889335