Redis第五讲 Redis内存淘汰策略之LRU与LFU算法详细介绍

本文详细介绍了Redis中的LRU和LFU内存淘汰策略。LRU通过近似方法,利用24bit属性字段记录访问时间,通过随机采样和淘汰池提高效果。LFU则维护计数器并考虑访问频率变化,通过衰减机制减少不常用key的内存占用。Redis可通过配置调整这些算法的行为。

前面介绍了Redis的一些内存淘汰策略,一般比较常用的两种淘汰策略为LRU,LFU,而且他们的算法考察的也比较多。

LRU(最近最久未使用)

标准LRU算法是这样的:它把数据存放在链表中按照“最近访问”的顺序排列,当某个key被访问时就将此key移动到链表的头部,保证了最近访问过的元素在链表的头部或前面。当链表满了之后,就将"最近最久未使用"的,即链表尾部的元素删除,再将新的元素添加至链表头部。其中LinkedHashMapt就可以通过访问的顺序来实现LRU算法。

一般的LRU的做法如下:
1、为每个节点设置一个prev前继节点指针和next后继节点指针,将所有节点通过这两个指针连接成一个链表,链表有着head和tail节点。
2、用一个哈希表将key和对应的节点存放起来,用来快速的找到key对应的节点
3、当通过key访问对应的节点的时候,通过哈希表查询key得到对应的节点。然后通过节点的的prev和next指针将节点从链表中移除,并将节点放到链表的头部中。
4、当新加入key和节点的时候,如果对应的链表已经满了,就将尾部的节点去除,然后将新节点加入到链表的头部。

从上述流程可以看出,基于链表和哈希表的LRU需要设置prev指针、next指针、以及hash表中的key和value的指针,这是一个很大的内存开销。

因为标准LRU算法需要消耗大量的内存,所以Redis采用了一种近似LRU的做法 给每个key增加一个大小为24bit的属性字段,代表最后一次被访问的时间戳。然后随机采样出5个key,淘汰掉最旧的key,直到Redis占用内存小于maxmemory为止。其中随机采样的数量可以通过Redis配置文件中的 maxmemory_samples 属性来调整,默认是5,采样数量越大越接近于标准LRU算法,但也会带来性能的消耗。

这里为每一个key增加的24bit的属性字段来自于RedisObject,前面的redis数据结构里面也讲过redis中的每个数据的key和value都对应着一个redisObj对象。如果忘记了Redis的几种基本数据结构,可以参考我的这篇博客:Redis第一讲 Redis五种基本类型的底层数据结构

lru值还和内存回收有关系,如果redis打开了maxmemory选项,并且内存回收算法是volatile-lru或者allkeys-lru,当内存占用超过maxmemory指定的值得时候,redis会优先选择空转时间最长的对象进行释放。

redisObj中有一个属性 lru,lru是一个24位的数字,保存了一个时间戳,代表着一个对象最新被使用的时间。

redisServer中维护了一个lruclock属性来表示当前系统的时间戳,每隔100ms就会调用updateLRUClock()来更新server.lruclock的值。
当新建key或者访问key的时候,会将对应的redisObj.lru = server.lruclock。

用server.lruclock的值减去redisObj.lru的值就可以得到对应key的空闲时间了,但是当server.unixtime也就是当前系统时间大于REDIS_LRU_CLOCK_MAX的时候,会将server.lruclock从0开始计数,这就会出现redisObj.lru大于server.lruclock的情况,这种情况下如何算对应的空闲时间呢

//这里只是粗略的估计空闲时间,因为之前算server.lruclock()和redisObj.lru的时候,已将unixtime()/REDIS_LRU_CLOCK_RESOLUTION了,对应的精度已经损失了。
unsigned  long  long  estimateObjectIdleTime(robj *o) {
     unsigned  long  long  lruclock = LRU_CLOCK();
     //正常情况,server.lruclock()还未转完一圈
     if  (lruclock >= o->lru) {
         return  (lruclock - o->lru) * REDIS_LRU_CLOCK_RESOLUTION;
     }  else  { server.lruclock()转完一圈了,那么需要将其结果加上对应的REDIS_LRU_CLOCK_MAX的值
         return  (lruclock + (REDIS_LRU_CLOCK_MAX - o->lru)) *
                     REDIS_LRU_CLOCK_RESOLUTION;
     }
}

在Redis 3.0以后增加了LRU淘汰池,进一步提高了与标准LRU算法效果的相似度。淘汰池即维护的一个数组,数组大小等于抽样数量 maxmemory_samples,在每一次淘汰时,新随机抽取的key和淘汰池中的key进行合并,然后淘汰掉最旧的key,将剩余较旧的前面5个key放入淘汰池中待下一次循环使用。假如maxmemory_samples=5,随机抽取5个元素,淘汰池中还有5个元素,相当于变相的maxmemory_samples=10了,所以进一步提高了与LRU算法的相似度。

