Redis内存淘汰和过期删除策略原理分析

Redis是一个内存键值对数据库,所以对于内存的管理尤为重要。Redis内部对于内存的管理主要包含两个方向,过期删除策略数据淘汰策略。 思考:

  • 什么是数据淘汰?
  • 数据过期和数据淘汰都是删除数据,两者有什么区别?
  • 实际使用场景是多样化的,如何选择合适的淘汰策略?

淘汰策略原理

所谓数据淘汰是指在Redis内存使用达到一定阈值的时候,执行某种策略释放内存空间,以便于接收新的数据。内存可使用空间由配置参数maxmemory决定(单位mb/GB)。故又叫"最大内存删除策略",也叫"缓存删除策略"。

%accordion%maxmemory配置%accordion%

ruby 复制代码
# 客户端命令方式配置和查看内存大小
127.0.0.1:6379> config get maxmemory
"maxmemory"
"0"
127.0.0.1:6379> config set maxmemory 100mb
OK
127.0.0.1:6379> config get maxmemory
"maxmemory"
"104857600"
​
#通过redis.conf 配置文件配置
127.0.0.1:6379> info
# Server
#...
# 配置文件路径
config_file:/opt/homebrew/etc/redis.conf
#...
​
​
# 修改内存大小
> vim /opt/homebrew/etc/redis.conf
############################## MEMORY MANAGEMENT ################################
​
# Set a memory usage limit to the specified amount of bytes.
# When the memory limit is reached Redis will try to remove keys
# according to the eviction policy selected (see maxmemory-policy).
#
#...
maxmemory 100mb
#...
​

%/accordion%

