LFU 算法的基本原理
因为 LFU 算法是根据数据访问的频率来选择被淘汰数据的,所以 LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。
访问频率是指在一定时间内的访问次数,也就是说,在计算访问频率时,我们不仅需要记录访问次数,还要记录这些访问是在多长时间内执行的。否则,如果只记录访问次数的话,就缺少了时间维度的信息,进而就无法按照频率来淘汰数据了。
LFU 算法的实现
LFU 算法的实现可以分成三部分内容,分别是键值对访问频率记录、键值对访问频率初始化和更新,以及 LFU 算法淘汰数据。
键值对访问频率记录
为了节省内存开销,Redis 源码就复用了 lru 变量来记录 LFU 算法所需的访问频率信息。
具体来说,当 lru 变量用来记录 LFU 算法的所需信息时,它会用 24 bits 中的低 8 bits 作为计数器,来记录键值对的访问次数,同时它会用 24 bits 中的高 16 bits,记录访问的时间戳。
键值对访问频率的初始化与更新
当一个键值对被创建后,createObject 函数就会被调用,用来分配 redisObject 结构体的空间和设置初始化值。如果 Redis 将 maxmemory-policy 设置为 LFU 算法,那么,键值对 redisObject 结构体中的 lru 变量初始化值,会由两部分组成:
- 第一部分是 lru 变量的高 16 位,是以 1 分钟为精度的 UNIX 时间戳。这是通过调用 LFUGetTimeInMinutes 函数(在 evict.c 文件中)计算得到的。
- 第二部分是 lru 变量的低 8 位,被设置为宏定义 LFU_INIT_VAL(在server.h文件中),默认值为 5。
robj *createObject(int type, void *ptr) {
robj *o = zmalloc(sizeof(*o));
...
//使用LFU算法时,lru变量包括以分钟为精度的UNIX时间戳和访问次数5
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
} else {
o->lru = LRU_CLOCK(); //使用LRU算法时的设置
}
return o;
}
当一个键值对被访问时,Redis 会调用 lookupKey 函数进行查找。当 maxmemory-policy 设置使用 LFU 算法时,lookupKey 函数会调用 updateLFU 函数来更新键值对的访问频率,也就是 lru 变量值:
robj *lookupKey(redisDb *db, robj *key, int flags) {
...
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
updateLFU(val); //使用LFU算法时,调用updateLFU函数更新访问频率
} else {
val->lru = LRU_CLOCK(); //使用LRU算法时,调用LRU_CLOCK
}
...
updateLFU 函数是在db.c文件中实现的,它的执行逻辑比较明确,一共分成三步:
- 第一步,根据距离上次访问的时长,衰减访问次数。
updateLFU 函数首先会调用 LFUDecrAndReturn 函数(在 evict.c 文件中),对键值对的访问次数进行衰减操作,如下所示:
void updateLFU(robj *val) {
unsigned long counter = LFUDecrAndReturn(val);
...
}
具体来说,LFUDecrAndReturn 函数会首先获取当前键值对的上一次访问时间,这是保存在 lru 变量高 16 位上的值。然后,LFUDecrAndReturn 函数会根据全局变量 server 的 lru_decay_time 成员变量的取值,来计算衰减的大小 num_period。
这个计算过程会判断 lfu_decay_time 的值是否为 0。如果 lfu_decay_time 值为 0,那么衰减大小也为 0。此时,访问次数不进行衰减。
否则的话,LFUDecrAndReturn 函数会调用 LFUTimeElapsed 函数(在 evict.c 文件中),计算距离键值对的上一次访问已经过去的时长。这个时长也是以 1 分钟为精度来计算的。有了距离上次访问的时长后,LFUDecrAndReturn 函数会把这个时长除以 lfu_decay_time 的值,并把结果作为访问次数的衰减大小。
Redis 在初始化时,会通过 initServerConfig 函数来设置 lfu_decay_time 变量的值,默认值为 1。所以,在默认情况下,访问次数的衰减大小就是等于上一次访问距离当前的分钟数。比如,假设上一次访问是 10 分钟前,那么在默认情况下,访问次数的衰减大小就等于 10。
unsigned long LFUDecrAndReturn(robj *o) {
unsigned long ldt = o->lru >> 8; //获取当前键值对的上一次访问时间
unsigned long counter = o->lru & 255; //获取当前的访问次数
unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0; //计算衰减大小
if (num_periods) //如果衰减大小不为0
//如果衰减大小小于当前访问次数,那么,衰减后的访问次数是当前访问次数减去衰减大小;否则,衰减后的访问次数等于0
counter = (num_periods > counter) ? 0 : counter - num_periods;
return counter; //如果衰减大小为0,则返回原来的访问次数
}
- 第二步,根据当前访问更新访问次数。
在这一步中,updateLFU 函数会调用 LFULogIncr 函数,来增加键值对的访问次数,如下所示:
void updateLFU(robj *val) {
...
counter = LFULogIncr(counter);
...
}
LFULogIncr 函数是在 evict.c 文件中实现的,它的执行逻辑主要包括两个分支:
- 第一个分支对应了当前访问次数等于最大值 255 的情况。此时,LFULogIncr 函数不再增加访问次数。
- 第二个分支对应了当前访问次数小于 255 的情况。此时,LFULogIncr 函数会计算一个阈值 p,以及一个取值为 0 到 1 之间的随机概率值 r。如果概率 r 小于阈值 p,那么 LFULogIncr 函数才会将访问次数加 1。否则的话,LFULogIncr 函数会返回当前的访问次数,不做更新。
阈值 p 的值大小,其实是由两个因素决定的。一个是当前访问次数和宏定义 LFU_INIT_VAL 的差值 baseval,另一个是 redis.conf 文件中定义的配置项 lfu-log-factor。
当计算阈值 p 时,我们是把 baseval 和 lfu-log-factor 乘积后,加上 1,然后再取其倒数。所以,baseval 或者 lfu-log-factor 越大,那么其倒数就越小,也就是阈值 p 就越小;反之,阈值 p 就越大。也就是说,这里其实就对应了两种影响因素。
- baseval 的大小:这反映了当前访问次数的多少。比如,访问次数越多的键值对,它的访问次数再增加的难度就会越大;
- lfu-log-factor 的大小:这是可以被设置的。也就是说,Redis 源码提供了让我们人为调节访问次数增加难度的方法。
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255; //访问次数已经等于255,直接返回255
double r = (double)rand()/RAND_MAX; //计算一个随机数
double baseval = counter - LFU_INIT_VAL; //计算当前访问次数和初始值的差值
if (baseval < 0) baseval = 0; //差值小于0,则将其设为0
double p = 1.0/(baseval*server.lfu_log_factor+1); //根据baseval和lfu_log_factor计算阈值p
if (r < p) counter++; //概率值小于阈值时,
return counter;
}
- 第三步,更新 lru 变量值。
到这一步,updateLFU 函数已经完成了键值对访问次数的更新。接着,它就会调用 LFUGetTimeInMinutes 函数,来获取当前的时间戳,并和更新后的访问次数组合,形成最新的访问频率信息,赋值给键值对的 lru 变量,如下所示:
void updateLFU(robj *val) {
...
val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}
LFU 算法淘汰数据
Redis 会使用一个全局数组 EvictionPoolLRU,来保存待淘汰候选键值对集合。然后,在 processCommand 函数处理每个命令时,它会调用 freeMemoryIfNeededAndSafe 函数和 freeMemoryIfNeeded 函数,来执行具体的数据淘汰流程。
淘汰流程分成三个步骤:
- 第一步,调用 getMaxmemoryState 函数计算待释放的内存空间
- ;第二步,调用 evictionPoolPopulate 函数随机采样键值对,并插入到待淘汰集合 EvictionPoolLRU 中;
- 第三步,遍历待淘汰集合 EvictionPoolLRU,选择实际被淘汰数据,并删除。
LFU 算法在淘汰数据时,在第二步的 evictionPoolPopulate 函数中,使用了不同的方法来计算每个待淘汰键值对的空闲时间。
实现 LFU 算法时,因为 LFU 算法会对访问次数进行衰减和按概率增加,所以,它是使用访问次数来近似表示访问频率的。相应的,LFU 算法其实是用 255 减去键值对的访问次数,这样来计算 EvictionPoolLRU 数组中每个元素的 idle 变量值的。而且,在计算 idle 变量值前,LFU 算法还会调用 LFUDecrAndReturn 函数,衰减一次键值对的访问次数,以便能更加准确地反映实际选择待淘汰数据时,数据的访问频率。
if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
idle = estimateObjectIdleTime(o);
} else if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
idle = 255-LFUDecrAndReturn(o);
}
LFU 算法按照访问频率,计算了待淘汰键值对集合中每个元素的 idle 值后,键值对访问次数越大,它的 idle 值就越小,反之 idle 值越大。而 EvictionPoolLRU 数组中的元素,是按 idle 值从小到大来排序的。最后当 freeMemoryIfNeeded 函数按照 idle 值从大到小,遍历 EvictionPoolLRU 数组,选择实际被淘汰的键值对时,它就能选出访问次数小的键值对了,也就是把访问频率低的键值对淘汰出去。
LFU_INIT_VAL为什么不设置为1?访问次数初始值太小,那这些新 key 的访问次数,很有可能在短时间内就被「衰减」为 0,那就会面临马上被淘汰的风险。
此文章为10月Day16学习笔记,内容来源于极客时间《Redis 源码剖析与实战》