LRU与LFU
LRU算法是淘汰链表尾部的元素,如果一个元素被访问过就放在链表的头部,其他元素在后面,根据这种方式,来算出元素的热度,排在最后的就是热度最低的,可以直接剔除。
但是这种算法会带来一个问题,如果一个key长时间都没有被访问,只是突然被用户访问了一下,就放到了链表的头部,这个元素在下次被淘汰发生时可能就被当成热点元素不被剔除,
LFU(Least Frequently Used)算法引入了时间的概念,其根据最近一段时间的访问频率来得出该key是否要被淘汰,如果是冷数据,只是突然被访问了一下,将有很大的概率被淘汰掉。
redis对象头部
// 头结构
typedef struct redisObject {
unsigned type:4; // 对象类型,例如zset、set、hash等
unsigned encoding:4; // 对象编码 例如 ziplist、intset、skiplist等
unsigned lru:24; // 对象的热度
int refcount; // 引用计数
void *ptr; // 对象body
}
LRU模式
LRU模式下,对象头中的lru字段存储的是redis时钟 server.lruclock ,redis时钟是一个24bit的整数,默认值是unix时间戳对2^24取模的结果,24个bit意味着可以存储194天左右的时间,194天后该值将会被清0。
当每个key被访问一次的时候,其对象头中的lru字段会被更新成当前的server.lruclock。
默认情况下,server.lruclock 每毫秒更新一次,在定时任务serverCron中设置。redis中很多定时任务都在serverCron中处理,例如大型hash渐进式迁移、过期key的主动淘汰,触发bgsave、bgaofrewrite等。
如果server.lruclock没有溢出折返,其的内容值是一直递增的,此时每个对象头中的lru字段不会超过server.lruclock的值,
如果对象头中的lru字段超过了server.lruclock的值,说明server.lruclock已经折返了,通过这两个逻辑,可以得出对象有多长时间没有被访问。
// 得出对象的空闲时间(多久没有被访问),单位是毫秒
unsigned long long estimateObjectIdleTime(robj *o) {
// 获取redis时钟,即 server.lruclock的值
unsigned long long lruclock = LRU_CLOCK();
if (lruclock >= o->lru) {
// 正常递增,使用 server.lruclock 减去对象的 lru 再乘上 1000 (LRU_CLOCK_RESOLUTION)
return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
} else {
// 折返了 使用 lruclock + (LRU_CLOCK_MAX(lruclock的最大值 2^24 -1) - 对象lru) 得出多久没有访问
return (lruclock + (LRU_CLOCK_MAX - o->lru)) *
LRU_CLOCK_RESOLUTION;
}
}
折返说明已经满了一轮了,所以需要用最大值减去对象的时间,得出上一轮闲了多久,再加上这一轮的时间,就是完整的空闲时间了,redis根据对象的空闲时间,决定谁新谁旧,进行淘汰。
LFU模式
在LFU模式下,lru的24个bit会被分成两份,一份是16bit的 ldt (last decrement time) ,另一份是8bit的 logc (logistic counter)。
logc
logc用于存储访问频次,最大值是255,每次被访问时进行更新,但是由于最大值只有255,所以该值的累加并不是简单的+1,而是访问计数的对数值,使用概率法进行递增,值越大,想累加该值就越难,
logc值还会随着时间衰减,越久没被访问,衰减的就越多,该值越小就越容易被回收,为了确保新创建的对象不会被回收,新对象的logc默认会给一个初始值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;
// 减去新对象初始时的基数(默认5)
double baseval = counter - LFU_INIT_VAL;
// baseval小于0,说明该对象快不行了,但是本次incr会延长他的寿命
if (baseval < 0) baseval = 0;
// 当前计数越大,想要+1就越难
// lfu_log_factor是困难系数,默认是10
// baseval非常大时(最大是255-5),p值很很小,很难走到counter++里去
// p如果大于随机数r,才有可能counter++,但是如果p很小的话,就很难了
double p = 1.0/(baseval*server.lfu_log_factor+1);
// 幸运儿 成功+1
if (r < p) counter++;
return counter;
}
ldt
ldt用于存储上一次logc的更新时间,取值为 使用分钟时间戳对2^16进行取模,最大值为 65535,以分钟为单位,也就是说45天左右就会进行折返一次,
根据这个逻辑,也可以算出一个对象的空闲时间,只是这个空间时间是以分钟为单位,而不是LRU模式下的毫秒。
/* Return the current time in minutes, just taking the least significant
* 16 bits. The returned time is suitable to be stored as LDT (last decrement
* time) for the LFU implementation. */
unsigned long LFUGetTimeInMinutes(void) {
// server.unixtime是redis缓存的系统时间戳,对65535取模,得到redis当下的ldt(注意是redis的,而不是某个对象的)
// server.unixtime也是每毫秒更新一次
return (server.unixtime/60) & 65535;
}
/* Given an object last access time, compute the minimum number of minutes
* that elapsed since the last access. Handle overflow (ldt greater than
* the current 16 bits minutes time) considering the time as wrapping
* exactly once. */
unsigned long LFUTimeElapsed(unsigned long ldt) {
unsigned long now = LFUGetTimeInMinutes();
// 系统的大于对象的,没有折返,正常比较返回
if (now >= ldt) return now-ldt;
// 折返了,用最大的减去对象的加上现在的,得到空闲时间
return 65535-ldt+now;
}
ldt不同于LRU模式下的lru字段,ldt不是在对象每次访问的时候进行更新的,而是在redis进行淘汰逻辑的时候进行更新,
当内存达到maxmemory的阈值时,每个指令执行前都将触发随机淘汰策略,随机挑选若干个key,对每个key的logc做衰减,大致就是得到当前的时间减去该对象上次的ldt时间,得到这个数字后,
除以一个衰减系数lfu_decay_time(默认为1,如果该值为0,就是不衰减的意思,如果该值大于1,那么衰减的就会比较慢,该值可以进行配置),最后使用key的logc减去这个值,算出最终的衰减数量,
例如当前时间是 120 ,该key的ldt是 64,那么就是 (120 - 64) / 1 = 56,最终该对象的logc会衰减为 64 - 56 = 8,
如果一个key的logc一直没有更新,也就是很久没访问时;或者更新的不是很频繁,那么势必会很小,所以最后发生淘汰时,使用时间做出衰减的时候,其值会被衰减的很小甚至可能为0,那么大概率就要被淘汰掉了,
没有被淘汰掉的相当于被手里的logc保活了一次,但是只能保活其一次,这个值就要被衰减掉,如果该值不衰减,那么从本次淘汰到下次淘汰发生之间,这个key一次都没被访问过,那么该值就可以逃过下次淘汰了,因此衰减的存在是必要的,免死令牌只能用一次。
// 衰减logc
unsigned long LFUDecrAndReturn(robj *o) {
// ldt 前16bit
unsigned long ldt = o->lru >> 8;
// 后8bit为logc
unsigned long counter = o->lru & 255;
// num_periods为即将衰减的数量
unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
if (num_periods)
counter = (num_periods > counter) ? 0 : counter - num_periods;
return counter;
}
因为是随机淘汰,如果key比较多,每个key的ldt更新的会比较慢,但是分钟级别的精度,不需要更新的太快。
缓存系统时间戳
因为使用 System.currentTimeInMillis 或者 time.time() 获取系统的毫秒时间戳时,每用一次都是一次系统调用,很浪费时间,因此需要有一个缓存,避免过多的浪费。
原子操作获取lruclock
由于redis背后还有几个线程在默默工作,这几个线程也需要访问redis始终,因此使用 atomicGet 获取时钟可以保证多线程下的数据一致性。
unsigned int LRU_CLOCK(void) {
unsigned int lruclock;
if (1000/server.hz <= LRU_CLOCK_RESOLUTION) {
atomicGet(server.lruclock,lruclock);
} else {
lruclock = getLRUClock();
}
return lruclock;
}
打开LFU模式
redis4.0对淘汰策略参数 maxmemory-policy 增加了2个选项,分别是volatile-lfu和allkeys-lfu,
前者对带过期时间的key执行LFU淘汰算法,后者是对所有key执行LFU淘汰算法,打开选项后,可以使用object freq指令获取对象的LFU计数值。
127.0.0.1:6379> config set maxmemory-policy allkeys-lfu
OK
127.0.0.1:6379> set hello world
127.0.0.1:6379> object freq hello
(integer) 5