注:若maxmemory=0则表示不做内存限制,但是对于windows系统来说,32位系统默认可使用空间是3G,因为整个系统内存是4G,需要留1G给系统运行。且淘汰策略会自动设置为noeviction,即不开启淘汰策略,当使用空间达到3G的时候,新的内存请求会报错。
### 淘汰策略分类

  • 淘汰策略配置maxmemory-policy,表示当内存达到maxmemory时,将执行配置的淘汰策略,由redis.c/freeMemoryIfNeeded 函数实现数据淘汰逻辑。

    %accordion%maxmemory-policy配置%accordion%

    ruby 复制代码
    # 命令行配置方式
    127.0.0.1:6379> CONFIG GET maxmemory-policy
    "maxmemory-policy"
    "noeviction"
    127.0.0.1:6379> CONFIG SET maxmemory-policy volatile-lru
    OK
    127.0.0.1:6379> CONFIG GET maxmemory-policy
    "maxmemory-policy"
    "volatile-lru"
    ​
    #redis.conf文件配置方式
    # MAXMEMORY POLICY: how Redis will select what to remove when maxmemory
    # is reached. You can select one from the following behaviors:
    #
    # volatile-lru -> Evict using approximated LRU, only keys with an expire set.
    # allkeys-lru -> Evict any key using approximated LRU.
    # volatile-lfu -> Evict using approximated LFU, only keys with an expire set.
    # allkeys-lfu -> Evict any key using approximated LFU.
    # volatile-random -> Remove a random key having an expire set.
    # allkeys-random -> Remove a random key, any key.
    # volatile-ttl -> Remove the key with the nearest expire time (minor TTL)
    # noeviction -> Don't evict anything, just return an error on write operations.
    #
    # LRU means Least Recently Used
    # LFU means Least Frequently Used
    #
    # Both LRU, LFU and volatile-ttl are implemented using approximated
    # randomized algorithms.
    # The default is:
    # ...
    maxmemory-policy noeviction

    %/accordion%

    %accordion%freeMemoryIfNeeded逻辑处理%accordion%

    ini 复制代码
    int freeMemoryIfNeeded(void) {
      size_t mem_used, mem_tofree, mem_freed;
      int slaves = listLength(server.slaves);
    ​
      /* Remove the size of slaves output buffers and AOF buffer from the count of used memory.*/
      // 计算出 Redis 目前占用的内存总数,但有两个方面的内存不会计算在内:
      // 1)从服务器的输出缓冲区的内存
      // 2)AOF 缓冲区的内存
      mem_used = zmalloc_used_memory();
      if (slaves) {
        listIter li;
        listNode *ln;
    ​
        listRewind(server.slaves,&li);
        while((ln = listNext(&li))) {
          redisClient *slave = listNodeValue(ln);
          unsigned long obuf_bytes = getClientOutputBufferMemoryUsage(slave);
          if (obuf_bytes > mem_used)
            mem_used = 0;
          else
            mem_used -= obuf_bytes;
        }
      }
      if (server.aof_state != REDIS_AOF_OFF) {
        mem_used -= sdslen(server.aof_buf);
        mem_used -= aofRewriteBufferSize();
      }
    ​
      /* Check if we are over the memory limit. */
      // 如果目前使用的内存大小比设置的 maxmemory 要小,那么无须执行进一步操作
      if (mem_used <= server.maxmemory) return REDIS_OK;
    ​
      // 如果占用内存比 maxmemory 要大,但是 maxmemory 策略为不淘汰,那么直接返回
      if (server.maxmemory_policy == REDIS_MAXMEMORY_NO_EVICTION)
        return REDIS_ERR; /* We need to free memory, but policy forbids. */
    ​
      /* Compute how much memory we need to free. */
      // 计算需要释放多少字节的内存
      mem_tofree = mem_used - server.maxmemory;
    ​
      // 初始化已释放内存的字节数为 0
      mem_freed = 0;
    ​
      // 根据 maxmemory 策略,
      // 遍历字典,释放内存并记录被释放内存的字节数
      while (mem_freed < mem_tofree) {
        int j, k, keys_freed = 0;
    ​
        // 遍历所有字典
        for (j = 0; j < server.dbnum; j++) {
          long bestval = 0; /* just to prevent warning */
          sds bestkey = NULL;
          dictEntry *de;
          redisDb *db = server.db+j;
          dict *dict;
    ​
          if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU ||
            server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_RANDOM)
          {
            // 如果策略是 allkeys-lru 或者 allkeys-random 
            // 那么淘汰的目标为所有数据库键
            dict = server.db[j].dict;
          } else {
            // 如果策略是 volatile-lru 、 volatile-random 或者 volatile-ttl 
            // 那么淘汰的目标为带过期时间的数据库键
            dict = server.db[j].expires;
          }
    ​
          // 跳过空字典
          if (dictSize(dict) == 0) continue;
    ​
          /* volatile-random and allkeys-random policy */
          // 如果使用的是随机策略,那么从目标字典中随机选出键
          if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_RANDOM ||
            server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_RANDOM)
          {
            de = dictGetRandomKey(dict);
            bestkey = dictGetKey(de);
          }
    ​
          /* volatile-lru and allkeys-lru policy */
          // 如果使用的是 LRU 策略,
          // 那么从一集 sample 键中选出 IDLE 时间最长的那个键
          else if (server.maxmemory_policy == REDIS_MAXMEMORY_ALLKEYS_LRU ||
            server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_LRU)
          {
            struct evictionPoolEntry *pool = db->eviction_pool;
    ​
            while(bestkey == NULL) {
              evictionPoolPopulate(dict, db->dict, db->eviction_pool);
              /* Go backward from best to worst element to evict. */
              for (k = REDIS_EVICTION_POOL_SIZE-1; k >= 0; k--) {
                if (pool[k].key == NULL) continue;
                de = dictFind(dict,pool[k].key);
    ​
                /* Remove the entry from the pool. */
                sdsfree(pool[k].key);
                /* Shift all elements on its right to left. */
                memmove(pool+k,pool+k+1,
                  sizeof(pool[0])*(REDIS_EVICTION_POOL_SIZE-k-1));
                /* Clear the element on the right which is empty since we shifted one position to the left.  */
                pool[REDIS_EVICTION_POOL_SIZE-1].key = NULL;
                pool[REDIS_EVICTION_POOL_SIZE-1].idle = 0;
    ​
                /* If the key exists, is our pick. Otherwise it is a ghost and we need to try the next element. */
                if (de) {
                  bestkey = dictGetKey(de);
                  break;
                } else {
                  /* Ghost... */
                  continue;
                }
              }
            }
          }
    ​
          /* volatile-ttl */
          // 策略为 volatile-ttl ,从一集 sample 键中选出过期时间距离当前时间最接近的键
          else if (server.maxmemory_policy == REDIS_MAXMEMORY_VOLATILE_TTL) {
            for (k = 0; k < server.maxmemory_samples; k++) {
              sds thiskey;
              long thisval;
    ​
              de = dictGetRandomKey(dict);
              thiskey = dictGetKey(de);
              thisval = (long) dictGetVal(de);
    ​
              /* Expire sooner (minor expire unix timestamp) is better candidate for deletion */
              if (bestkey == NULL || thisval < bestval) {
                bestkey = thiskey;
                bestval = thisval;
              }
            }
          }
    ​
          /* Finally remove the selected key. */
          // 删除被选中的键
          if (bestkey) {
            long long delta;
    ​
            robj *keyobj = createStringObject(bestkey,sdslen(bestkey));
            propagateExpire(db,keyobj);
            // 计算删除键所释放的内存数量
            delta = (long long) zmalloc_used_memory();
            dbDelete(db,keyobj);
            delta -= (long long) zmalloc_used_memory();
            mem_freed += delta;
            
            // 对淘汰键的计数器增一
            server.stat_evictedkeys++;
    ​
            notifyKeyspaceEvent(REDIS_NOTIFY_EVICTED, "evicted",
                keyobj, db->id);
            decrRefCount(keyobj);
            keys_freed++;
    ​
            /* When the memory to free starts to be big enough, we may */
            /* start spending so much time here that is impossible to */
            /* deliver data to the slaves fast enough, so we force the */
            /* transmission here inside the loop. */
            if (slaves) flushSlavesOutputBuffers();
          }
        }
    ​
        if (!keys_freed) return REDIS_ERR; /* nothing to free... */
      }
    ​
      return REDIS_OK;
    }

    %/accordion%

  • 8种淘汰策略

    %accordion%Redis定义的策略常量(version < 4.0)%accordion%

    arduino 复制代码
     /* Redis maxmemory strategies */
     #define REDIS_MAXMEMORY_VOLATILE_LRU 0
     #define REDIS_MAXMEMORY_VOLATILE_TTL 1
     #define REDIS_MAXMEMORY_VOLATILE_RANDOM 2
     #define REDIS_MAXMEMORY_ALLKEYS_LRU 3
     #define REDIS_MAXMEMORY_ALLKEYS_RANDOM 4
     #define REDIS_MAXMEMORY_NO_EVICTION 5
     #define REDIS_DEFAULT_MAXMEMORY_POLICY REDIS_MAXMEMORY_NO_EVICTION

    %/accordion%

    3.0版本提供6种策略:

    • volatile-lru( REDIS_MAXMEMORY_VOLATILE_LRU): Evict using approximated LRU, only keys with an expire set -> 内存不足时,对所有配置了过期时间的key,淘汰最近最少使用的数据。
    • allkeys-lru(REDIS_MAXMEMORY_ALLKEYS_LRU): Evict any key using approximated LRU -> 内存不足时,对所有key,淘汰最近最少使用的数据。
    • volatile-random( REDIS_MAXMEMORY_VOLATILE_RANDOM): Remove a random key having an expire set -> 内存不足时,对所有配置了过期时间的key,淘汰随机数据。
    • allkeys-random(REDIS_MAXMEMORY_ALLKEYS_RANDOM): Remove a random key, any key -> 内存不足时,对所有key,淘汰随机数据。
    • volatile-ttl( REDIS_MAXMEMORY_VOLATILE_TTL): Remove the key with the nearest expire time (minor TTL) -> 内存不足时,对所有配置了过期时间的key,淘汰最近将要过期的数据。
    • noeviction( REDIS_MAXMEMORY_NO_EVICTION): Don't evict anything, just return an error on write operations -> 不开启淘汰策略,在不配置淘汰策略的情况下,maxmemory-policy默认等于该值。内存不足时,会抛出异常,写操作不可用。不同系统存在差异性-具体见

    4.0以上版本增加两种LFU策略:

    • volatile-lfu( REDIS_MAXMEMORY_VOLATILE_LFU): Evict using approximated LFU, only keys with an expire set -> 对配置了过期时间的key,淘汰最近使用频率最少的数据。
    • allkeys-lfu(REDIS_MAXMEMORY_ALLKEYS_LFU): Evict any key using approximated LFU -> 对所有key,淘汰最近使用频率最少的数据。

