• Redis内存淘汰机制


    概述

    Redis是基于内存存储,常用于数据的缓存,所以Redis提供了对键的过期时间的设置,实现了几种淘汰机制便于适应各种场景。


    设置过期时间
    我们可以在设置键时设置expire time,也可以在运行时给存在的键设置剩余的生存时间,不设置则默认为-1,设置为-1时表示永久存储。

    Redis清除过期Key的方式

    定期删除 + 惰性删除
    

    定期删除

    Redis设定每隔100ms随机抽取设置了过期时间的key,并对其进行检查,如果已经过期则删除。

    为什么是随机抽取? 因为如果存储了大量数据,全部遍历一遍是非常影响性能的!

    惰性删除

    每次获取key时会对key进行判断是否还存活,如果已经过期了则删除。


    注意:Redis中过期的key并不会马上删除,因为定期删除可能正好没抽取到它,我们也没有访问它触发惰性删除
    

    Redis内存淘汰机制

    思考一下,如果定期删除漏掉了很多过期的key,而我们也没有再去访问它,如果不加处理,很可能导致内存耗尽。


    Redis配置文件中可以设置maxmemory,内存的最大使用量,到达限度时会执行内存淘汰机制

    配置

    • redis.conf 配置文件中配置最大可用内存

      // 设置Redis 最大可用内存为 1024mb
      maxmemory 1024mb
      
    • 命令操作

      //获取设置的Redis能使用的最大内存大小
      127.0.0.1:6379> config get maxmemory
      //设置Redis最大占用内存大小为1024M
      127.0.0.1:6379> config set maxmemory 1024mb

    Redis中的内存淘汰机制

    没有配置时,默认为noeviction 不驱逐(删除)数据

    名称 描述
    noeviction 当内存不足写入新数据时,写入操作会报错,同时不删除数据
    volatile-lru 已设置过期时间 的数据集中挑选 最近最少使用 的 Key 淘汰
    volatile-ttl 已设置过期时间 的数据集中挑选 将要过期 的 Key 淘汰
    volatile-random 已设置过期时间 的数据集中挑选 任意 Key 淘汰
    volatile-lfu 已设置过期时间 的数据集中挑选 最不经常 使用的 Key 淘汰
    allkeys-lru 当内存不足写入新数据时淘汰 最近最少使用 的 Key
    allkeys-random 当内存不足写入新数据时随机选择 任意 Key 淘汰
    allkeys-lfu 当内存不足写入新数据时移除 最不经常使用 的 Key
    • volatile为前缀的策略都是从 已过期的数据集 中进行淘汰。
    • allkeys为前缀的策略都是面向 所有key 进行淘汰。
    • LRU(Least Recently Used)最近最少使用的。
    • LFU(Least Frequently Used)最不常用的。
    • 它们的触发条件都是Redis使用的内存达到阈值时。

    内存淘汰机制设置获取修改

    • redis.conf 配置文件中配置最大可用内存

      // 设置Redis 淘汰机制为 volatile-lfu
      maxmemory-policy volatile-lfu
      
    • 命令操作

      //获取设置的Redis内存淘汰机制
      127.0.0.1:6379> config get maxmemory-policy
      //设置Redis内存淘汰机制
      127.0.0.1:6379> config set maxmemory-policy volatile-lfu

    LRU 算法

    概念

    LRU(Least Recently Used),最近最少使用,是一种缓存置换算法,其核心思想是:如果一个数据在最近一段时间内没有被用到,那么将来被使用的可能性也很小,所以就可以被淘汰掉。

    实现原理

    实现 LRU 算法除了需要 key/value 字典外,还需要附加一个链表,链表中的元素按照一定的顺序进行排列。当空间满的时候,会踢掉链表尾部的元素,当字典某个元素被访问时,它在链表中的位置会被移动到表头,所以链表的元素排列顺序就是元素最近被访问的时间顺序。

    位于链表尾部的元素就是不被重用的元素,所以会被踢掉。位于表头的元素是刚被使用过的,因此暂时不会被踢。

    下面使用 PHP 来实现一个简单的 LRU 算法。

    <?php
    class LRUCache
    {
        private $cache = [];
        private $maxSize = 0;
    
        function __construct($size)
        {
            // 缓存最大存储数量
            $this->maxSize = $size;
        }
    
        public function set($key, $value)
        {
            // 如果存在,就先删除,然后在开头插入
            if (isset($this->cache[$key])) {
                unset($this->cache[$key]);
            }
            // 长度检查,超长则删除尾部元素
            if (count($this->cache) >= $this->maxSize) {
                array_pop($this->cache);
            }
            // 头部插入元素
            $this->cache = [$key=>$value] + $this->cache;
        }
    
        public function get($key)
        {
            $resultValue = null;
            if (isset($this->cache[$key])) {
                $resultValue = $this->cache[$key];
                // 移动到头部
                unset($this->cache[$key]);
                $this->cache = [$key=>$resultValue] + $this->cache;
            }
            
            return $resultValue;
        }
    
        public function getAll()
        {
            return $this->cache;
        }
    }
    
    $cache = new LRUCache(3);
    $cache->set('a', 1);
    $cache->set('b', 2);
    $cache->set('c', 3);
    var_dump($cache->getAll());
    
    $cache->set('d', 4);
    var_dump($cache->getAll());
    

    LRU 在 redis 中的实现

    Redis 使用了一种近似 LRU算法,之所以不使用 LRU 算法,是因为其需要消耗大量的额外内存。

    redis 为了实现近似 LRU 算法,给每个 key 增加了一个 24 bit的字段,用于保存最后一次被访问的时间。

    Redis维护了一个24位时钟,可以简单理解为当前系统的时间戳,每隔一定时间会更新这个时钟。每个key对象内部同样维护了一个24位的时钟,当新增key对象的时候会把系统的时钟赋值到这个内部对象时钟。比如我现在要进行LRU,那么首先拿到当前的全局时钟,然后再找到内部时钟与全局时钟距离时间最久的(差最大)进行淘汰,这里值得注意的是全局时钟只有24位,按秒为单位来表示才能存储194天,所以可能会出现key的时钟大于全局时钟的情况,如果这种情况出现那么就两个相加而不是相减来求最久的key。

    struct redisServer {
          pid_t pid; 
          char *configfile; 
          //全局时钟
          unsigned lruclock:LRU_BITS; 
          ...
    };
    
    typedef struct redisObject {
       unsigned type:4;
       unsigned encoding:4;
       /* key对象内部时钟 */
       unsigned lru:LRU_BITS;
       int refcount;
       void *ptr;
    } robj;
    

    近似的 LRU 算法实际原理是 维护一个候选池(大小16),第一次选取 5 个(默认值)key 放到池中,随后每次选取的 key 值只有 访问时间(与系统时钟)间隔 大于 池中最小访问时间间隔的 才会被放到 池中,直到放满,如果有新加入的,则移除间隔时间最小的 key,当需要淘汰时,则直接从池中选取时间间隔最大(最久没用被调用)的进行淘汰。

    LRU 和 近似 LRU 效果对比

    下图是常规LRU淘汰策略与Redis随机样本取一键淘汰策略的对比,浅灰色表示已经删除的键,深灰色表示没有被删除的键,绿色表示新加入的键,越往上表示键加入的时间越久。从图中可以看出,在redis 3中,设置样本数为10的时候能够很准确的淘汰掉最久没有使用的键,与常规LRU基本持平。

    Snipaste_2020-08-27_22-13-36

    LFU 算法

    概念

    LFU(Least Frequently Used),它的核心思想是 如果一个数据在最近一段时间内使用次数很少,那么在将来一段时间内被使用的可能性也很小,所有就可以被淘汰掉。

    实现原理

    根据 key 的最近访问频率进行淘汰,很少被访问的优先被淘汰,被访问多的则留下来。

    下面使用 PHP 实现 LFU 算法

    class LFUCache
    {
        private $cache = [];
        private $maxSize = 0;
        // 访问次数 key=>count
        private $lfu = [];
    
        function __construct($size)
        {
            // 缓存最大存储数量
            $this->maxSize = $size;
        }
    
        public function set($key, $value)
        {
            // 如果存在,就更新访问次数+1
            if (isset($this->cache[$key])) {
                $this->lfu[$key] += 1; 
            }
            // 长度检查,超长则删除最久访问数据
            $this->cleanup();
    
            // 插入元素, 更新访问次数
            $this->cache[$key] = $value;
            if (!isset($this->lfu[$key])) {
                $this->lfu[$key] = 1; 
            }
        }
    
        public function cleanup()
        {
            if (count($this->cache) >= $this->maxSize) {
                asort($this->lfu);
                $k = array_keys($this->lfu)[0];
                unset($this->cache[$k]);
                unset($this->lfu[$k]);
            }
            return true;
        }
    
        public function get($key)
        {
            $resultValue = null;
            if (isset($this->cache[$key])) {
                $resultValue = $this->cache[$key];
                // 更新访问时间
                $this->lfu[$key] += 1;
            }
            
            return $resultValue;
        }
    
        public function getAll()
        {
            return $this->cache;
        }
    }
    
    $cache = new LFUCache(3);
    $cache->set('a', 1);
    $cache->set('b', 2);
    $cache->set('c', 3);
    
    var_dump($cache->getAll());
    $cache->get('a');
    $cache->set('d', 4);
    var_dump($cache->getAll());
    

    LFU 在 redis 中的实现

    LFU 是在 Redis4.0 后出现的,LRU 的最近最少使用实际上并不精确,考虑下面的情况,如果在|处删除,那么A距离的时间最久,但实际上A的使用频率要比B频繁,所以合理的淘汰策略应该是淘汰B。LFU 就是为应对这种情况而生的。

    A~~A~~A~~A~~A~~A~~A~~A~~A~~A~~~|
    B~~~~~B~~~~~B~~~~~B~~~~~~~~~~~B|
    

    LFU 把原来的 key 对象的内部时钟的 24 位分成两部分,前16位还代表时钟,后8位代表一个计数器。

    16 位的情况下如果还按照秒为单位就会导致不够用,所以一般这里以时钟为单位。而后8位表示当前 key 对象的访问频率,8 位只能代表255,但是redis 并没有采用线性上升的方式,而是通过一个复杂的公式,通过配置两个参数来调整数据的递增速度。
    下图从左到右表示 key 的命中次数,从上到下表示影响因子,在影响因子为 100 的条件下,经过 10M 次命中才能把后8位值加满到255.

    # +--------+------------+------------+------------+------------+------------+
    # | 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        |
    # +--------+------------+------------+------------+------------+------------+
    
      uint8_t LFULogIncr(uint8_t counter) {
          if (counter == 255) return 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;
      }
    

    配置参数

    lfu-log-factor 10
    lfu-decay-time 1
    

    上面说的情况是 key 一直被命中的情况,如果一个 key 经过几分钟没有被命中,那么后8位的值是需要递减几分钟,具体递减几分钟根据衰减因子lfu-decay-time来控制

    unsigned long LFUDecrAndReturn(robj *o) {
        unsigned long ldt = o->lru >> 8;
        unsigned long counter = o->lru & 255;
        unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
        if (num_periods)
            counter = (num_periods > counter) ? 0 : counter - num_periods;
        return counter;
    }
    

    上面递增和衰减都有对应参数配置,那么对于新分配的 key 呢?如果新分配的 key 计数器开始为0,那么很有可能在内存不足的时候直接就给淘汰掉了,所以默认情况下新分配的 key 的后 8 位计数器的值为5(可配置),防止因为访问频率过低而直接被删除。

    低 8 位我们描述完了,那么高16位的时钟是用来干嘛的呢?目前我的理解是用来衰减低8位的计数器的,就是根据这个时钟与全局时钟进行比较,如果过了一定时间(做差)就会对计数器进行衰减。

    参考资料

    Redis内存淘汰机制

    redis内存淘汰机制

    Redis的缓存淘汰策略LRU与LFU

  • 相关阅读:
    面试问烂的 MySQL 四种隔离级别,看完吊打面试官!
    注解Annotation实现原理与自定义注解例子
    趣图:苦逼的后端工程师
    session深入探讨
    趣图:听说996工作可以获得巨大成长
    面试官:一个 TCP 连接可以发多少个 HTTP 请求?
    聊聊前后端分离接口规范
    趣图:什么?需求文档又改了
    ASP.NET页面中去除VIEWSTATE视
    C#
  • 原文地址:https://www.cnblogs.com/martini-d/p/redis-nei-cun-tao-tai-ji-zhi.html
Copyright © 2020-2023  润新知