Comment comprendre la stratégie de récupération et d'élimination de la mémoire de redis ?

Nous savons que pour une base de données en mémoire telle que redis, comment supprimer des données expirées et comment récupérer de la mémoire lorsque la mémoire est pleine est une tâche très importante.

Le travail commun de récupération de mémoire Redis est principalement divisé en deux aspects :

  1. Effacer les clés expirées
  2. Lorsque la mémoire est insuffisante, suffisamment de mémoire est récupérée pour stocker la nouvelle clé

Effacer les clés expirées

Nous utilisons rarement une clé sans horodatage dans redis, car cela signifie que la clé peut devenir une clé morte dans un avenir proche, ce qui est inconnu, mais cela prend de l'espace de stockage, ce qui conduit à un autre problème. Redis ne peut pas analyser et supprimer directement les clés expirées. De cette façon, lorsque le nombre de clés atteint 100 000 ou même des millions, redis tombera dans un état d'indisponibilité élevée du processeur en raison de l'analyse fréquente des clés.

Afin d'effacer les clés expirées, redis définit une stratégie spéciale :

  1. Suppression paresseuse : comme son nom l'indique, cette stratégie ne supprime pas activement les clés expirées, mais les supprime lorsque le client tente d'accéder à la clé expirée.
  2. Effacement périodique : redis a une stratégie de synchronisation qui extrait en continu certaines clés pour vérifier si elles sont expirées, et les clés expirées seront effacées

suppression paresseuse

La suppression paresseuse est une méthode efficace pour réduire le CPU. Le point de déclenchement est placé lors de l'accès à la clé. Lors de l'accès à la clé, une méthode spéciale sera déclenchée. Le contenu principal de cette méthode est le suivant. Sa fonction est que si la clé expire, elle sera supprimée après cet accès expireIfNeeded.

int expireIfNeeded(redisDb *db, robj *key) {
    // 判断 key 是否过期
    if (!keyIsExpired(db,key)) return 0;
    ....
    // 如果 server.lazyfree_lazy_expire 为 1 表示异步删除,反之同步删除;
    return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
                                         dbSyncDelete(db,key);
}

Le processus général est le suivant :

  1. Accès client à la clé horodatée
  2. Le serveur vérifie si la clé a expiré, et si elle a expiré, supprime la clé. Le fait qu'il s'agisse d'une suppression synchrone dépend des paramètres lazyfree_lazy_expire.
  3. retourner le client nul
  4. S'il n'est pas expiré, renvoie la valeur normale du client

img

nettoyer régulièrement

Nous ne pouvons pas nous attendre à ce que les clés expirées soient toujours accessibles, nous avons besoin d'un moyen de nettoyer activement les clés. Cependant, afin de réduire la perte causée par l'effacement, nous ne pouvons pas scanner directement toutes les clés. Redis sélectionne au hasard une partie des clés pour vérifier si elles ont expiré. Si elles ont expiré, supprimez-les.

Redis propose deux modes d'appel de méthodes spécifiques pour nettoyer les clés expirées :

  1. 定期清理:也就是slow模式,我们将主要介绍的模式,它会定期清理过期的key,同时为了减少影响会限制每次执行的总的时间,我们可以通过配置hz参数来提高扫描频率,默认情况下值为10,即每100ms扫描一次。
  2. 快速清理模式:fast模式通过方法beforeSleep执行清理方法

快速清理模式

Fast模式通过于beforeSleep方法执行,当满足以下条件之一时,将通过Fast模式清理内存中的过期key,降低内存压力

  1. 上一次任务不是因为超时而退出,且已过期键占比近似值server.stat_expired_stale_perc小于可容忍上限config_cycle_acceptable_stale
  2. 距离上一次FAST时间,未超过指定的时间间隔,默认是2000us

定期清理

其中,随机删除的方法是activeExpireCycle,位于文件expire.c中,随机抽取的key的数量由变量config_keys_per_loop定义,它是ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP(它本身的值为20),通过特定公式计算得出,计算公式如下:

config_keys_per_loop = ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP +
                       ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP/4*effort

effort(值的范围是0~9,默认为0)参数由active_expire_effort得到,它表示在抽取key的时候的力度,这个值越大意味着每次遍历时抽取的key的数量就越多,同样的,性能损耗也就越大。

大致流程如下所示:

  1. 从头遍历所有的库
  2. 对每一个库先抽取桶,再对桶中的key进行遍历,如果key已经过期,那么删除它,同时计数。
  3. 如果时间已经超过限制,直接结束循环
  4. 如果循环的key的数量已经超过了限制,那么继续抽取当前库,直至时间达到限制或者过期率降低至期望以下

这个特定值表示在当前数据库中需要抽取的key的数量,config_cycle_acceptable_stale的值的大小由公式:

config_cycle_acceptable_stale=ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE - effort

计算得到,其中ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE的值为10,在代码中写死。

再根据下面的公式:

do {
  				//抽取key并删除过期key
           ...
        } while (sampled == 0 ||
                 (expired*100/sampled) > config_cycle_acceptable_stale);