淘汰策略的选择

  • 存在冷热数据区别,即意味着访问频率存在较大差异,4.0及以上版本建议选择allkeys-lfu策略,但要设置lfu-decay-time 计数衰减值,一般默认1,这样可避免缓存污染现象;3.0及以下版本建议选择allkeys-lru策略。

    %accordion%LFU访问计数衰减配置%accordion%

    ini 复制代码
    # The counter decay time is the time, in minutes, that must elapse in order
    # for the key counter to be divided by two (or decremented if it has a value
    # less <= 10).
    #
    # The default value for the lfu-decay-time is 1. A special value of 0 means to
    # decay the counter every time it happens to be scanned.
    #
    lfu-decay-time 1

    %/accordion%

  • 若整体访问频率较为平衡,则可选择allkeys-random策略随机淘汰数据。

  • 存在置顶数据(或者希望一些数据长期被保存) ,4.0及以上版本建议选择volatile-lfu策略,3.0及以下版本建议选择volatile-lru策略。对于需要置顶的数据不设置或者设置较长的过期时间,其他数据都设置小于该值的过期时间,以便淘汰非置顶数据。

  • 若希望所有的数据可通过过期时间来判断其顺序,则可选择volatile-ttl策略。

  • 由于过期删除策略的存在,对于过期时间的配置,存在额外的expires字典表,是会占用部分Redis内存的。若希望内存可以得到更加高效的利用,可选择allkeys-lru/allkeys-lfu策略。


