vlambda博客
学习文章列表

Redis系列1—内存管理、缓存淘汰策略

    Redis是基于内存的key-value数据库,默认最大使用内存即为服务器的内存大小,因为系统的内存大小有限,一旦超过系统内存大小,linux会将系统内存中的数据转移到swap交换区中,当使用内存大小超过交换区+内存大小时,Redis 的写命令会返回错误信息(但是读命令还可以正常返回)。解决这个问题可以通过配置Redis最大内存和缓存淘汰策略,当内存大小超过Redis配置的最大内存限制时,Redis会启用缓存淘汰策略,淘汰一部分老数据,释放内存空间用来存储新的数据。

本文大纲:

  • 配置最大内存

  • 配置缓存淘汰策略

  • 缓存淘汰策略及算法

  • 总结

一. 配置最大内存

配置最大内存有两种方式:

  • 修改redis.conf配置文件

    maxmemory 100
  • 使用config set命令

    使用redis-cli命令在命令行登陆redis

    127.0.0.1:6666>config set maxmemory 100mb    

二. 配置缓存淘汰策略

  • 修改redis.conf配置文件 

    maxmemory-policy allkeys-lru
  • 使用config set 命令

        
          
          
        
    127 .0.0.1 :6666config  set  maxmemory-policy  allkeys-lru
三.缓存淘汰策略及算法

    Redis一共提供以下8种缓存淘汰策略,默认是noeviction,即不配置缓存淘汰策略

  • volatile-lru -> 从设置了过期时间的key中使用LRU算法进行淘汰

  • allkeys-lru -> 所有key使用LRU算法进行淘汰

  • volatile-lfu -> 从设置了过期时间的key中使用LFU算法进行淘汰

  • allkeys-lfu -> 所有key使用LFU算法进行淘汰

  • volatile-random -> 从设置了过期时间的key中随机淘汰数据

  • allkeys-random -> 从所有key中随机淘汰数据

  • volatile-ttl -> 在设置了过期时间的key中,根据key的过期时间进行淘汰,越早过期的越优先被淘汰

  • noeviction(默认策略) -> 对于写请求不再提供服务,直接返回错误(DEL请求和部分特殊请求除外)

(1) 何为LRU算法

    LRU(Least Recently Used),即最近最少使用,是一种缓存置换算法。在使用内存作为缓存的时候,缓存的大小一般是固定的。当缓存被占满,这个时候继续往缓存里面添加数据,就需要淘汰一部分老的数据,释放内存空间用来存储新的数据。这个时候就可以使用LRU算法了。其核心思想是:如果一个数据在最近一段时间没有被用到,那么将来被使用到的可能性也很小,所以就可以被淘汰掉。

    下面使用java实现一个简单的LRU算法
/**
 * LRU缓存算法实现
 */

public class LRUCache<kv{
    //容量
    private int capacity;
    //当前有多少节点的统计
    private int count;
    //缓存节点
    private Map<k, Node<k, v>> nodeMap;
    private Node<k, v> head;
    private Node<k, v> tail;
    public LRUCache(int capacity) {
        if (capacity < 1) {
            throw new IllegalArgumentException(String.valueOf(capacity));
        }
        this.capacity = capacity;
        this.nodeMap = new HashMap<>();
        //初始化头节点和尾节点,利用哨兵模式减少判断头结点和尾节点为空的代码
        Node headNode = new Node(nullnull);
        Node tailNode = new Node(nullnull);
        headNode.next = tailNode;
        tailNode.pre = headNode;
        this.head = headNode;
        this.tail = tailNode;
    }
    public void put(k key, v value) {
        Node<k, v> node = nodeMap.get(key);
        if (node == null) {
            if (count >= capacity) {
                //先移除一个节点
                removeNode();
            }
            node = new Node<>(key, value);
            //添加节点
            addNode(node);
        } else {
            //移动节点到头节点
            moveNodeToHead(node);
        }
    }
    public Node<k, v> get(k key) {
        Node<k, v> node = nodeMap.get(key);
        if (node != null) {
            moveNodeToHead(node);
        }
        return node;
    }
    private void removeNode() {
        Node node = tail.pre;
        //从链表里面移除
        removeFromList(node);
        nodeMap.remove(node.key);
        count--;
    }
    private void removeFromList(Node<k, v> node) {
        Node pre = node.pre;
        Node next = node.next;

        pre.next = next;
        next.pre = pre;

        node.next = null;
        node.pre = null;
    }
    private void addNode(Node<k, v> node) {
        //添加节点到头部
        addToHead(node);
        nodeMap.put(node.key, node);
        count++;
    }
    private void addToHead(Node<k, v> node) {
        Node next = head.next;
        next.pre = node;
        node.next = next;
        node.pre = head;
        head.next = node;
    }
    public void moveNodeToHead(Node<k, v> node) {
        //从链表里面移除
        removeFromList(node);
        //添加节点到头部
        addToHead(node);
    }
    class Node<kv{
        k key;
        v value;
        Node pre;
        Node next;
        public Node(k key, v value) {
            this.key = key;
            this.value = value;
        }
    }
}
  • LRU在Redis中的实现:

    Redis使用的是近似LRU算法,它跟常规的LRU算法还不太一样。近似LRU算法通过随机采样法淘汰数据,每次随机出5(默认)个key,从里面淘汰掉最近最少使用的key。

