redis的内存管理机制——过期键删除与内存淘汰

828 阅读6分钟

image.png

本文内存管理的内容包括:

  • 过期键的懒性删除和过期删除
  • 内存溢出控制策略。

redis过期键删除

redis过期键删除哦你给过两种方式:

  1. 惰性删除,过期键的惰性删除策略由expireIfNeeded函数实现,所有要操作数据库key的Redis命令在执行之前都会调用expireIfNeeded函数对输入键进行检查

  2. 定时删除,定时函数serverCron() 调用 databasesCron函数

expireIfNeeded

  • 功能:检查键是否过期,如果过期就进入删除流程。为了保证一致性,如果是slave节点不删除。
  • 返回:0是未过期,1是已过期
int expireIfNeeded(redisDb *db, robj *key) {
    /* 检查key是否过期 */
    if (!keyIsExpired(db,key)) return 0;
    if (checkClientPauseTimeoutAndReturnIfPaused()) return 1;

    server.stat_expiredkeys++;
    
    /* 传播过期消息到 slave 和 AOF */
    propagateExpire(db,key,server.lazyfree_lazy_expire);
    
    /* redis事件通知机制,这里不关心,以后单开文章 */
    notifyKeyspaceEvent(NOTIFY_EXPIRED,"expired",key,db->id);
    
    /* 删除调用dbAsyncDelete 或者 dbSyncDelete*/
    /* 这两个函数后面会讲 */
    int retval = server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
                                               dbSyncDelete(db,key);
    if (retval) signalModifiedKey(NULL,db,key);
    return retval;
}

expireIfNeeded的步骤就是

  • 检查key是否过期,通过keyIsExpired函数实现,
  • 将过期消息传播过期消息到 slave 和 AOF
  • 调用dbAsyncDelete 或者 dbSyncDelete删除过期kv,dbAsyncDelete根据被删除的val大小判断同步还是交给bio线程异步删除

下面看下删除函数dbAsyncDelete

dbAsyncDelete

int dbAsyncDelete(redisDb *db, robj *key) {
    // 从过期表中删除kv
    if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);

    // 解绑
    dictEntry *de = dictUnlink(db->dict,key->ptr);
    
    // 根据大小判断同步还是异步
    if (de) {
        robj *val = dictGetVal(de);
        moduleNotifyKeyUnlink(key,val);
        // 获取大小
        size_t free_effort = lazyfreeGetFreeEffort(key,val);

        if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) {
            atomicIncr(lazyfree_objects,1);
            bioCreateLazyFreeJob(lazyfreeFreeObject,1, val);
            // 同步提交后,把de的entry设为NULL
            dictSetVal(db->dict,de,NULL);
        }
    }

    /* 同步释放
     *  */
    if (de) {
        dictFreeUnlinkedEntry(db->dict,de);
        if (server.cluster_enabled) slotToKeyDel(key->ptr);
        return 1;
    } else {
        return 0;
    }
}

redis 数据库,数据内容和过期时间是分开保存。

  • dict 保存了键值对本身
  • expires 保存了键值对应的过期时间。

dbAsyncDelete函数首先调用dictDeleteexpires中删除kv的过期时间。然后用dictUnlink函数从dict表中删除,但是却不释放 key、val和对应的表entry对象。接着,通过lazyfreeGetFreeEffort函数获取删除的val的大小,这个大小没有具体的单位,比如链表就返回链表的长度,每个数据结构有自己的计算方法,算出来的只是个估计值,这个值大于LAZYFREE_THRESHOLD也就是64的时候就通过bioCreateLazyFreeJob方法把删除工作提交给bio线程,lazyfreeFreeObject就是提交给线程的删除函数,1和val是参数。提交后, 同步提交后,把val设为NULL,下面就是同步释放的过程了,直接调用dictFreeUnlinkedEntry删除并释放key和value,如果经过了上一步异步,val是NULL了,就只释放key

这里似乎解释了前面的一个疑问,为什么dict已经有删除函数了,还要提供解绑的函数dictFreeUnlinkedEntry,答案就是有时候要解绑给异步释放

redis的内存溢出控制策略

redis里有个配置项maxmemory,它限制了最大可用内存,设置这个选项是为了:

  • 当内存超出时,要使用LRU等策略释放空间
  • 设置最大内存以防止使用内存超出物理内存,导致OOM

内存消耗的分类

redis内存分为以下几类:

  • 自身内存:可忽略不计
  • 对象内存:存储kv的大小
  • 缓冲内存:
    • AOF缓冲区:
    • 客户端缓冲:所有接入到 Redis 服务器 TCP 连接的输入输出缓冲。
    • 复制积压缓冲:可重用的固定大小缓冲区,实现部分复制功能,repl-backlog-size参数控制
  • 内存碎片:redis默认内存分配器采用jemalloc(可选的分配器还有:glibc、tcmalloc) Redis 正常碎片率一般在 1.03 左右。以下场景容易出现高内存碎片问题:
    • 频繁做更新操作,例如频繁对已经存在的键执行 append、setrange 等更新操作。
    • 大量过期键删除,键对象过期删除后,释放的空间无法得到重复利用,导致碎片率上升。
  • 子进程消耗内存:AOF或者RDB的时候需要fork一个子进程,理论上需要一倍的物理内存来完成相应的操作。但是 Linux 具有写时复制技术 (copy-on-write),父子进程会共享相同的物理内存页,当父进程处理写请求时会对需要修改的页复制出一份副本完成写操作,而子进程依然读取 fork 时整个父进程的内存快照。