Redis在实现淘汰策略时为了更合理的利用内存空间以及保证Redis的高性能,只是几近于算法的实现机制,其会从性能和可靠性层面做出一些平衡,故并不是完全可靠的。因此我们在实际使用过程中,建议都配置过期时间,主动删除那些不再使用的数据,以保证内存的高效使用。另外关于LRU和LFU算法,Redis内部在数据结构和实现机制上都做了一定程度的适应性改造

过期策略原理分析

众所周知,在Redis的实际使用过程中,为了让可贵的内存得到更高效的利用,我们提倡给每一个key配置合理的过期时间,以避免因内存不足,或因数据量过大而引发的请求响应延迟甚至是不可用等问题。 思考:

  • key的删除是实时的吗?
  • 是否存在并发和数据一致性问题?
  • 内存空间是有限的,除了过期策略,Redis还有什么其他保障?

过期Key删除原理

过期时间底层原理

当key设置了过期时间,Redis内部会将这个key带上过期时间放入过期字典(expires)中,当进行查询时,会先在过期字典中查询是否存在该键,若存在则与当前UNIX时间戳做对比来进行过期时间判定。

  • 过期时间配置命令如下(即EX|PX|EXAT|PXAT):

    arduino 复制代码
    # expire: t秒后过期
    expire key seconds
    # pexpire: t毫秒后过期
    pexpire key millseconds
    # expireat: 到达具体的时间戳时过期,精确到秒
    expireat key timestamp
    # pexpireat: 到达具体的时间戳时过期,精确到毫秒
    pexpire key millseconds

    这四个命令看似有差异,但在RedisDb底层,最终都会转换成pexpireat指令。内部由db.c/expireGenericCommand函数实现,对外由上面四个指令调用

    scss 复制代码
    //expire命令
    void expireCommand(redisClient *c) {
      expireGenericCommand(c,mstime(),UNIT_SECONDS);
    }
    //expireat命令
    void expireatCommand(redisClient *c) {
      expireGenericCommand(c,0,UNIT_SECONDS);
    }
    //pexpire命令
    void pexpireCommand(redisClient *c) {
      expireGenericCommand(c,mstime(),UNIT_MILLISECONDS);
    }
    //pexpireat命令
    void pexpireatCommand(redisClient *c) {
      expireGenericCommand(c,0,UNIT_MILLISECONDS);
    }
    ​
    /* This is the generic command implementation for EXPIRE, PEXPIRE, EXPIREAT
    * and PEXPIREAT. Because the commad second argument may be relative or absolute
    * the "basetime" argument is used to signal what the base time is (either 0
    * for *AT variants of the command, or the current time for relative expires).
    */
    void expireGenericCommand(redisClient *c, long long basetime, int unit) {
      ...
      /* unix time in milliseconds when the key will expire. */
      long long when; 
      ...
      //如果是秒转换为毫秒
      if (unit == UNIT_SECONDS) when *= 1000;
      when += basetime;
      ...
    }
  • 过期字典内部存储结构:key表示一个指向具体键的指针,value是long类型的毫秒精度的UNIX时间戳。

  • Rediskey过期时间内部流程图:

常见删除方式

  • 定时删除:在写入key之后,根据否配置过期时间生成特定的定时器,定时器的执行时间就是具体的过期时间。用CPU性能换去内存存储空间------即用时间获取空间
  • 定期删除:提供一个固定频率的定时器,执行时扫描所有的key进行过期检查,满足条件的就进行删除。
  • 惰性删除:数据不做及时释放,待下一次接收到读写请求时,先进行过期检查,若已过期则直接删除。用内存存储空间换取CPU性能------即用空间换取时间
删除方式 优点 缺点
定时删除 能及时释放内存空间,不会产生滞留数据 频繁生成和销毁定时器,非常损耗CPU性能,影响响应时间和指令吞吐量
定期删除 固定的频率进行过期检查,对CPU交友好 1.数据量比较大的情况下,会因为全局扫描而损耗CPU性能,且主线程的阻塞会导致其他请求响应延迟。2.未能及时释放内存空间。3.数据已过期,但定时器未执行时会导致数据不一致。
惰性删除 节约CPU性能 当某些数据长时间无请求访问时,会导致数据滞留,使内存无法释放,占用内存空间,甚至坑导致内存泄漏而引发服务不可用

Redis过期删除策略

由上述三种常用的删除方式对比结果可知,单独的使用任何一种方式都不能达到比较理想的结果,因此Redis的作者在设计过期删除策略的时候,结合了定期删除惰性删除两种方式来完成。

  • 定期删除:内部通过redis.c/activeExpireCycle函数,以一定的频率运行,每次运行从数据库中随机抽取一定数量的key进行过期检查,若检查通过,则对该数据进行删除。在2.6版本中,默认每秒10次,在2.8版本后可通过redis.config配置文件的hz属性对频率进行设置,,官方建议数值不要超过100,否则将对CPU性能有重大影响。

    bash 复制代码
    # The range is between 1 and 500, however a value over 100 is usually not
    # a good idea. Most users should use the default of 10 and raise this up to
    # 100 only in environments where very low latency is required.
    hz 10
  • 惰性删除:内部通过redis.c/expireIfNeeded函数,在每次执行读写操作指令之前,进行过期检查。若已设置过期时间且已过期,则删除该数据。