我们不难发现,默认情况下,当实际抽取的桶的数量和被释放的key的数量的比值大于10的时候,就会认定为当前数据库的过期key的数量过多,从而触发再一次的回收。

这里的回收也并不是没有时间限制的,为了减少对用户的影响,当执行时间超过某个特定值时,会直接退出本次收集,这个时间由参数timelimit确定,这个参数的计算公式为config_cycle_slow_time_perc*1000000/server.hz/100,单位为us,其中hz由参数CONFIG_DEFAULT_HZ和配置文件共同确定,默认为10,config_cycle_slow_time_perc由下面的公式确定,

config_cycle_slow_time_perc = ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC +
                              2*effort,

ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC参数用于确定最大的CPU占比,默认为25

即默认情况下,一次定期扫描不允许超过25ms。

内存已满时淘汰key以回收内存

我们知道,如果对redis需要的内存预估错误,那么需要写入的时候就有可能会将redis的内存打满,我们可以通过配置特定的策略来处理当redis内存满的时候应当怎么做。

可选的策略有以下几种:

  • noeviction(默认策略):对于写请求不再提供服务,直接返回错误(DEL请求和部分特殊请求除外),试图写入新数据时会产生OOM异常

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

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

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

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

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

  • volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;

  • allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。

不淘汰-只产生异常

这是默认的选项,使用这种策略意味着不会对任意的key进行淘汰,但由于内存已满也无法再写入任何新的数据。

看上去会产生一定的异常,但是它有它优势,它的优势在于,不会产生任何意料之外的淘汰操作,从而避免热点key意外被淘汰,导致出现缓存击穿的现象。

淘汰key回收内存

对于需要淘汰的数据,我们可以从数据范围和筛选数据的方法方面选择需要淘汰的key进行淘汰:

数据范围讲,根据key是否带时间戳可以分为:

  1. 从设置了过期时间的key中挑选需要淘汰的key
  2. 从所有的key中挑选需要淘汰的key

筛选方法方面,根据算法的不同可以分为:

  1. 随机淘汰key
  2. 通过LRU算法进行淘汰
  3. 通过LFU算法进行淘汰

需要注意的是,不论是哪种淘汰方法,需要淘汰的key的数量都是可以配置的

随机淘汰没什么好说的,就是从数据范围中随机取出N个key淘汰掉

近似LRU

严格的LRU算法是需要将所有需要管控的数据都纳入到一个链表中,每当有访问的就移动到最前面,总是淘汰链表末尾的数据

image-20230720174313605

但是传统的LRU算法也有它的问题:

  • 需要用链表管理所有的缓存数据,这会带来额外的空间开销;
  • 当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。

redis选用近似的LRU算法,放弃了链表式的结构以此来最大限度的减少算法执行过程中的性能损耗。

redis对象内部维护了一个特殊的字段,针对不同的回收策略会有不同的用处,对于LRU来讲,它是一个24位的时钟,记录对象保存到redis的时间,对于LFU算法而言,它是用来保存访问时间以及频率的字段。

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    int refcount;
    void *ptr;
} robj;

因为不维护字段,所以对于需要淘汰的key的选拔是通过在数据范围内,随机筛选出N(可配置)个key,选择最旧的一个数据淘汰掉,这样做的优势在于能够节省在损耗过程中的性能损耗。

对比严格LRU算法,它的优势在于

  • 节省了保存链表的空间
  • 降低了频繁移动链表节点带来的CPU的损耗

它的问题在于,近似始终是近似,在效果上必然不如严格LRU算法那么精确。

如下图所示,在选取数为10的情况下,可以看到是大多数古老的key是已经被淘汰的,同时对新的key影响也比较低,与严格LRU效果已经接近,已经足够满足我们的需求。

image-20220114162408978

近似LFU算法也有LFU算法的固有问题,就是LFU实际上只有时间的概念,它是没有热度这个说法的,也就是说,我们在淘汰过程中很可能会淘汰掉一个热点key,或者在大批新的数据写入时,会影响对旧数据的判断。

LFU算法

我们之前提到,redis的对象中维护了一个字段,在回收算法是LFU的时候,这个字段的作用在于记录最近访问时间以及频率,这是一个24位的字段,它的前16位用于记录时间,后8位则记录频率。

img

增长策略

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增长的概率也就越小。

降低策略

既然与热度相关,那么自然就会有降低的策略,下降的周期也可以通过参数配置,配置的含义在于每过多久下降1,这样下次计算时可以通过时间差直接计算出当前的key下降后的热度。

下面是LFU算法的热度增长代码:

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;

  }

可以看到,对于任意的key,它的热度越高,计算得到的允许热度上涨的p就越小,热度上升就需要更多次的访问,同时还可以通过lfu_log_factor参数来控制增长速率,它的值越大,增长速度就越小。

Je suppose que tu aimes

Origine juejin.im/post/7257803905232650300
conseillé
Classement