可以通过maxmemory-samples参数修改采样数量:例:maxmemory-samples 10 maxmenory-samples配置的越大,淘汰的结果越接近于严格的LRU算法

    Redis为了实现近似LRU算法,给每个key增加了一个额外增加了一个24bit的字段,用来存储该key最后一次被访问的时间。

    Redis 3.0对于LRU替换算法的优化,在只维护一个eviction pool带来的少量开销情况下,对算法效率的提升是比较明显的,效率的提升带来的是访问命中率的提升。

  • LRU时钟的粒度从秒级提升为毫秒级

  • 使用新的API来获取LRU替换时的采样样本

  • 默认的LRU采样样本数从3提升为5

  • 使用eviction pool来选取需要淘汰的key

    在Redis 2.8中每次选取淘汰样本时,都是调用dictGetRandomKey(一个函数)来随机获取一个key,会根据maxmemory-samples配置的大小,多次调用。这个流程在Redis3.0中被优化为一次调用获取指定数量的key,且不需要每次都调用随机函数。

    dictGetSomeKeys会随机从db的某个起始位置开始,连续获取指定数量的key,需要注意的是,如果db对应的字典正在做rehash,可能需要从两个hashtable来获取key。如果需要根据某种分布来随机获取字典里面的key,这种采样方式可能是不合适的,但是如果只是为了随机获取一系列key来作为LRU算法的淘汰样本,这种方式是可行的。

    采样性能的提升带来的好处就是,我们可以在不牺牲淘汰算法性能的情况下,提高采样的样本数,让Redis的近似LRU算法更接近于严格LRU算法,所以目前Redis把超过maxmemory后默认的采样样本数从3个提升到5个。

    还有最重要的改进是,选取要淘汰key的流程。之前是每次随机选取maxmemory-samples个key,然后比较它们的idle时间,idle时间最久的key会被淘汰掉。在Redis 3.0中增加了一个eviction pool的结构,eviction pool是一个数组,保存了之前随机选取的key及它们的idle时间,数组里面的key按idle时间升序排序,当内存满了需要淘汰数据时,会调用dictGetSomeKeys选取指定的数目的key,然后更新到eviction pool里面,如果新选取的key的idle时间比eviction pool里面idle时间最小的key还要小,那么就不会把它插入到eviction pool里面。

    最后淘汰掉eviction pool里面的最后一个元素所对应的key,这样的选取淘汰key的方式的好处是:假设说新随机选取的key的访问时间可能比历史随机选取的key的访问时间还要新,但是在Redis 2.8中,新选取的key会被淘汰掉,这和LRU算法利用的访问局部性原理是相违背的,在Redis 3.0中,这种情况被避免了。

    此外,如果某个历史选取的key的idle时间相对来说比较久,但是本次淘汰并没有被选中,因为出现了idle时间更久的key,那么在使用eviction pool的情况下,这种idle时间比较久的key淘汰概率增大了,因为它在eviction pool里面被保存下来,参与下轮淘汰,这个思路和访问局部性原理是契合的。

    和Redis 2.8相比,改进的效果我们可以引用一下上篇文章《Redis作为LRU Cache的实现》中第一张图的下半部分,

我们可以看到在前面1/2需要淘汰的key里面(浅灰色的点),Redis 3.0残留下来的key明显比Redis 2.8少了很多,而且后面新插入的1/2的key里面(绿色的点),Redis 3.0没有一个淘汰的key。

(2) 何为LFU算法

    LFU算法是Redis4.0里面新加的一种淘汰策略。它的全称是Least Frequently Used,它的核心思想是根据key的最近被访问的频率进行淘汰,很少被访问的优先被淘汰,被访问的多的则被留下来。

  • 解决LRU算法的不足:

     LFU算法能更好的表示一个key被访问的热度。假如你使用的是LRU算法,一个key很久没有被访问到,只刚刚是偶尔被访问了一次,那么它就被认为是热点数据,不会被淘汰,而有些key将来是很有可能被访问到的则被淘汰了。如果使用LFU算法则不会出现这种情况,因为使用一次并不会使一个key成为热点数据。

    了解Redis中的RFU算法,首先了解Redis对象的结构如下:
/**
 *Redis存储的value对象
 */

