1.概论
Redis中的缓存淘汰算法大体分为两种,volatile-xxx和allkeys-xxx。volatile-xxx 是对带过期时间的 key 进行淘汰,即使用了expire的key;allkeys-xxx 策略会对所有的key 进行淘汰。如果只是用redis做缓存,几乎不使用持久化功能,则使用allkeys-xxx,每个key不带过期时间。而如果要使用持久化,或key的量级特别大的时候使用 volatile-xxx策略,这样可以保留没有设置过期时间的key,它们是永久的 key 不会被淘汰算法淘汰。
但即使设置了expire,也不是在他过期后直接淘汰,这样需要维护一个轮询的线程,还需要考虑读取个删除线程的冲突,会增加额外复杂度,故redis是在每次获取key时,先去检查是否过期,过期返回(nil)。
具体的淘汰策略中,比较常用的有LRU和LFU两种:
LRU(The Least Recently Used,最近最久未使用算法):如果一个数据在最近一段时间没有被访问到,那么可以认为在将来它被访问的可能性也很小。因此,当空间满时,最久没有访问的数据最先被置换(淘汰)。
LFU(Least Frequently Used ,最近最少使用算法):如果一个数据在最近一段时间很少被访问到,那么可以认为在将来它被访问的可能性也很小。因此,当空间满时,最小频率访问的数据最先被淘汰。
这是操作系统的课程中内存做换页时的两种经典算法,但实际实现起来非常麻烦,如果使用某些数据结构,但就LRU而言,就将近100行代码。(字节跳动就爱让你手撕这个,我好多同学面试的时候就把他背下来,直接默写)。
而Redis中的实现比较不同,个人认为最大的区别在于随机数的使用。详情如下
2.LRU 算法
从LRU的实现上来讲,要么额外存储一个访问时间字段,要么将所有key做成链表,每次读取key,对链表做重新排序。原生的LRU用的就是第二种消耗大量的额外的内存,且需要大量链表操作和排序。而Redis中的实习则很简单,类似于第一种,在现有数据结构的基础上使用随机采样法来淘汰元素,能达到和 LRU算法非常近似的效果(详细效果下面有图可以看到)。 Redis 为实现近似LRU 算法,它给每个 key 增加了一个额外的小字段,这个字段的长度是 24 个 bit,也就是最后一次被访问的时间戳。
而LRU 淘汰不一样,它的处理方式只有懒惰处理。当 Redis 执行写操作时,发现内存超出 maxmemory(想到要阈值,可以通过参数配置),就会执行一次LRU 淘汰算法。这个淘汰就是我上面说的随机的,随机采样出 5(默认5,可以通过参数配置) 个 key,然后淘汰掉最旧的 key,如果淘汰后内存还是超出 maxmemory,那就继续随机采样淘汰,直到内存低于maxmemory 为止。
而采样范围就是看maxmemory-policy选择的是allkeys还是volatile。
但这种随机性肯定会带来一个局部性问题,举例来说就是现在有10个key,由新到旧分别是1,2,3,4,5,6,7,8,9,10,如果现在要淘汰三个,严格的LRU应该淘汰8,9,10,但redis可能存在取样取出来1,2,3,4,5,此时只能淘汰5。这样的随机性可能带来淘汰的不严格,但因为实现简单,不会待读写key造成太大硬性,还是可以接收。
而且在Redis 3.0中对近似LRU还做了一点小优化,也就是增加了一个数组,叫淘汰池(eviction poo,大小是 maxmemory_samples,可设置),在每一次淘汰循环中,新随机出来的 key 列表会和淘汰池中的 key 列表进行比较和排序,淘汰掉最旧的一个 key 之后,保留剩余较旧的 key 列表放入淘汰池中留待下一个循环。因为随机性可能会使得淘汰的数据过新,上一次淘汰的数远比这次淘汰的数新,使用淘汰池将这个问题解决。注:淘汰池中的key并未清除,仍可读取。
这个是很小的优化,且内存和时间上的开销并不大,但对算法的提升还是很大的,如图
图中绿色部分是新加入的 key,深灰色部分是老旧的 key,浅灰色部分是通过 LRU 算法淘汰掉的 key。从图中可以看出采样数量越大,近似 LRU 算法的效果越接近严格 LRU算法。同时 Redis3.0 在算法中增加了淘汰池,进一步提升了近似 LRU 算法的效果。
3.LFU
LRU即最不经常使用页置换算法,要求在页置换时置换引用计数最小的页,因为经常使用的页应该有一个较大的引用次数。所以要存储两个额外字段,一个是时间区间,一个是访问次数。Redis在4.0以后支持,Redis使用使用24bit存储:其中16bit用于表示上一次递减时间 (时间戳的后16位),8bit表示访问次数。注:8位只能代表255,但是redis并没有采用线性上升的方式,而是通过一个复杂的公式,通过配置两个参数来调整数据的递增速度。如在影响因子 lfu-log-factor 为10的情况下,经过1百万次命中才能达到 255。
每次读取key会检查这24bit,根据当前时间戳key中那24bit存的时间的差值,如果距离现在超过 N 分钟(可配置lfu-decay-time),则递减或者减半,然后通过衰减后的24bit通过LFULogIncr()重新计算后更新这24bit。具体如何减少见代码
void updateLFU(robj *val) {
//衰减
unsigned long counter = LFUDecrAndReturn(val);
//根据衰减后的counter重新计算新的counter
counter = LFULogIncr(counter);
val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}
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;
//server.lfu_decay_time默认为1,每经过一分钟counter衰减1
if (num_periods)
counter = (num_periods > counter) ? 0 : counter - num_periods;//如果需要衰减,则计算衰减后的值
return counter;
}
uint8_t LFULogIncr(uint8_t counter) {
if (counter == 255) return 255;//counter最大只能存储到255,到达后不再增加
double r = (double)rand()/RAND_MAX;//算一个随机的小数值
//新加入的key初始counter设置为LFU_INIT_VAL,默认5
double baseval = counter - LFU_INIT_VAL;
if (baseval < 0) baseval = 0;
double p = 1.0/(baseval*server.lfu_log_factor+1);//server.lfu_log_facotr默认为10
//counter越大,则p越小,随机值r小于p的情况就越少,counter增加得越来越慢
if (r < p) counter++;
return counter;
}
unsigned long LFUGetTimeInMinutes(void) {
return (server.unixtime/60) & 65535;//获取分钟级别的时间戳
}
lfu随着分钟数对counter做衰减是基于一个原理:过去被大量访问的key不一定现在仍然会被访问。相当于除了计数,给时间也增加了一定的权重进行递减。
而默认情况下新写的key的后8位计数器的值为5(可配置),防止因为访问频率过低而直接被删除。
删除时,还是随机的,随机采样N个key(与LRU一样),淘汰25bit最小的。
参考文献:
[1] 官方文档,Key eviction。
[2] 钱文品,Redis深度历险:核心原理和应用实践,书籍。
[3]知乎:张老师,Redis中的LRU算法,一篇文章彻底搞懂。