删除方式 优点 缺点
Redis定期删除 避免了全局扫描,每次随机抽取数据量较少,性能较稳定,执行频率可配置;避免了惰性删除低频数据长时间滞留的问题 存在概率上某些数据一直没被抽取的情况,导致数据滞留
Redis惰性删除 解决了定期删除可能导致的数据滞留现象,性能较高 低频数据长时间无法释放

总结:由表格可知,这两种方式的结合,能很好的解决过期数据滞留内存的问题,同时也很好的保证了数据的一致性,保证了内存使用的高效与CPU的性能

过期删除策略引起的脏读现象

  • 在单节点实例模式下,因为Redis是单线程模型,所以过期策略可以保证数据一致性。

  • 在集群模式下,过期删除策略会引起脏读现象

    • 数据的删除在主库执行,从库不会执行。对于惰性删除策略来说,3.2版本以前,从库读取数据时哪怕数据已过期还是会返回数据,3.2版本以后,则会返回空。
    • 对于定期删除策略,由于只是随机抽取了一定的数据,此时已过期但未被命中删除的数据在从库中读取会出现脏读现象。
    • 过期时间命令EX|PX,在主从同步时,因为同步需要时间,就会导致主从库实际过期时间出现偏差。比如主库设置过期时间60s,但同步全量花费了1分钟,那么在从库接收到命令并执行之后,就导致从库key的过期时间整体跨越了两分钟,而此时主库在一分钟之前数据就已经过期了。EXAT|PXAT 命令来设置过期时间节点。这样可避免增量同步的发生。但需注意主从服务器时间一致。

在实际使用过程中,过期时间配置只是一种常规手段,当key的数量在短时间内突增,就有可能导致内存不够用。此时就需要依赖于Redis内部提供的淘汰策略来进一步的保证服务的可用性。

结语

到这里,我们可得出一个结论:Redis的高性能不仅仅体现在单线程上,还在于内存和数据管理的相辅相成上。 除此之外,Redis的多样化数据结构和vm体系也为其高性能提供了更加有力的支撑,后续我们可以一起研究学习.

(ps: github源码地址)

推荐阅读

浅析MySQL之MVCC机制

被忽略的缓存 -bfcache

新一代vue状态管理工具Pinia

Cola-StateMachine状态机的实战使用

Redisson杂谈

招贤纳士

政采云技术团队(Zero),Base 杭州,一个富有激情和技术匠心精神的成长型团队。规模 500 人左右,在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。

如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊......如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 zcy-tc@cai-inc.com

微信公众号

文章同步发布,政采云技术团队公众号,欢迎关注

相关推荐
小蜗牛慢慢爬行9 分钟前
如何在 Spring Boot 微服务中设置和管理多个数据库
java·数据库·spring boot·后端·微服务·架构·hibernate
wm10431 小时前
java web springboot
java·spring boot·后端
龙少95433 小时前
【深入理解@EnableCaching】
java·后端·spring
溟洵4 小时前
Linux下学【MySQL】表中插入和查询的进阶操作(配实操图和SQL语句通俗易懂)
linux·运维·数据库·后端·sql·mysql
SomeB1oody7 小时前
【Rust自学】6.1. 定义枚举
开发语言·后端·rust
SomeB1oody7 小时前
【Rust自学】5.3. struct的方法(Method)
开发语言·后端·rust
啦啦右一9 小时前
Spring Boot | (一)Spring开发环境构建
spring boot·后端·spring
森屿Serien9 小时前
Spring Boot常用注解
java·spring boot·后端
盛派网络小助手10 小时前
微信 SDK 更新 Sample,NCF 文档和模板更新,更多更新日志,欢迎解锁
开发语言·人工智能·后端·架构·c#
∝请叫*我简单先生11 小时前
java如何使用poi-tl在word模板里渲染多张图片
java·后端·poi-tl