内存回收

Redis基于内存的存储策略,由于持久性和主从同步,一般并不建议内存上限配置过大。
在配置文件中可以通过 :

maxmemory <bytes>
例如
maxmemory 1gb

当内存达到上限,Redis提供了两大类内存回收策略:内存过期策略和内存淘汰策略

内存过期策略

通过expire命令给Redis中的key设置TTL,当key过期后访问会返回nil。

问题:Redis如何指导一个key时候过期?
在结构中使用键值key-TTL对来存储当前key的过期时间。
问题:是否TTL到期就会立即删除吗?

RedisDB结构体

Redis本身是一个典型的 key-value 存储数据库,因为此所有的 key-value 就存储在Dict结构当中,不过在database结构体中,有两个Dict:一个存储key-value,一个记录key-TTL。

typedef struct redisDb {
    dict *dict;                 /* 存放所有key及value的地方,也被称为keyspace*/
    dict *expires;              /* 存放每一个key及其对应的TTL存活时间,只包含设置了TTL的key*/
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;                     /* Database ID,0~15 */
    long long avg_ttl;          /* 记录平均TTL时长 */
    unsigned long expires_cursor; /* expire检查时在dict中抽样的索引位置. */
    list *defrag_later;         /* 等待碎片整理的key列表. */
} redisDb;

在这里插入图片描述

惰性删除

在TTL到期之后不会立即删除,而是当访问Key的时候,检查Key的存活时间,看是否过期来决定是否删除。

// 查找一个key执行写操作
robj *lookupKeyWriteWithFlags(redisDb *db, robj *key, int flags) {
    // 检查key是否过期
    expireIfNeeded(db,key);
    return lookupKey(db,key,flags);
}
// 查找一个key执行读操作
robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
    robj *val;
    // 检查key是否过期    if (expireIfNeeded(db,key) == 1) {
        // ...略
    }
    return NULL;
}

int expireIfNeeded(redisDb *db, robj *key) {
    // 判断是否过期,如果未过期直接结束并返回0
    if (!keyIsExpired(db,key)) return 0;
    // ... 略
    // 删除过期key
    deleteExpiredKeyAndPropagate(db,key);
    return 1;
}

周期删除

惰性删除的问题在于,如果一个数据一直未被访问,那么该数据一直不能被删除,所以Redis引入了周期删除。
周期删除是抽样部分的过期Key,然后执行删除,执行的周期有两种:

  • Redis会设置定时任务seveCron(),按照server.hz的频率来执行过期key清理,模式为SLOW。
  • Redis的每个事件循环前会调用beforeSleep()函数,执行过期key清理,模式为FAST。(执行时间很短)
// server.c
void initServer(void){
    // ...
    // 创建定时器,关联回调函数serverCron,处理周期取决于server.hz,默认10
    aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) 
}

// server.c
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    // 更新lruclock到当前时间,为后期的LRU和LFU做准备
    unsigned int lruclock = getLRUClock();
    atomicSet(server.lruclock,lruclock);
    // 执行database的数据清理,例如过期key处理
    databasesCron();
}

void databasesCron(void) {
    // 尝试清理部分过期key,清理模式默认为SLOW
    activeExpireCycle(
          ACTIVE_EXPIRE_CYCLE_SLOW);
}

void beforeSleep(struct aeEventLoop *eventLoop){
    // ...
    // 尝试清理部分过期key,清理模式默认为FAST
    activeExpireCycle(
         ACTIVE_EXPIRE_CYCLE_FAST);
}

Slow模式:会耗时几十毫秒。低频长时间的清理。
Fast模式:几十微秒。高频短时间的清理。
在这里插入图片描述
两者对比:SLOW模式和FAST模式

模式执行频率耗时限制触发条件抽样规则
SLOWserver.hz决定(默认10次/秒,周期100ms)≤25ms(周期的25%)每次周期执行每轮抽取20个key,若未超时且过期比例>10%则继续抽样
FASTbeforeSleep()调用,间隔≥2ms≤1ms过期key比例<10%时不执行每轮抽取20个key,若未超时且过期比例>10%则继续抽样
  • SLOW模式:全局周期性扫描,确保长期稳定性,允许较长的单次耗时。 会逐个遍历DB,并且逐个遍历DB中的Bucket,抽取20个key判断是否过期。
  • FAST模式:高频轻量扫描,减少延迟影响,但限制更严格的单次耗时。
  • 共同逻辑:均采用分桶抽样,动态判断是否继续清理(基于过期比例和时间上限)。