其中根据抽取key的数据来源不同,将lru分为了两类:
1、volatile-lru
从设置了过期时间的数据中随机抽取数据淘汰,也就是从redisDb.expires中抽取key。
2、allkeys-lru
从所有数据中随机抽取数据淘汰,也就是从redisDb.dict中抽取key。

JAVA实现LRU算法

public class LRUTest extends LinkedHashMap<String, String> {

    int capacity;

    public LRUTest(int capacity) {
        //按照顺序访问
        super(16, 0.75f, true);
        this.capacity = capacity;
    }

    /**
     * LinkedHashMap自带的判断是否删除最老的元素方法,默认返回false,即不删除老数据
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
        return size() > capacity;
    }

    public static void main(String[] args) {
        Map<String, String> linkedHashMap = new LRUTest(6);

        linkedHashMap.put("1", "1");
        linkedHashMap.put("2", "2");
        linkedHashMap.put("3", "3");
        linkedHashMap.put("4", "4");
        linkedHashMap.put("5", "5");
        linkedHashMap.put("6", "6");
        linkedHashMap.put("7", "7");
        linkedHashMap.put("8", "8");
        linkedHashMap.put("9", "9");

        System.out.println("size="+linkedHashMap.size());
        System.out.println(linkedHashMap.get("8"));

        linkedHashMap.forEach((k,v) ->{
            System.out.print(k + ":"+ v +"  ");
        });

        System.out.println();
        System.out.println("size="+linkedHashMap.size());
    }
}

LFU(最近最少使用)

在Redis LFU算法中,为每个key维护了一个计数器,每次key被访问的时候,计数器增大,计数器越大,则认为访问越频繁。但其实这样会有问题,

1、因为访问频率是动态变化的,前段时间频繁访问的key,之后也可能很少再访问(如微博热搜)。为了解决这个问题,Redis记录了每个key最后一次被访问的时间,随着时间的推移,如果某个key再没有被访问过,计数器的值也会逐渐降低。

2、新生key问题,对于新加入缓存的key,因为还没有被访问过,计数器的值如果为0,就算这个key是热点key,因为计数器值太小,也会被淘汰机制淘汰掉。为了解决这个问题,Redis会为新生key的计数器设置一个初始值。

上面说过在Redis LRU算法中,会给每个key维护一个大小为24bit的属性字段,代表最后一次被访问的时间戳。在LFU中也维护了这个24bit的字段,不过被分成了16 bits与8 bits两部分:
在这里插入图片描述

其中高16 bits用来记录计数器的上次缩减时间,时间戳,单位精确到分钟。低8 bits用来记录计数器的当前数值。

你可能会奇怪8位最多只能记录255,这不是在开玩笑吗?其实每次访问不会一定将counter++,而是通过一定的概率来判断是否将counter++。

//counter就是当前key对应的counter
uint8_t LFULogIncr(uint8_t counter) {
    if (counter == 255) return 255;
    //随机生成一个数字
    double r = (double)rand()/RAND_MAX;
    //将当前的counter减去一个固定值
    double baseval = counter - LFU_INIT_VAL;
    if (baseval < 0) baseval = 0;
    //server.lfu_log_factor是增长控制因子
    double p = 1.0/(baseval*server.lfu_log_factor+1);
    //如果随机生成的数小于p,就将counter++
    if (r < p) counter++;
    return counter;
}

在redis.conf配置文件中还有2个属性可以调整LFU算法的执行参数:lfu-log-factor、lfu-decay-time。
其中lfu-log-factor用来调整计数器counter的增长速度,lfu-log-factor越大,counter增长的越慢。
lfu-decay-time是一个以分钟为单位的数值,用来调整counter的缩减速度。

衰减机制

当一个key在前一段时间被频繁访问,但是之后慢慢用不到了,对应的counter应该变小,要不然key会一直保存在内存中,无法被移除,降低内存使用率。所以redis提供了一个衰减机制。
衰减机制 与 key的空闲时间有关, key空闲时间越长,对应的counter就减少的越多。

unsigned long LFUDecrAndReturn(robj *o) {
    //将lru左移8位,得到对应的时间戳
    unsigned long ldt = o->lru >> 8;
    //将lru & 0000 0000 0000 0000 1111 得到对应的counter
    unsigned long counter = o->lru & 255;
    //算出对应的空闲周期数。
    //server.lfu_decay_time是衰减因子,可以在配置文件中修改,默认是10,单位是分钟
    //LFUTimeElapsed(ldt)就是算出对应的空闲时间,然后将空闲时间除去衰减因子,得到衰减周期 num_periods
    unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
    //将counter减去对应的衰减周期
    if (num_periods)
        counter = (num_periods > counter) ? 0 : counter - num_periods;
    return counter;
}

从上述代码看出,如果一个Key在N分钟没有被访问,当衰减检查的时候,就会将其counter - N。

lfu工作过程

当访问对应的key的时候,会更新其redisObj中的lru中的时间戳和counter信息。
和lru一样,lfu也会维护一个Pool,只不过Pool中的排序依据是counter,如果counter一致,就按照时间戳排序。每次需要内存替换的时候,就随机抽取出若干个key,如果Pool不满,或者counter小于Pool中的最小值的话,就将key加入进去,然后选出Pool中counter值最小的那个Key,将其对应的数据从内存中移除。

volatile-lfu: 只从redisDb.expires中,也就是设置了过期时间的key中随机选取对应的key按照lfu规则移除
allkeys-lfu: 从redisDb.dict中,也就是全部key中随机选出对应的key按照lfu规则移除。

java实现的LFU算法:

public class LfuTest {
    /**
     * 记录一个是缓存的元素
     *
     * @param <K>
     * @param <V>
     */
    static class LFUNode<K, V> {
        private K key;
        private V value;
        private int count;