copy-on-write:fork进程时只复制页表,某一页发生改变时,才真正进行页的复制。

内存淘汰策略

配置maxmemory-policy 设置了内存淘汰策略,最常用的是allkeys-lru 即,针对全部key,进行lru。此外还有这些策略可以选择

  • volatile-lru:从已设置过期时间的内存数据集中挑选最近最少使用的数据 淘汰;
  • volatile-ttl: 从已设置过期时间的内存数据集中挑选即将过期的数据 淘汰;
  • volatile-random:从已设置过期时间的内存数据集中任意挑选数据 淘汰;
  • allkeys-lru:从内存数据集中挑选最近最少使用的数据 淘汰;
  • allkeys-random:从数据集中任意挑选数据 淘汰;
  • no-enviction(驱逐):禁止驱逐数据。

策略的执行

redis每次执行命令都会检查内存是否溢出

redis执行命令的函数中有这么一段,这就是用来处理内存溢出的情况

int processCommand(client *c) {
    ...
    if (server.maxmemory && !server.lua_timedout) {
        /* 这句重点 */
        int out_of_memory = (performEvictions() == EVICT_FAIL);
        
        if (server.current_client == NULL) return C_ERR;

        int reject_cmd_on_oom = is_denyoom_command;
        if (c->flags & CLIENT_MULTI &&
            c->cmd->proc != execCommand &&
            c->cmd->proc != discardCommand &&
            c->cmd->proc != resetCommand) {
            reject_cmd_on_oom = 1;
        }

        if (out_of_memory && reject_cmd_on_oom) {
            rejectCommand(c, shared.oomerr);
            return C_OK;
        }

        if (c->cmd->proc == evalCommand || c->cmd->proc == evalShaCommand) {
            server.lua_oom = out_of_memory;
        }
    }
}

这段重点就是performEvictions()函数

performEvictions

performEvictions()函数用来处理内存溢出,用策略删除kv对,使内存回到正常范围

int performEvictions(void) {
	// 计算需要释放的内存
        // mem_reported:已使用的内存
        // mem_tofree:需要释放的内存
	getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) 
	mem_freed = 0;

	// 循环直到释放出足够的空间
	while (mem_freed < (long long)mem_tofree) {
		// evictionPoolEntry用于临时存储应该被优先淘汰的数据样本
		// 这是一个数组,如果是LRU策略就按照idl时间
		struct evictionPoolEntry *pool = EvictionPoolLRU; 
		// 遍历直到找到一个合适的key来释放
		while (bestkey == NULL) {
			// 遍历每一个db,抽样填充pool
			for (i = 0; i < server.dbnum; i++) {
                db = server.db+i;
                dict = (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) ?
                        db->dict : db->expires;
                if ((keys = dictSize(dict)) != 0) {
                	// 从dict中随机选取一些key(采样)放进poll
                    evictionPoolPopulate(i, dict, db->dict, pool);
                    total_keys += keys;
                }
            }
            if (!total_keys) break; /* No keys to evict. */

            // 遍历 pool 中的记录,释放内存,同时找到besstkey
            for (k = EVPOOL_SIZE-1; k >= 0; k--) {
                if (pool[k].key == NULL) continue;
                bestdbid = pool[k].dbid;

                if (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) {
                    de = dictFind(server.db[pool[k].dbid].dict,
                        pool[k].key);
                } else {
                    de = dictFind(server.db[pool[k].dbid].expires,
                        pool[k].key);
                }

                /* Remove the entry from the pool. */
                if (pool[k].key != pool[k].cached)
                    sdsfree(pool[k].key);
                pool[k].key = NULL;
                pool[k].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... Iterate again. */
                }
            }
		}
		// 删除key在这里,看dbAsyncDelete
		if (bestkey) {
			db = server.db+bestdbid;
			robj *keyobj = createStringObject(bestkey,sdslen(bestkey));
			if (server.lazyfree_lazy_eviction)
                dbAsyncDelete(db,keyobj);
            else
                dbSyncDelete(db,keyobj);
		}
	}
}

大致步骤如下:

  • 调用getMaxmemoryState()计算需要释放多少内存,然后进入while()循环,重复以下步骤直到内存释放完
  • 构建一个evictionPoolEntry结构体对象pool,用于临时存储应该被优先淘汰的数据样本,本质是一个数组,根据不同策略对里面的数据进行排序,例如如果是LRU策略就按照idl时间排序
  • 遍历每一个db,进行抽样填充pool, evictionPoolPopulate()是抽样函数,从dict中随机选取一些key(采样)放进poll
  • 然后遍历填充完成的pool, 把pool中的内容释放,同时找到bestkey,在数据库里进行删除,删除的方式是dbAsyncDelete 或者 dbSyncDelete, 这与redis过期键删除中删除的方式一样,这两个函数已经介绍过,就不再赘述