Redis 的内存淘汰
本文记录了 Redis
的内存淘汰策略,并介绍了其中 LRU
和 LFU
算法实现相比于普通版本的优化思路,最后简单探究了 Redis
关于内存淘汰的具体实现细节,重点关注 Redis
是如何选取和评估哪些 key
是更应该优先被淘汰的 key
。
在探究的过程中,再次体会到 Chat GPT 带来的便捷和它在细微之处的胡说八道,以及网上的资料很有帮助但是通过阅读源码加以验证同样重要。
前置补充说明:
- 以下提到的代码文件可在 GitHub 的 redis 代码库中搜索。
- 引用代码的分支版本为
4.0
。 - 阅读代码并不需要对
C
语言有多深入的了解,也不需要配置相关环境,通过在Github
代码库中搜索符号跳转即可完成。
Redis 的内存淘汰策略
Redis 的内存淘汰策略可以通过配置项 maxmemory-policy
设置,最大内存阈值可以通过配置项 maxmemory
设置:
volatile-lru
:从设置了过期时间的数据集中挑选最近最少使用的数据进行淘汰。allkeys-lru
:从所有数据集中挑选最近最少使用的数据进行淘汰。volatile-lfu
:从设置了过期时间的数据集中挑选最近最不经常使用的数据进行淘汰。allkeys-lfu
:从所有数据集中挑选最近最不经常使用的数据进行淘汰。volatile-random
:从设置了过期时间的数据集中随机挑选数据进行淘汰。allkeys-random
:从所有数据集中随机挑选数据进行淘汰。volatile-ttl
:从设置了过期时间的数据集中随机最近要过期的数据进行淘汰。noeviction
(默认):不淘汰数据,当内存不足时,新写入操作将报错。
根据面向的数据集分为两类,设置了过期时间的数据集指 server.db[i].expires
,所有数据集指 server.db[i].dict
。两者都是 dict
类型,在后续的代码分析中,我们会看到 Redis
根据策略是 volatile
还是 allkeys
类型选择引用哪一个 dict
。
根据算法类型分为五类,LRU
、LFU
、Random
、TTL
和 NO
。
LFU
算法是在4.0
版本后新增。其实截至目前(2024年),Redis
的版本已经来到了8.0
,接触到4.0
以前的版本的可能性已经很低了。
关于内存淘汰策略的最新定义可以在 config.c
中查看:
1 | configEnum maxmemory_policy_enum[] = { |
上述常量定义在 server.h
中:
1 | /* Redis maxmemory strategies. Instead of using just incremental number |
由自身经验或注释均可知,Redis
没有使用增量数字来定义这些常量,而是使用了一组标志,以便更快地测试多个策略的某些共同属性。在后续的代码分析中,我们会看到 Redis
是如何通过位运算判断内存淘汰策略是否具有某一属性,比如是否属于 LRU
类型就可以通过 server.maxmemory_policy & MAXMEMORY_FLAG_LRU
来判断。
Redis 的内存淘汰优化
和普通版本的 LRU
以及 LFU
算法实现并不完全相同,Redis
的 LRU
和 LFU
做了一些“优化”,主要目的是降低内存和时间的开销。
近似 LRU
代码注释中使用了 approximated LRU algorithm 作为表述。
Redis
的 LRU
并非严格的 LRU
,而是使用了“近似 LRU
”。在具体实现中,Redis
为了节省内存,并没有维护一个完整的 LRU
链表,而是为每个 key
维护一个“最后访问时间”,然后通过采样机制检查一部分 key
,在通过筛选获得的候选 key
中选出其中最近最少使用的 key
进行淘汰。
- 访问时间戳:
Redis
为每个键维护一个“最后访问时间”,用于实现LRU
。 - 采样机制:当已用内存超出
maxmemory
时,Redis
会通过采样机制获取一部分key
加以筛选,然后在通过筛选获得的候选key
中选取“最后访问时间”最久远的key
进行淘汰。这个采样机制降低了内存的开销,也减少了计算的复杂度。 - 采样精度:用户可以通过配置参数
maxmemory-samples
调整采样数量来影响采样的精度。采样数量越多,近似LRU
越接近于真实的LRU
(回头再看这句陈述,个人并不能严谨的证明)。
事实上,
“最后访问时间”并不是简单地直接使用我们常用的时间戳,而是为了节省内存对时间戳做了点小处理。Redis
的采样机制也不是简单地在每次需要进行内存淘汰时获取一部分key
,然后从这部分key
中选择要淘汰的目标。如果是这样,并不能选出更应该优先被淘汰的key
。
回头再看“采样数量越多,越接近于真实的LRU
”这句话,个人虽然予以保留,但心里是存疑的,个人只能确认增加采样的数量在一定程度上可以加快接近真实 LRU 的速度。
近似 LFU
Redis
的 LFU
同样并非严格的 LFU
,也是使用了“近似 LFU
”。Redis
在使用 LFU
策略时,会为每个 key
维护一个“频率计数器”,然后通过和 LRU
相同的采样机制,在通过筛选获得的候选 key
中选出其中最近最不常使用的 key
进行淘汰,若访问频率相同,则选择最近最少使用的 key
。
- 计数器设计:
Redis
为每个key
维护一个8
位的“频率计数器”,其取值范围是0-255
,用于统计访问频率。注意:这个“频率计数器”不直接记录访问次数,而是记录访问次数的对数logc
(logistic counter)。 - 随时间衰减:
Redis
的LFU
还使用了频率衰减机制,以避免陈旧的高频key
长期占据内存。在使用LFU
策略时,Redis
不仅为每个key
维护一个“频率计数器”,还会维护一个ldt
(last decrement time),用于记录上一次logc
的更新时间。Redis
会根据当前的时间和ldt
的差值以及配置的lfu-decay-time
对logc
进行衰减。 - 衰减时间:用户可以通过配置项
lfu-decay-time
控制衰减的速率,默认是1
。 - 采样精度:和
LRU
一样,用户可以通过配置参数maxmemory-samples
调整采样数量来影响采样的精度。事实上LRU
、LFU
和TTL
共用了同一个采样机制。
事实上,
logc
并不是直接对访问次数求对数而得,因为求对数是一个较为复杂的操作。Redis
通过加权衰减机制模拟对数增长,在访问时它会根据logc
的数值决定增加“频率计数器”的概率,公式可以简化为:
真正的公式有少许调整,我们会在后续代码分析中进一步探究。这个公式使得“频率计数器”的增长是非线性的——计数越高,增加的概率越小,从而避免计数器快速增长导致溢出。对于高频访问的key
,“频率计数器”仍能达到较高的值;对于低频访问的key
,“频率计数器”增长缓慢,在内存淘汰时也更容易被淘汰。
关于“随时间衰减”机制,其实个人在刚开始对具体细节就有些好奇,对一些不够清晰的表述也有些困惑,而探究的过程也确实“一波三折”。有些资料将其称为“定期衰减”,个人感觉有点容易让人误解,随意取了一个名字。
Redis 的内存淘汰实现细节
在了解
Redis
内存淘汰算法的优化思路时,情不自禁会设想如何处理脑海中浮现的一些问题。有时候并不是不知道“看了源代码就知道了”,而是对不熟悉的项目可能不知道从何开始看,以至于“开始”成了最难的一个步骤。
freeMemoryIfNeeded
Redis
处理内存淘汰的过程是定义在 evict.c
中的 freeMemoryIfNeeded
方法,它会先判断是否真正需要进行内存淘汰,如果需要则根据设置的内存淘汰策略执行相应的操作。
- 内存淘汰前判断和处理。
- 比较已用内存和
maxmemory
判断是否需要进行内存淘汰(由此可见一些配置项会被转换为server
的属性在代码中使用)。1
2
3
4/* Check if we are over the memory usage limit. If we are not, no need
* to subtract the slaves output buffers. We can just return ASAP. */
mem_reported = zmalloc_used_memory();
if (mem_reported <= server.maxmemory) return C_OK; - 在真正淘汰
key
前会先尝试释放主从同步和AOF
的缓冲区占用的内存(由此可见这部分的内存也被计算在已用内存中,也就是说已用内存不单单只包含存储数据占用的内存),如果在释放后内存已经够用,则直接返回。1
2
3
4
5
6
7
8/* Remove the size of slaves output buffers and AOF buffer from the
* count of used memory. */
mem_used = mem_reported;
size_t overhead = freeMemoryGetNotCountedMemory();
mem_used = (mem_used > overhead) ? mem_used-overhead : 0;
/* Check if we are still over the memory limit. */
if (mem_used <= server.maxmemory) return C_OK; - 计算需要释放的内存(按这个计算方式,释放到
maxmemory
即可写入数据?那不是有机会在最后放入一个超大key
从而使已用内存超过阈值很多?)。1
2mem_tofree = mem_used - server.maxmemory;
mem_freed = 0;
- 比较已用内存和
- 根据设置的内存淘汰策略,执行相应的操作。
- 如果策略设置为
noeviction
,则直接跳转到不能释放内存的处理逻辑。在1
2if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION)
goto cant_free;cant_free
代码块中,如果异步释放内存的线程正在执行任务,则等待(对于 if 判断条件不理解)。1
2
3
4
5
6
7
8
9
10cant_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... */
while(bioPendingJobsOfType(BIO_LAZY_FREE)) {
if (((mem_reported - zmalloc_used_memory()) + mem_freed) >= mem_tofree)
break;
usleep(1000);
}
return C_ERR; - 否则,循环“选取“最佳”的淘汰目标
key
然后释放内存”直到释放的内存大于需要释放的内存。在1
2while (mem_freed < mem_tofree) {
}while
循环的内部,主要分为两个步骤:
1. 选取“最佳”的淘汰目标key
2. 释放内存
- 如果策略设置为
选取“最佳”的淘汰目标 key
选取“最佳”的淘汰目标 key
的处理分为两种情形:
- 在使用
LRU
、LFU
和TTL
策略时,需要通过采样机制 - 在使用
Random
策略时,则较为简单
相关的分支判断代码如下,判断过程通过位运算提高效率:
1 | if (server.maxmemory_policy & (MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_LFU) || |
在使用 Random 策略时选择 key
Redis
在使用 Random
策略时选择 key
的过程较为简单,所以我们先看这部分。
1 | /* When evicting a random key, we try to evict a key for |
Redis
首先使用一个静态全局的变量next_db
用于遍历所有DB
。- 然后根据是否为
allkeys
类型的策略,选择不同的dict
。通过指针运算,选择DB
和dict
的代码显得很优雅。 dictGetRandomKey
方法定义在dict.c
中,顾名思义,用于从哈希表dict
中获得一个随机的条目dictEntry
。
db->dict
是主字典,存储了key-value
;db->expires
存储了key-expire_time
。
dictGetRandomKey
方法并不复杂,即使不了解“重哈希”的实现细节,也很容易理解,主要分为两个步骤:
- 用随机数作为数组索引选择一个非空
bucket
。 - 在选择到一个非空
bucket
后,就得到了存储在那的链表。遍历并计算链表的大小,并再次用一个随机数选择一个dictEntry
。
1 | dictEntry *dictGetRandomKey(dict *d) |
dictSize
方法定义在 dict.h
中,可以快速获取 dict
的大小。
1 |
ChatGPT
的胡言乱语中曾提到Redis
需要遍历所有的key
进行统计才可以知道key
的数量,因此在使用Random
策略进行内存淘汰时需要注意效率问题。不知道这个说法的诞生是否是因为考虑到获取随机的一个key
需要遍历链表统计数量,而如果链表过长,可能会影响性能。
在使用 LRU、LFU 和 TTL 策略时选择 key
- 采样机制的核心是一个保存了“更应该优先被淘汰”的
key
的候选池pool
,默认大小为16
。1
struct evictionPoolEntry *pool = EvictionPoolLRU;
Redis
遍历每个非空DB
,从中获取一部分key
尝试填充到pool
中。1
2
3
4
5
6
7
8
9
10
11
12
13
14/* 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) {
// 填充 pool
evictionPoolPopulate(i, dict, db->dict, pool);
total_keys += keys;
}
}
if (!total_keys) break; /* No keys to evict. */- 填充后的
pool
中的key
已经按“更不应该优先被淘汰”到“更应该优先被淘汰”的顺序排序,在pool
中从“最优先被淘汰”开始选择,直到选择到一个存在的key
。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31/* Go backward from best to worst element to evict. */
for (k = EVPOOL_SIZE-1; k >= 0; k--) {
// 如果 key 不存在,跳过(pool 可能没有满)
if (pool[k].key == NULL) continue;
bestdbid = pool[k].dbid;
// 根据策略的不同,从对应的 dict 中获取 dictEntry
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) {
// 如果 key 存在
bestkey = dictGetKey(de);
break;
} else {
// 如果 key 不存在,可能已经过期或删除了?
/* Ghost... Iterate again. */
}
} - 若在
pool
中找不到可以淘汰的key
,会循环上述过程直到选到一个可以淘汰的key
为止1
2while(bestkey == NULL) {
}
填充 EvictionPoolLRU
填充 EvictionPoolLRU
(pool
) 的过程,就是所谓采样的过程,LRU
、LFU
和 TTL
共享了这部分处理逻辑。之所以命名中携带了 LRU
,是因为这部分代码原先就是为了处理 LRU
而写的。
Redis
的代码中也会留下这种不修正命名的历史代码呢。代码洁癖既有好的一面,也有不好的一面,得取舍。
- 首先通过
dictGetSomeKeys
获取(最多)maxmemory_samples
个key
,该方法并不保证返回指定个数的结果。1
2
3int j, k, count;
dictEntry *samples[server.maxmemory_samples];
count = dictGetSomeKeys(sampledict,samples,server.maxmemory_samples); - 循环处理实际获得的
dictEntry
,将它们与之前留在pool
中的key
进行比较,留下更应该优先被淘汰的key
1
2for (j = 0; j < count; j++) {
}- 获取
key
以及视情况从主字典获取value
。这是因为db.expires
中存储的value
是过期时间,在使用TTL
类型的策略时,使用过期时间就足以实现算法;在使用非TTL
策略时,需要用到主字典存储的value
对象头中的lru
字段。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17unsigned long long idle;
sds key;
robj *o;
dictEntry *de;
de = samples[j];
// 获取 key
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. */
if (server.maxmemory_policy != MAXMEMORY_VOLATILE_TTL) {
// 非 TTL 策略中,需要从主字典中获取 value
if (sampledict != keydict) de = dictFind(keydict, key);
o = dictGetVal(de);
} - 计算
idle
作为评估key
是否应该优先被淘汰的“得分”,分数越高表示越应该优先被淘汰。之所以叫做idle
,也是因为这部分代码原先就是为了处理LRU
而写的。idle
在使用不同的内存淘汰策略时有不同的计算方式。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23/* 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. */
if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
// 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. */
// LFU
idle = 255-LFUDecrAndReturn(o);
} else if (server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL) {
/* In this case the sooner the expire the better. */
// TTL
idle = ULLONG_MAX - (long)dictGetVal(de);
} else {
serverPanic("Unknown eviction policy in evictionPoolPopulate()");
} - 将当前
key
的“得分”和pool
中已存在的key
的得分进行比较,判断它是否应该被放入pool
中,如果需要则做些前置处理。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41/* 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. */
k = 0;
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. */
// 如果 key 的得分低于 pool 中最差的 key,则跳过
continue;
} else if (k < EVPOOL_SIZE && pool[k].key == NULL) {
/* Inserting into empty position. No setup needed before insert. */
// 如果目标位置为空,无需前置处理
} else {
/* 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. */
/* Save SDS before overwriting. */
// 如果还有空位置,则将剩余的 key 右移一个位置
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 {
/* No free space on right? Insert at k-1 */
// 如果没有空位置,只需要淘汰掉最低分,将跳过的 key 左移一个位置
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;
}
} - 将评估为更应该优先被淘汰的
key
放入pool
的相应位置1
2
3
4
5
6
7
8
9
10
11
12
13
14/* 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. */
int klen = sdslen(key);
if (klen > EVPOOL_CACHED_SDS_SIZE) {
pool[k].key = sdsdup(key);
} else {
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;
- 获取
总的来说,Redis
维护了一个保存了更应该优先被淘汰的 key
的 pool
(类似于排行榜前 100),大小为 16
;每次采样,从每个非空 DB
中获取最多 maxmemory_samples
个 key
,根据当前使用的内存淘汰策略对其进行评估;将当前 key
的评估结果和原先留在 pool
中的 key
的进行比较,留下更应该优先被淘汰的 key
;随着内存淘汰的不断进行,pool
中的 key
将越来越具有“代表性”。
dictGetSomeKeys
像裹脚布一样,跳过算了。。。
淘汰 key
在选取到“最佳”的淘汰目标 key
后,释放内存。
无话可说,主要是对于内存分配和释放的代码,个人也没深入阅读。
1 | if (bestkey) { |
如何评估“更应该优先被淘汰”
在这部分我们会介绍 Redis
具体如何评估一个 key
更应该优先被淘汰。
Redis 的对象头
我们先看 Redis
的对象头,对象头中存储了评估所需要的信息,对象头的定义在 server.h
中:
1 |
|
重点关注 lru
字段,该字段为 24
位,在使用 LRU
策略时,存储 server.lruclock
;在使用 LFU
策略时,存储两个值,分别是 ldt
和 logc
。
在每次访问 key
时,Redis
都会更新该字段,相关处理逻辑在 db.c
的 lookupKey
方法中:
1 | robj *lookupKey(redisDb *db, robj *key, int flags, dictEntry **deref) { |
在使用 LRU 策略时的评估机制
在使用 LRU
策略时,每次访问 key
,都会更新 lru
字段为 LRU_CLOCK
方法的返回值。如果 Redis
刷新 lruclock
的频率高于一定程度,则获取缓存的 lru_clock
;否则获取系统时间计算 lruclock
。
由于这个分辨率(resolution)的概念有点抽象,感觉描述起来有点奇怪,但意会一下并不困难。我们可以先往下看看
lruclock
的定义和更新。
1 | /* This function is used to obtain the current LRU clock. |
lruclock 的定义和更新
在 Redis
中,server.lruclock
是用于 LRU
的时钟,我们通过它来计算 key
的空闲时间 idle
。lruclock
并不直接使用时间戳,为了节省内存,它使用了秒级时间戳对常量 LRU_CLOCK_MAX
取余的结果,这样就可以只用 24
位存储。计算 lruclock
的方法 getLRUClock
定义在 evict.c
中:
1 | /* Return the LRU clock, based on the clock resolution. This is a time |
相关常量定义在 server.h
中:
1 |
LRU_CLOCK_RESOLUTION
表示时钟分辨率(感觉这个概念有点抽象,好像在不同代码中有多个概念重叠在里面了,不过大概明白它的用处)。- 最大值
LRU_CLOCK_MAX
就是。 lruclock
是一个24
位表示的整数,大约194
天“清零”一次。
server.lruclock
是 lruclock
的缓存,会定期更新。相关处理逻辑在 server.c
的 serverCron
防范中,该方法中的操作每秒钟执行 server.hz
次,server.hz
的默认值为 10
。
1 | int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) { |
之所以需要缓存
lruclock
是因为mstime
方法是系统调用,消耗较高。也就是说在LRU
时钟分辨率为1000ms
时,只要每秒钟更新至少一次,我们就可以使用缓存的lruclock
。
空闲时间的计算
计算 key
的空闲时间 idle
的方法 estimateObjectIdleTime
定义在 evict.c
中:
- 获取
lruclock
- 计算空闲时间
idle
,考虑lruclock
折返分两种情况
Redis 并没有考虑时钟多次折返的情况,它假设时钟折返发生的频率非常低,认为一次折返已经覆盖了大部分情况。
1 | /* Given an object returns the min number of milliseconds the object was never |
在使用 LFU 策略时的评估机制
在使用 LFU
策略时,每次访问 key
,都会调用 updateLFU(val)
更新 lru
字段,该字段保存了两个值,分别是 ldt
(last decrement time,16 bit) 和 logc
(logistic counter,8 bit)。
1 | /* Update LFU when an object is accessed. |
logc 的定义和更新
显然,8 bit
的 logc
虽然节省了内存空间,但是不足以存储真正的访问次数,在设计上它存储的是访问次数的对数,在实现上 Redis
通过“加权衰减”机制模拟了 logc
的对数增长。计算的方法定义在 evict.c
中
1 | /* Logarithmically increment a counter. The greater is the current counter value |
根据代码,我们可以知道,增加“频率计数器”的概率的计算公式如下:
其中,LFU_INIT_VAL
是在使用 LFU
策略时用于初始化的默认值,默认为 5
;lfu_log_factor
是 LFU
的频率计数器的对数增长系数,默认为 10
。
ldt 的定义和更新
ldt
的全称是 last decrement time,意思是 logc
最后衰减的时间。由于每次在访问时,都会进行衰减处理,因此 ldt
也具备“最后访问时间”的含义。
在和 Chat GPT 吧啦吧啦的时候,对于衰减的时机一度产生了困惑,到底是访问时、定期的还是进行内存淘汰时?看代码到底是最靠谱的。
但是和使用 LRU
策略时不同,由于存储 ldt
的位数只有 16
位,计算的方式变成以下这样,取分钟级时间戳对 65535
取余。
1 | /* Return the current time in minutes, just taking the least significant |
“随时间衰减”的处理逻辑定义在 evict.c
中的 LFUDecrAndReturn
方法,它会根据当前时间和 ldt
的差值以及配置项 server.lfu_decay_time
决定是否对 logc
进行衰减计算,然后返回最终的结果。server.lfu_decay_time
的含义是每个 n
分钟衰减 1
吗?Chat GPT 又迷惑到我了。当取值为 0
时不衰减。
1 | unsigned long LFUDecrAndReturn(robj *o) { |
计算过去了多长时间,以分钟为单位,最多考虑一次时钟折返。
1 | /* Given an object last access time, compute the minimum number of minutes |
不常访问程度的计算
在使用 LFU
策略中,不常访问程度(尽管变量名仍为 idle
)的计算方式如下。注意:在内存淘汰时,尽管计算了衰减后的 logc
,但是并未更新 lru
字段。
1 | idle = 255-LFUDecrAndReturn(o); |
在使用 TTL 策略时的评估机制
在使用 TTL
策略时,计算距离过期远近程度的计算方式如下,只需要 db->expires
中存储的过期时间,
1 | idle = ULLONG_MAX - (long)dictGetVal(de); |
总结和感受
常见的内存淘汰算法并不难记忆和理解,Redis
的优化思路也并不复杂,但是我们难免会对黑盒中的一些细节产生好奇心,又会因为网上一些简化且不太准确的信息而产生困扰。这些问题,有时候如同鸡肋食之无味又弃之可惜,不探索无伤大雅但想起来又如鲠在喉,但是查看并理解相应源代码后豁然开朗的感受却是非常真实的。
深入探究 Redis
内存淘汰的具体实现,既可以帮助我们了解一个工业界成熟的内存淘汰算法实现方案是如何的,也可以了解到一些资料中一笔带过的信息背后的细节。