总结

RedisKey的TTL记录方式:在RedisDB中通过一个Dict记录每个Key的TTL时间。
过期Key的删除策略:

  • 惰性删除:每次查找Key时判断是否过期,如果过期删除
  • 定期清理:定期抽样部分Key,判断是否过期,如果过期就删除

定期清理的两种方式:

  • SLOW模式:执行频率默认为10,每次不超过25ms;
  • FAST模式:执行频率不固定,但是两次间隔不低于2ms,每次耗时不超过1ms;执行时机是在每次事件循环处理完就绪时间之后,在进入下一次epoll_wait之前执行。也就是在beforeSleep回调函数中执行FAST模式。

内存淘汰策略

当Redis内存使用达到设置阈值时,Redis主动挑选部分Key删除以释放更多内存的流程。
那么Redis会在什么时机来执行内存淘汰?只要是Redis被访问,那么就会检查当前内存并在对应条件下执行内存淘汰。
Redis会在处理客户端命令的方法 processCommand() 中尝试做内存淘汰:

int processCommand(client *c) {
    // 如果服务器设置了server.maxmemory属性,并且没有执行lua脚本
    if (server.maxmemory && !server.lua_timedout) {
        // 尝试进行内存淘汰performEvictions
        int out_of_memory = (performEvictions() == EVICT_FAIL);
        // ...
        if (out_of_memory && reject_cmd_on_oom) {
            rejectCommand(c, shared.oomerr);
            return C_OK;
        }
        // ....
    }
}

Redis支持的8种淘汰策略

  • noeviction:不淘汰任何Key,内存满时不允许写入新数据,默认策略
  • volatile-ttl:比较所有设置了TTL的Key,TTL越小越先被淘汰
  • allkeys-random:对全体Key,随机进行淘汰。直接在DB->dict中随机挑选。
  • volatile-random:随机淘汰设置了TTL的Key,直接在DB->expires中随机挑选。
  • allkeys-lru:对全体Key,基于LRU算法进行淘汰
  • volatile-lrl:对设置了TTL,基于LRU算法淘汰
  • allkeys-lfu:对全体Key,基于LFU算法淘汰
  • volatile-lfu:对设置了TTL的Key,基于LFU算法淘汰
    两种淘汰算法:
    LRU(Least Recently Used):最少最近使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
    LFU(Least Frequently Used):最少频率使用。会统计每个Key的访问频率,值越小优先级越高。

那么就需要统计当前Key的最近一次访问时间以及当前Key被访问了多少次。这些信息都被封装在RedisObject结构当中;

#define LRU_BITS 24
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). 
                            * LRU:以秒为单位记录最近一次访问时间,长度24bit
			                * LFU:高16位以分钟为单位记录最近一次访问时间,低8位记录逻辑访问次数
			                */
    int refcount;
    void *ptr;
} robj;

其中结构体中lru属性是一个24位的值,其中在LRU中是以秒为单位记录最近一次访问时间,在LFU中,高16为是以分钟为单位记录最近一次访问时间,低8为记录逻辑访问次数。
逻辑访问次数的逻辑,使用一个概率模型来统计次数,思想为确定一个[0,p)的区间,如果随机数落在了这个区间里,那么访问次数+1,否则不统计,并且[0,p)这个区间是随着访问次数的增加,区间在不断缩小,那么访问次数越高,统计次数越难被+1,访问次数越低越容易被+1。
逻辑访问次数的具体运算为:

evict.c
#define RAND_MAX 0x7fff
#define LFU_INIT_VAL 5
/* Logarithmically increment a counter. The greater is the current counter value
 * the less likely is that it gets really implemented. Saturate it at 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); // lfu_log_factor默认是10
    if (r < p) counter++;
    return counter;
}

执行流程:performEvictions
在这里插入图片描述
当内存淘汰时,使用LRU或者LFU算法,这样需要遍历所有的key,但是这种方式性能太差,但是Redis选择了部分抽样的方式,其准备一个淘汰池eviction_pool,看流程图中,随机挑选maxMeemory_samples数量的key,并且判断内存策略。
那么会统一淘汰池的淘汰策略:池子中值按照某种规则升序排列,并且按照这个排序的值的最大值(倒序删除)进行淘汰。
所以不同算法的排序规则都不同:
用maxTTL -TTL 值
用now - LRU 值
用255 - LFU 值

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