在 Redis 源代码中,evict.c 文件主要包含了与内存回收有关的代码,负责实现 Redis 的内存驱逐机制。内存驱逐是指在 Redis 达到设置的内存上限时,通过一些策略去删除部分数据,以释放内存空间,使得 Redis 的内存占用保持在可控的范围内。
具体来说,evict.c 文件实现了 Redis 的内存驱逐策略,其中包括了以下主要内容:
- LRU(Least Recently Used)和 LFU(Least Frequently Used)机制:evict.c 中包含了关于 LRU 和 LFU 策略的实现,用于确定哪些键应该被优先删除以释放内存。
- 释放内存的具体实现: 当 Redis 内存使用超出限制时,evict.c 根据预定的策略选择要释放的键,并执行删除操作,以将内存使用降到合理的水平。
- 懒惰释放: 通过懒惰释放(lazy free)机制,evict.c 可以异步地释放内存。这样可以提高 Redis 的性能,因为内存的释放不会阻塞其他操作。
懒惰释放的工作方式如下:
当某个对象需要释放内存时,不直接调用 free 函数,而是将释放工作交给懒惰释放线程处理。
懒惰释放线程负责在后台异步执行内存释放操作。主线程不需要等待懒惰释放线程完成,可以继续处理其他请求。这种机制的好处在于,当需要释放大量内存时,可以避免阻塞主线程,从而提高 Redis 的性能和响应速度。不过,这也意味着在某些情况下,Redis 的内存占用可能会略高于实际需要释放的内存量,因为实际的释放是在懒惰释放线程中异步执行的。懒惰释放的相关部分主要涉及到 dbAsyncDelete 函数的调用,该函数用于异步删除数据库中的键。这样,即使主线程没有直接等待释放完成,懒惰释放线程会在后台逐步完成释放操作。
#define EVPOOL_SIZE 16//驱逐池的大小
#define EVPOOL_CACHED_SDS_SIZE 255//缓存的 SDS 对象的大小
struct evictionPoolEntry {
unsigned long long idle; /* Object idle time (inverse frequency for LFU) *///对象的空闲时间(对于 LFU 策略而言是逆频率)
sds key; /* Key name. *///键名
sds cached; /* Cached SDS object for key name. *///: 键名的缓存 SDS 对象。
int dbid; /* Key DB number. *///键所在的数据库编号。
};
//声明了一个静态变量 EvictionPoolLRU,它是指向 evictionPoolEntry 结构体的指针,用于存储 LRU 策略下的驱逐池条目。
static struct evictionPoolEntry *EvictionPoolLRU;
使用这个静态变量 EvictionPoolLRU来存放需要淘汰的键
淘汰方式:
LRU
LRU(Least Recently Used,最近最少使用)是一种常见的缓存淘汰策略,用于在缓存空间不足时决定哪些缓存项应该被移除以腾出空间。LRU 的基本思想是优先淘汰最久未被访问的缓存项,即最近使用时间最远的缓存项。
unsigned long long estimateObjectIdleTime(robj *o) {
/*获取当前的 LRU 时钟。LRU_CLOCK 宏是一个用于返回LRU算法使用的当前时间或计数器的函数或操作的占位符。通常,每当访问对象时,此时钟都会更新。*/
unsigned long long lruclock = LRU_CLOCK();
//如果当前的 LRU 时钟大于等于对象的上次访问时间(o->lru),则直接计算时间差,乘以 LRU_CLOCK_RESOLUTION。
if (lruclock >= o->lru) {
return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
} else {
//如果当前时钟小于对象上次访问时间,说明发生了时钟溢出,需要进行特殊处理,通过 LRU_CLOCK_MAX 来表示时钟的最大值,然后再计算时间差。
return (lruclock + (LRU_CLOCK_MAX - o->lru)) *
LRU_CLOCK_RESOLUTION;
}
}
这段代码用于计算基于近似最近最少使用(LRU)算法的 Redis 对象的估计空闲时间。LRU 算法通常用于缓存管理,以优先保留最近使用的项目并淘汰使用较少的项目。
LFU
它是根据缓存项的访问频率来进行淘汰。LFU认为访问频率较低的缓存项应该更容易被淘汰。
LFU算法通常涉及以下几个要素:
- 频率计数器: 每个缓存项关联一个计数器,用于记录该缓存项被访问的频率。每次访问该缓存项,计数器的值就会增加。
- 淘汰规则: 在需要淘汰缓存项时,选择访问频率最低的缓存项进行淘汰。这通常涉及到对计数器值进行比较。
- 计数器的更新策略: 计数器的更新策略决定了计数器如何随着时间推移而减小。这可以通过定期减小计数器的值或者根据访问频率的相对大小来调整。
LFU的优势在于它能够保留那些经常被访问但可能长时间不再被访问的缓存项。相对于LRU,LFU更注重缓存项的访问频率,而不仅仅是最近的访问时间。
uint8_t LFULogIncr(uint8_t counter) {
// 如果计数器已经达到最大值(255),则不再增加。
if (counter == 255) return 255;
// 生成一个0到1之间的随机数r。
double r = (double)rand()/RAND_MAX;
// 计算以LFU_INIT_VAL为基准的值。
double baseval = counter - LFU_INIT_VAL;
// 如果基准值小于0,将其设置为0。
if (baseval < 0) baseval = 0;
// 计算增加的概率p,其中server.lfu_log_factor是一个配置参数。
double p = 1.0/(baseval*server.lfu_log_factor+1);//p越大越容易增加,越小越容易不加。
// 如果随机数r小于概率p,增加计数器的值。
if (r < p) counter++;
return counter;
}
这个函数用于对 LFU(Least Frequently Used)计数器进行对数递增操作。LFU 计数器在 Redis 中用于衡量对象的使用频率,这个函数的目的是模拟 LFU 计数器的递增过程,使得 LFU 计数器的递增随着当前值的增大而减缓。
总的淘汰策略
/*它是 freeMemoryIfNeeded 函数的一个辅助函数,用于在每次需要过期一个键时向淘汰池(EvictionPoolLRU)中添加一些条目。*/
void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) {
int j, k, count;
dictEntry *samples[server.maxmemory_samples];
// 获取一定数量的键样本
count = dictGetSomeKeys(sampledict,samples,server.maxmemory_samples);
for (j = 0; j < count; j++) {
unsigned long long idle;
sds key;
robj *o;
dictEntry *de;
de = samples[j];
key = dictGetKey(de);
/* If the dictionary we are sampling from is not the main
* dictionary (but the expires one) we need to lookup the key
* again in the key dictionary to obtain the value object. */
// 判断 Redis 服务器的内存策略是否为 MAXMEMORY_VOLATILE_TTL
if (server.maxmemory_policy != MAXMEMORY_VOLATILE_TTL) {
/*
这段代码的目的是在内存策略不是 MAXMEMORY_VOLATILE_TTL 时,从主字典 keydict 中获取键 key 对应的值对象 o。
这样做可能是因为在处理过期键时,过期键的字典中只包含键而没有完整的字典条目。
*/
if (sampledict != keydict) de = dictFind(keydict, key);
o = dictGetVal(de);
}
/* Calculate the idle time according to the policy. This is called
* idle just because the code initially handled LRU, but is in fact
* just a score where an higher score means better candidate. */
/*
如果服务器的内存策略包含 MAXMEMORY_FLAG_LRU(即采用 LRU 策略),
则调用 estimateObjectIdleTime 函数计算键 o 的空闲时间(或者说“idle”)。
LRU 策略中,空闲时间表示键最后一次被访问的时间,越长表示越不常使用。
*/
if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
idle = estimateObjectIdleTime(o);
} else if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
/* When we use an LRU policy, we sort the keys by idle time
* so that we expire keys starting from greater idle time.
* However when the policy is an LFU one, we have a frequency
* estimation, and we want to evict keys with lower frequency
* first. So inside the pool we put objects using the inverted
* frequency subtracting the actual frequency to the maximum
* frequency of 255. */
/*
如果服务器的内存策略包含 MAXMEMORY_FLAG_LFU(即采用 LFU 策略),则调用 LFUDecrAndReturn 函数计算键 o 的 LFU 频率。
在 LFU 策略中,频率越低表示键被访问的次数越少,因此这里采用 255 减去实际频率,
以便更高的频率对应更高的分数。这样,频率低的键会被认为是更好的淘汰候选对象。
*/
idle = 255-LFUDecrAndReturn(o);
} else if (server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL) {
/* In this case the sooner the expire the better. */
/*TTL(Time To Live)策略主要用于释放已经过期的键
如果服务器的内存策略是 MAXMEMORY_VOLATILE_TTL(即采用 TTL 策略),则计算键 o 的 TTL(Time To Live)值。
在 TTL 策略中,TTL 表示键的过期时间,而这里的空闲时间计算为 ULLONG_MAX - TTL,以便越快过期的键对应更高的分数。*/
idle = ULLONG_MAX - (long)dictGetVal(de);
} else {
serverPanic("Unknown eviction policy in evictionPoolPopulate()");
}
/* Insert the element inside the pool.
* First, find the first empty bucket or the first populated
* bucket that has an idle time smaller than our idle time. */
/*这段代码是插入一个元素到淘汰池(pool)中的逻辑,根据键的空闲时间(idle)来进行排序。*/
k = 0;
/*使用循环(while)在淘汰池中找到第一个空的位置或者第一个空间被占用且空闲时间小于当前元素的位置。
这是为了找到合适的位置将新元素插入。*/
while (k < EVPOOL_SIZE &&
pool[k].key &&
pool[k].idle < idle) k++;
if (k == 0 && pool[EVPOOL_SIZE-1].key != NULL) {
/* 如果元素比我们已有的最差元素还差,并且没有空的位置,那么无法插入。继续下一轮循环。 */
/* Can't insert if the element is < the worst element we have
* and there are no empty buckets. */
continue;
} else if (k < EVPOOL_SIZE && pool[k].key == NULL) {
/* 插入到一个空的位置。插入前不需要额外的设置。 */
/* Inserting into empty position. No setup needed before insert. */
} else {
/* 在中间插入。此时 k 指向第一个大于要插入元素的元素。 */
/* Inserting in the middle. Now k points to the first element
* greater than the element to insert. */
if (pool[EVPOOL_SIZE-1].key == NULL) {
/* Free space on the right? Insert at k shifting
* all the elements from k to end to the right. */
/* 如果淘汰池的最差元素为空(即右侧有空间),则在位置 k 处插入新元素,
同时将从 k 到末尾的所有元素右移一位。*/
/* Save SDS before overwriting. */
sds cached = pool[EVPOOL_SIZE-1].cached;
memmove(pool+k+1,pool+k,
sizeof(pool[0])*(EVPOOL_SIZE-k-1));
pool[k].cached = cached;
} else {
/* 如果淘汰池的最差元素不为空(即右侧没有空间),则在位置 k-1 处插入新元素。
将所有元素从 k(包括 k)左移一位,这样我们就丢弃了空闲时间较小的元素。*/
/* No free space on right? Insert at k-1 */
k--;
/* Shift all elements on the left of k (included) to the
* left, so we discard the element with smaller idle time. */
sds cached = pool[0].cached; /* Save SDS before overwriting. */
if (pool[0].key != pool[0].cached) sdsfree(pool[0].key);
/* 将所有元素从左移到右,丢弃了最左侧的元素,这是空闲时间较小的元素。*/
memmove(pool,pool+1,sizeof(pool[0])*k);
pool[k].cached = cached;
}
}
/* Try to reuse the cached SDS string allocated in the pool entry,
* because allocating and deallocating this object is costly
* (according to the profiler, not my fantasy. Remember:
* premature optimizbla bla bla bla. */
/*
这段代码的目的是为了尽可能地重用淘汰池(pool)中缓存的 SDS(Simple Dynamic String)字符串,
以减少频繁分配和释放 SDS 对象的开销。
*/
//使用 sdslen 函数获取键 key 的长度,存储在变量 klen 中。
int klen = sdslen(key);
//如果键的长度 klen 大于淘汰池中缓存 SDS 的最大大小
if (klen > EVPOOL_CACHED_SDS_SIZE) {
//则说明键的长度太大,无法重用缓存的 SDS 对象。
在这种情况下,通过 sdsdup 函数复制一个新的 SDS 对象,存储在 pool[k].key 中。
pool[k].key = sdsdup(key);
} else {//如果键的长度 klen 不大于 EVPOOL_CACHED_SDS_SIZE,则说明可以尝试重用缓存的 SDS 对象。
//使用 memcpy 将键的内容拷贝到 pool[k].cached 中,然后通过 sdssetlen 设置 SDS 的长度。
//最终,将 pool[k].key 设置为重用的 SDS 对象。
memcpy(pool[k].cached,key,klen+1);
sdssetlen(pool[k].cached,klen);
pool[k].key = pool[k].cached;
}
pool[k].idle = idle;
pool[k].dbid = dbid;
}
}
在这段代码中还有一种策略是通过TTL模式,即只淘汰过期的键值。
这段代码中主要是为了维护静态变量 EvictionPoolLRU,来进行淘汰
最终实现代码
int freeMemoryIfNeeded(void) {
int keys_freed = 0;//记录成功释放的键的数量。
/* By default replicas should ignore maxmemory
* and just be masters exact copies. */
//如果 Redis 是从节点(replica)且配置为忽略 maxmemory 设置,则直接返回 C_OK,不执行内存释放操作。
if (server.masterhost && server.repl_slave_ignore_maxmemory) return C_OK;
/*
mem_reported:报告的内存使用量。
mem_tofree:要释放的目标内存量。
mem_freed:已经成功释放的内存量。
latency, eviction_latency, lazyfree_latency:用于测量延迟的时间戳。
delta:用于记录内存释放前后的内存变化量。
slaves:从节点数量。
result:用于记录内存释放操作的结果。
*/
size_t mem_reported, mem_tofree, mem_freed;
mstime_t latency, eviction_latency, lazyfree_latency;
long long delta;
int slaves = listLength(server.slaves);
int result = C_ERR;
/* When clients are paused the dataset should be static not just from the
* POV of clients not being able to write, but also from the POV of
* expires and evictions of keys not being performed. */
//如果客户端被暂停,直接返回 C_OK,不执行内存释放操作。
if (clientsArePaused()) return C_OK;
/*获取当前内存状态,如果内存使用在限制以下,直接返回 C_OK,不执行内存释放操作。*/
if (getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) == C_OK)
return C_OK;
mem_freed = 0;
latencyStartMonitor(latency);
if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION)
goto cant_free; /* We need to free memory, but policy forbids. */
//进入循环,直到成功释放足够的内存达到目标。
while (mem_freed < mem_tofree) {
int j, k, i;
static unsigned int next_db = 0;
//选择要释放的键的信息的变量。
sds bestkey = NULL;
int bestdbid;
redisDb *db;
dict *dict;
dictEntry *de;
//检查是否配置了 LRU、LFU 或 Volatile-TTL 中的任意一种策略。这三种策略都需要按照一定的规则选择键进行释放。
if (server.maxmemory_policy & (MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_LFU) ||
server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL)
{
//获取指向 LRU 驱逐池的指针,该池是一组用于记录待释放键的数据结构。
struct evictionPoolEntry *pool = EvictionPoolLRU;
//进入循环,该循环将选择要释放的键。循环会一直执行,直到成功选择了键。
while(bestkey == NULL) {
//total_keys 用于记录数据库中的键的总数。
unsigned long total_keys = 0, keys;
/* We don't want to make local-db choices when expiring keys,
* so to start populate the eviction pool sampling keys from
* every DB. */
/*对于每个数据库,获取键的数量,并将这些键的信息填充到驱逐池中。
这个步骤是为了从整个数据库中选择要释放的键,而不仅仅是从一个本地数据库中选择。*/
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) {
evictionPoolPopulate(i, dict, db->dict, pool);
total_keys += keys;
}
}
//如果没有可用的键,则退出循环,表示没有键可以驱逐。
if (!total_keys) break; /* No keys to evict. */
/* Go backward from best to worst element to evict. */
//从 LRU 驱逐池中最好的元素到最差的元素进行遍历。
for (k = EVPOOL_SIZE-1; k >= 0; k--) {
//如果池中的键为空,表示该位置没有元素,直接跳过。
if (pool[k].key == NULL) continue;
bestdbid = pool[k].dbid;//记录当前池中元素对应的数据库 ID。
//查找驱逐池中元素对应的键在数据库中的位置。
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. */
//如果键存在,表示该键是要释放的键。设置 bestkey 并跳出循环。如果键不存在,继续迭代下一个元素。
if (de) {
bestkey = dictGetKey(de);
break;
} else {
/* Ghost... Iterate again. */
}
}
}
}
/* volatile-random and allkeys-random policy */
/*这段代码处理了 MAXMEMORY_ALLKEYS_RANDOM 和 MAXMEMORY_VOLATILE_RANDOM 两种策略,即随机选择要驱逐的键。*/
else if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM ||
server.maxmemory_policy == MAXMEMORY_VOLATILE_RANDOM)
{
/* When evicting a random key, we try to evict a key for
* each DB, so we use the static 'next_db' variable to
* incrementally visit all DBs. */
/*对于每个数据库,通过使用静态变量 next_db 来循环访问数据库,选择一个数据库,并随机从该数据库中选择一个键。*/
for (i = 0; i < server.dbnum; i++) {
j = (++next_db) % server.dbnum;
db = server.db+j;
dict = (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM) ?
db->dict : db->expires;
if (dictSize(dict) != 0) {/*如果数据库中有键存在,选择该键作为要删除的键,并记录相关信息。*/
de = dictGetRandomKey(dict);
bestkey = dictGetKey(de);
bestdbid = j;
break;
}
}
}
/* Finally remove the selected key. */
if (bestkey) {
db = server.db+bestdbid;
/*使用 createStringObject 函数创建一个字符串对象,该对象表示即将被删除的键。*/
robj *keyobj = createStringObject(bestkey,sdslen(bestkey));
/*根据配置传播过期信息。如果启用了 server.lazyfree_lazy_eviction,则延迟传播。*/
propagateExpire(db,keyobj,server.lazyfree_lazy_eviction);
/* We compute the amount of memory freed by db*Delete() alone.
* It is possible that actually the memory needed to propagate
* the DEL in AOF and replication link is greater than the one
* we are freeing removing the key, but we can't account for
* that otherwise we would never exit the loop.
*
* AOF and Output buffer memory will be freed eventually so
* we only care about memory used by the key space. */
/*记录删除键前后系统内存使用的差值。*/
delta = (long long) zmalloc_used_memory();
latencyStartMonitor(eviction_latency);
/*如果启用了延迟驱逐,则异步删除键;否则,同步删除键。*/
if (server.lazyfree_lazy_eviction)
dbAsyncDelete(db,keyobj);
else
dbSyncDelete(db,keyobj);
//向监听键变化的客户端发送通知。
signalModifiedKey(NULL,db,keyobj);
//记录删除键后系统内存使用的变化,并更新总释放的内存量。
latencyEndMonitor(eviction_latency);
latencyAddSampleIfNeeded("eviction-del",eviction_latency);
delta -= (long long) zmalloc_used_memory();
mem_freed += delta;
//增加服务器统计信息中已驱逐键的计数。
server.stat_evictedkeys++;
//向键空间事件通知系统发送键已被驱逐的消息。
notifyKeyspaceEvent(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();//将信息发出
/* Normally our stop condition is the ability to release
* a fixed, pre-computed amount of memory. However when we
* are deleting objects in another thread, it's better to
* check, from time to time, if we already reached our target
* memory, since the "mem_freed" amount is computed only
* across the dbAsyncDelete() call, while the thread can
* release the memory all the time. */
if (server.lazyfree_lazy_eviction && !(keys_freed % 16)) {
/*如果启用了延迟驱逐,每处理16个键,检查是否满足停止条件(达到预定的释放内存目标),以便退出循环。*/
if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {
/* Let's satisfy our stop condition. */
mem_freed = mem_tofree;
}
}
} else {
goto cant_free; /* nothing to free... */
}
}
result = C_OK;
cant_free:
/* We are here if we are not able to reclaim memory. There is only one
* last thing we can try: check if the lazyfree thread has jobs in queue
* and wait... */
/*这段代码的主要目的是在无法释放内存的情况下进行最后一次尝试。
它检查懒惰释放线程是否有挂起的工作,如果有,就等待直到懒惰释放线程完成工作或者超时。*/
/*如果前面的尝试(通过删除键来释放内存)未能将内存降到目标以下,result 的值将为 C_ERR。*/
if (result != C_OK) {
latencyStartMonitor(lazyfree_latency);
/*使用 bioPendingJobsOfType(BIO_LAZY_FREE) 检查是否有懒惰释放线程的挂起工作。*/
while(bioPendingJobsOfType(BIO_LAZY_FREE)) {
/*如果有挂起的懒惰释放任务,函数会等待,通过 usleep(1000) 暂停执行 1 毫秒,并再次检查是否有懒惰释放任务。
这个过程会一直持续,直到没有懒惰释放任务或者等待超时。*/
if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {
result = C_OK;
break;
}
usleep(1000);
}
latencyEndMonitor(lazyfree_latency);//函数结束监测懒惰释放的延迟。
latencyAddSampleIfNeeded("eviction-lazyfree",lazyfree_latency);
}
latencyEndMonitor(latency);//函数结束监测整个驱逐周期的延迟。
latencyAddSampleIfNeeded("eviction-cycle",latency);
return result;
}
这段注释描述了一个定期调用的函数,该函数检查当前的 “maxmemory” 设置是否有内存可释放。如果超过了内存限制,该函数将尝试释放一些内存以返回到限制以下。
具体而言:
如果内存使用在限制以下,或者尝试释放内存成功,函数返回 C_OK。
如果内存使用超过了限制,但尝试释放的内存不足以使其返回到限制以下,函数返回 C_ERR。
这个函数的主要目的是在服务器接近或超过配置的内存限制时,通过释放一些内存来确保服务器在允许的内存范围内运行。