typedef struct redisObject {
    unsigned type:4
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /*在LRU算法中,24 bits的lru是用来记录LRU time的。
                                                    在LFU中也可以使用这个字段,不过是分成16 bits与8bits使用,
                                                    高16bits的用来记录最近一次计数器降低的,时间ldt( Last decr time),单位是分钟,
                                                    低8bits记录计数器数值counter(LOG_C)。*/

    int refcount;
    void *ptr;
} robj;

LFU配置

Redis4.0之后为maxmemory_policy淘汰策略添加了两个LFU模式:

  • volatile-lfu:对有过期时间的key采用LFU淘汰算法

  • allkeys-lfu:对全部key采用LFU淘汰算法

还有2个配置可以调整LFU算法:
lfu-log-factor  10 #可以调整计数器counter的增长速度,lfu-log-factor越大,counter增长的越慢。
lfu-decay-time  1  #是一个以分钟为单位的数值,可以调整counter的减少速度   

Redis实现LFU算法是基于key的最近访问时间和访问次数综合考虑的,当一个KEY长时间没有被访问的话,这个KEY的访问次数会相应的减少,访问key时会自动增加次数。其实现源码如下:

//LFU算法更新策略
void updateLFU(robj *val) {
    unsigned long counter = LFUDecrAndReturn(val); #调用减少次数的函数
    counter = LFULogIncr(counter); #调用增加次数的函数
    val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}
//减少次数函数
unsigned long LFUDecrAndReturn(robj *o) {
    unsigned long ldt = o->lru >> 8#获取最近一次的记录时间
    unsigned long counter = o->lru & 255#获取最近一次记录的访问次数
    #根据配置的lfu_decay_time计算应该降低多少,其中LFUTimeElapsed用来计算当前时间与ldt的差值
    unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
    #已过去n个lfu_decay_time,则将counter减少n,counter - num_periods。 
   if (num_periods)
        counter = (num_periods > counter) ? 0 : counter - num_periods;
    return counter;
}
/**
 *增加次数函数
 *counter并不是简单的访问一次就+1,而是采用了一个0-1之间的p因子控制增长。
 *counter最大值为255。取一个0-1之间的随机数r与p比较,当r<p时,才增加counter,这和 
 *比特币中控制产出的策略类似。p取决于当前counter值与lfu_log_factor因子,counter值 
 *与lfu_log_factor因子越大,p越小,r<p的概率也越小,counter增长的概率也就越小。
*/

uint8_t LFULogIncr(uint8_t counter) {
    if (counter == 255return 255;
    double r = (double)rand()/RAND_MAX;
    double baseval = counter - LFU_INIT_VAL;
    if (baseval < 0) baseval = 0;
    double p = 1.0/(baseval*server.lfu_log_factor+1);
    if (r < p) counter++;
    return counter;
}
//增长情况如下,可见counter增长与访问次数呈现对数增长的趋势,随着访问次数越来越大,counter增长的越来越慢。
+--------+------------+------------+------------+------------+------------+
| factor | 100 hits   | 1000 hits  | 100K hits  | 1M hits    | 10M hits   |
+--------+------------+------------+------------+------------+------------+
|      0      | 104            | 255             | 255             | 255        | 255              |
+--------+------------+------------+------------+------------+------------+
|      1      | 18              | 49         | 255             | 255        | 255               |
+--------+------------+------------+------------+------------+------------+
|     10     | 10              | 18               | 142             | 255        | 255              |
+--------+------------+------------+------------+------------+------------+
|    100    | 8                | 11               | 49               | 143        | 255              |
+--------+------------+------------+------------+------------+------------+
  • 新生key策略

    当创建新对象的时候,对象的counter如果为0,很容易就会被淘汰掉,还需要为新生key设置一个初始counter,初始化为LFU_INIT_VAL,默认5。

    pool筛选池算法和LRU一致,这里不再赘述

    计算idle时有所不同:

    使用了 255-LFUDecrAndReturn(o) 当做排序的依据,即根据key的访问次数倒序排列。

四. 总结

    在选用Redis作为缓存数据库时,需要提前预估缓存的数据的使用量,为避免当使用内存超过系统内存是导致的Redis无法写入数据的问题,需预先配置好Redis的最大使用内存,同时还应配置Redis的缓存淘汰策略,在缓存数据使用量超限时,自动删除就缓存数据,腾出内存空间供新的缓存数据使用。

    Redis的缓存有volatile-lru,allkeys-lru,volatile-lfu,allkeys-lfu,volatile-random, allkeys-random, volatile-ttl,noeviction(默认策略)8种淘汰策略。

    Redis缓存淘汰算法有LRU和LFU两种,LRU算法是根据key的最新访问时间来作为是否淘汰的条件的,这种算法的缺点是无法识别热点key。LFU算法是根据key的访问次数作为是否淘汰的条件,LFU算法是基于key的最近访问时间和访问次数综合考虑的,通过访问次数的最新记录时间来衡量当前访问次数是否需要减少,当访问key是自动增加访问次数,访问次数的减少和增加都有相应的控制因子,用于控制减少或者增减的速度。