        public LFUNode(K key, V value) {
            this.key = key;
            this.value = value;
        }

        public LFUNode(K key, V value, int count) {
            this.key = key;
            this.value = value;
            this.count = 1;
        }

        public K getKey() {
            return key;
        }

        public V getValue() {
            return value;
        }

        public int getCount() {
            return count;
        }

        public void setCount(int count) {
            this.count = count;
        }
    }

    static class LFUCache<K, V> {
        //根据访问counter排序
        private TreeMap<Integer, List<LFUNode<K, V>>> countMap;
        // cache存储插入的node
        private HashMap<K, LFUNode<K, V>> cache;
        private int size;

        public LFUCache(int size) {
            if (size <= 0) {
                throw new IllegalArgumentException("Invalid size");
            }
            //排序
            countMap = new TreeMap<Integer, List<LFUNode<K, V>>>(new Comparator<Integer>() {
                public int compare(Integer o1, Integer o2) {
                    return o1 - o2;
                }
            });
            cache = new HashMap<K, LFUNode<K, V>>(size);
            this.size = size;
        }

        public void put(K key, V value) {
            if (cache.containsKey(key)) {
                //访问过
                LFUNode node = cache.get(key);
                int count = node.getCount();
                node.setCount(count + 1);
                rmCountNode(count, node);
                addCountNode(count + 1, node);
                return;
            }
            if (cache.size() == size) {
                Map.Entry<Integer, List<LFUNode<K, V>>> entry = countMap.firstEntry();
                LFUNode node = entry.getValue().get(0);
                rmCountNode(node.getCount(), node);
                cache.remove(node.getKey());
            }
            LFUNode node = new LFUNode(key, value);
            cache.put(key, node);
            addCountNode(node.getCount(), node);
        }

        public V get(K key) {
            if (!cache.containsKey(key)) {
                return null;
            }
            LFUNode<K, V> node = cache.get(key);
            rmCountNode(node.getCount(), node);
            addCountNode(node.getCount() + 1, node);
            node.setCount(node.getCount() + 1);
            return node.getValue();
        }

        public void rmCountNode(int count, LFUNode node) {
            List<LFUNode<K, V>> list = countMap.get(count);
            if (list.size() == 1) {
                countMap.remove(count);
            } else {
                list.remove(node);
            }
        }

        public void addCountNode(int count, LFUNode node) {
            List<LFUNode<K, V>> list = countMap.get(count);
            if (list == null) {
                list = new ArrayList<LFUNode<K, V>>();
                countMap.put(count, list);
            }
            list.add(node);
        }
    }

    public static void main(String[] args) {
        LFUCache<String, String> lfuCache = new LFUCache<>(5);
        lfuCache.put("1", "1");
        lfuCache.put("2", "2");
        lfuCache.put("3", "3");
        lfuCache.put("4", "4");
        lfuCache.put("5", "5");
        lfuCache.put("6", "6");
        System.out.println(lfuCache);
        lfuCache.put("2", "2");
        lfuCache.put("7", "7");
        lfuCache.put("8", "8");
        lfuCache.put("9", "9");
        System.out.println(lfuCache);

    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员路同学

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值